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
Index: src/services/EmbedResolver.js
===================================================================
--- src/services/EmbedResolver.js	(revision b258a79c2fe38a0e36e15b6b37200f98dc0c951c)
+++ src/services/EmbedResolver.js	(revision 0101d0a23f8210828d4680832a5f1d30b5772d63)
@@ -35,4 +35,28 @@
 function decodeEntities(s) {
   return String(s).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
+}
+
+/**
+ * OpenGraph, the one that actually carries link previews on the open web.
+ * oEmbed is the richer protocol but most sites simply do not implement it;
+ * og:image / og:title is what Mastodon and everyone else reads, so it is the
+ * fallback that makes thumbnails appear at all. Same page fetch as the oEmbed
+ * discovery, so it costs nothing extra.
+ */
+export function findOpenGraph(html) {
+  if (!html || typeof html !== 'string') return null;
+  const meta = {};
+  for (const tag of html.match(/<meta\b[^>]*>/gi) || []) {
+    const key = (tag.match(/\b(?:property|name)\s*=\s*["']([^"']+)["']/i) || [])[1];
+    if (!key) continue;
+    const k = key.toLowerCase();
+    if (!/^(og:image|og:title|og:site_name|og:description|twitter:image|twitter:title)$/.test(k)) continue;
+    const val = (tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1];
+    if (val && !meta[k]) meta[k] = decodeEntities(val);
+  }
+  const image = meta['og:image'] || meta['twitter:image'];
+  const title = meta['og:title'] || meta['twitter:title'];
+  if (!image && !title) return null;
+  return { image: image && /^https?:\/\//i.test(image) ? image : null, title: title || null, site: meta['og:site_name'] || null };
 }
 
@@ -118,12 +142,27 @@
   }
 
-  // 3. oEmbed: the generic path for everything else.
-  if (io.getPage && io.getJSON) {
+  // 3. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer
+  //    protocol, OpenGraph is the one most of the web actually ships.
+  if (io.getPage) {
     const page = await io.getPage(url).catch(() => null);
-    const endpoint = findOEmbedEndpoint(page);
-    if (endpoint) {
-      const o = await io.getJSON(endpoint).catch(() => null);
-      const card = fromOEmbed(url, o);
-      if (card) return card;
+    if (page) {
+      const endpoint = findOEmbedEndpoint(page);
+      if (endpoint && io.getJSON) {
+        const o = await io.getJSON(endpoint).catch(() => null);
+        const card = fromOEmbed(url, o);
+        if (card) return card;
+      }
+      const og = findOpenGraph(page);
+      if (og) {
+        return {
+          kind: 'opengraph',
+          url,
+          title: og.title,
+          author: og.site ? { name: og.site, handle: null, icon: null } : null,
+          provider: og.site,
+          html: null,
+          media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
+        };
+      }
     }
   }
@@ -140,8 +179,9 @@
 
 const MAX_BODY = 512_000;   // an oEmbed page/endpoint is small; refuse the rest
-
-async function safeText(safeFetch, url, accept) {
+const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
+
+async function safeText(safeFetch, url, accept, extra = {}) {
   try {
-    const r = await safeFetch(url, { headers: { Accept: accept } });
+    const r = await safeFetch(url, { headers: { Accept: accept, ...extra } });
     if (!r.ok) return null;
     if (Number(r.headers.get('content-length') || 0) > MAX_BODY) return null;
@@ -163,5 +203,7 @@
       try { return JSON.parse(body); } catch { return null; }   // an HTML page is simply not AP
     },
-    getPage: (u) => safeText(safeFetch, u, 'text/html'),
+    // Plenty of sites only hand out their OpenGraph tags to something that
+    // looks like a browser, so the page fetch identifies itself.
+    getPage: (u) => safeText(safeFetch, u, 'text/html', { 'User-Agent': UA }),
     getJSON: async (u) => {
       const body = await safeText(safeFetch, u, 'application/json');
@@ -179,3 +221,3 @@
 }
 
-export default { resolveEmbed, findOEmbedEndpoint, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
+export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
