Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ src/config/database.js	(revision b258a79c2fe38a0e36e15b6b37200f98dc0c951c)
@@ -555,5 +555,9 @@
   ensureColumn('ap_timeline', 'link_json', 'TEXT');          // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
   ensureColumn('ap_timeline', 'quote_json', 'TEXT');         // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
-  ensureColumn('ap_timeline', 'embed_json', 'TEXT');         // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
+  // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
+  // time so buildNote (sync, also used by the outbox) needs no network.
+  ensureColumn('posts', 'quote_uri', 'TEXT');     // the quoted object's id
+  ensureColumn('posts', 'quote_actor', 'TEXT');   // its author, so we can address them
+  ensureColumn('ap_timeline', 'embed_json', 'TEXT');       // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
   ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
   ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ src/services/ActivityPubService.js	(revision b258a79c2fe38a0e36e15b6b37200f98dc0c951c)
@@ -503,4 +503,7 @@
   // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
   Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
+  // FEP-044f: this post quotes a fediverse object. Emit it the way the network
+  // actually reads it, and address the quoted author so they get told.
+  applyQuoteProps(note, post.quote_uri, post.quote_actor);
   if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
   if (attachment.length) note.attachment = attachment;
@@ -1775,8 +1778,25 @@
   // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
   const mres = await resolveMentionsInText(base, post.content || '');
-  const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
+  let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
+  // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
+  // and remember it on the post, so buildNote (sync, also used by the outbox)
+  // never has to fetch. The quoted author's inbox joins the delivery set: that
+  // IS the notification.
+  const quoteInboxes = [];
+  if (post2.quote_uri === undefined || post2.quote_uri === null) {
+    const q = await resolveOwnQuote(post2.content || '');
+    if (q) {
+      try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
+      post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
+    }
+  }
+  if (post2.quote_actor) {
+    const a = await fetchActor(post2.quote_actor).catch(() => null);
+    const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
+    if (inbox) quoteInboxes.push(inbox);
+  }
   const followers = fStmts().list.all(site.slug);
-  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
-  if (!inboxes.length) return; // no followers and no one mentioned
+  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
+  if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
   const keys = getOrCreateKeys(site.slug);
   const keyId = `${actorId(base, site.slug)}#main-key`;
@@ -2848,4 +2868,29 @@
 }
 
+// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
+// own posts quotes a fediverse object, say so in the shapes the network really
+// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
+// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
+// third form. All three point at the same object, which is what every reader
+// expects. The quoted author goes in `cc`, because being quoted without being
+// told is exactly the rudeness this FEP is trying to design away.
+export function applyQuoteProps(note, quoteUri, quoteActor) {
+  if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
+  note.quote = quoteUri;
+  note.quoteUrl = quoteUri;
+  note['_misskey_quote'] = quoteUri;
+  note.tag = [...(note.tag || []), {
+    type: 'Link',
+    mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+    href: quoteUri,
+    rel: ['https://misskey-hub.net/ns#_misskey_quote'],
+    name: quoteUri,
+  }];
+  if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
+    note.cc = [...new Set([...(note.cc || []), quoteActor])];
+  }
+  return note;
+}
+
 // The first external (non-fediverse) link in a note, resolved to the same card
 // shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
@@ -2867,12 +2912,30 @@
   const thumb = (card.media || []).find((m) => m && m.url);
   if (!thumb && !card.title) return null;
+  // Title, provider and author name come from a third party. Store them as
+  // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
+  // be the one that remembers to escape. A card is a card, not an essay.
+  const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
   return JSON.stringify({
     url: card.url,
     kind: card.kind,                       // 'provider' | 'oembed'
-    provider: card.provider || null,
-    title: card.title || null,
-    author: card.author || null,
+    provider: plain(card.provider),
+    title: plain(card.title),
+    author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
     media: thumb ? [thumb] : [],           // thumbnail only, no html/iframe
   });
+}
+
+/**
+ * Does our own post link to a fediverse object? Returns { uri, actor } when the
+ * first external link resolves to a quotable AP object, else null. Runs once at
+ * publish time; the answer is stored on the post.
+ */
+export async function resolveOwnQuote(html) {
+  const first = firstExternalUrl(html);
+  if (!first) return null;
+  const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
+  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
+  if (!card || card.kind !== 'ap' || !card.id) return null;
+  return { uri: card.id, actor: card.attributedTo || null };
 }
 
