Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision b258a79c2fe38a0e36e15b6b37200f98dc0c951c)
+++ src/services/ActivityPubService.js	(revision 0101d0a23f8210828d4680832a5f1d30b5772d63)
@@ -2849,5 +2849,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 15; // v15: re-capture emoji_json/link_json for boosts that arrived before the Announce-path tag fix
+const SELFHEAL_VERSION = 16; // v16: resolve external link previews (embed_json) for posts that predate the embed pipeline
 async function fetchNoteAP(url) {
   try {
@@ -3159,5 +3159,5 @@
     if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
     let rows = [];
-    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
+    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     let healed = 0, failed = 0;
     for (const r of rows) {
@@ -3185,4 +3185,12 @@
           const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
           if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
+        }
+        // v16: link previews. A post from before the embed pipeline has no
+        // card at all, which is why nothing showed. Only for rows that have no
+        // quote (a quote already IS the card) and no embed yet, so this costs
+        // one page fetch per candidate and never repeats.
+        if (!r.quote_json && !r.embed_json) {
+          const ej = await resolveExternalEmbed(html || r.content).catch(() => null);
+          if (ej) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id); } catch { /* ignore */ } }
         }
         // v14: same for the booster's display name ("X boosted"). The row stores