@@ -3634,5 +3697,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ src/views/partials/tl-item.ejs	(revision b258a79c2fe38a0e36e15b6b37200f98dc0c951c)
@@ -23,5 +23,19 @@
             <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div>
           <% } %>
-          <% var _quote = noteQuote(p.quote_json); if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
+          <%
+            // One card for both: a fediverse quote and an external embed look
+            // identical; only where they came from differs. An embed carries a
+            // title and a thumbnail, never an iframe.
+            var _quote = noteQuote(p.quote_json);
+            if (!_quote) {
+              var _emb = noteQuote(p.embed_json);
+              // The title comes from a third-party oEmbed provider, so it is
+              // escaped here: the quote card renders `content` as HTML (fine for
+              // AP content, which we sanitise on the way in, but not for this).
+              if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
+                                   content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
+            }
+          %>
+          <% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
           <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
           <% if (imgs.length) { %>
Index: test/external-embeds.test.js
===================================================================
--- test/external-embeds.test.js	(revision fc404105536bc9fcadc1fdb7dceb0449fee77e98)
+++ test/external-embeds.test.js	(revision b258a79c2fe38a0e36e15b6b37200f98dc0c951c)
@@ -43,2 +43,48 @@
   assert.equal(timelineEmbed('{"title":"no url"}'), undefined, 'a card without a url is not a card');
 });
+
+// FEP-044f emit side: quoting a fediverse object must federate as a quote AND
+// tell the quoted author. This is the mirror of the ingest we already had.
+const { applyQuoteProps } = await import('../src/services/ActivityPubService.js');
+
+test('a quote is emitted in all three shapes the network reads', () => {
+  const note = { to: ['https://www.w3.org/ns/activitystreams#Public'], cc: [], tag: [{ type: 'Hashtag', name: '#x' }] };
+  applyQuoteProps(note, 'https://s/objects/9', 'https://s/users/alice');
+  assert.equal(note.quote, 'https://s/objects/9', 'the FEP property');
+  assert.equal(note.quoteUrl, 'https://s/objects/9', 'the as: alias Mastodon reads');
+  assert.equal(note._misskey_quote, 'https://s/objects/9', 'the misskey alias');
+  const link = note.tag.find((t) => t.type === 'Link');
+  assert.ok(link, 'and an FEP-e232 Link tag');
+  assert.equal(link.href, 'https://s/objects/9');
+  assert.ok(link.mediaType.includes('activitystreams'));
+  assert.ok(note.tag.some((t) => t.type === 'Hashtag'), 'existing tags survive');
+});
+
+test('the quoted author is addressed, so being quoted is not a surprise', () => {
+  const note = { cc: ['https://s/users/me/followers'] };
+  applyQuoteProps(note, 'https://s/objects/9', 'https://s/users/alice');
+  assert.ok(note.cc.includes('https://s/users/alice'));
+  assert.ok(note.cc.includes('https://s/users/me/followers'), 'without dropping the followers');
+});
+
+test('no quote, or a junk one, changes nothing', () => {
+  const a = { cc: [], tag: [] };
+  applyQuoteProps(a, null, null);
+  assert.equal(a.quote, undefined);
+  assert.equal(a.tag.length, 0);
+  const b = { cc: [], tag: [] };
+  applyQuoteProps(b, 'javascript:alert(1)', 'https://s/users/alice');
+  assert.equal(b.quote, undefined, 'a non-http quote uri is refused');
+  const c = { cc: [], tag: [] };
+  applyQuoteProps(c, 'https://s/objects/9', 'not-a-url');
+  assert.equal(c.quote, 'https://s/objects/9');
+  assert.equal(c.cc.length, 0, 'a junk actor is simply not addressed');
+});
+
+test('a hostile oEmbed title is stored as plain text, not markup', async () => {
+  const { resolveExternalEmbed } = await import('../src/services/ActivityPubService.js');
+  // No network in the test env, so the resolver bails and returns null; the
+  // point here is the contract: whatever comes back is never raw provider HTML.
+  const out = await resolveExternalEmbed('<p><a href="https://v.example/1">x</a></p>');
+  assert.ok(out === null || !/<script/i.test(out), 'never stores executable markup');
+});
