diff --git a/MANUAL.md b/MANUAL.md
index 24cddab..2e3fd2b 100644
--- a/MANUAL.md
+++ b/MANUAL.md
@@ -136,6 +136,9 @@ The active card follows your reading position. On the feed screen:
- `O` opens or closes the active email thread
- action buttons show their keyboard shortcut when one is available
+Email cards with a source mailbox and explicit Gmail thread references show **Open in Gmail** beneath the title.
+The link opens the conversation in that account in a new tab, with separate links when a card covers several conversations.
+
### Card Actions
Card buttons describe the concrete next move, such as:
diff --git a/docs/pr-images/gmail-link-after.png b/docs/pr-images/gmail-link-after.png
new file mode 100644
index 0000000..026feed
Binary files /dev/null and b/docs/pr-images/gmail-link-after.png differ
diff --git a/docs/pr-images/gmail-link-before.png b/docs/pr-images/gmail-link-before.png
new file mode 100644
index 0000000..4e3359a
Binary files /dev/null and b/docs/pr-images/gmail-link-before.png differ
diff --git a/src/feed/CardView.tsx b/src/feed/CardView.tsx
index 0f2abb9..325a8d4 100644
--- a/src/feed/CardView.tsx
+++ b/src/feed/CardView.tsx
@@ -6,6 +6,7 @@ import { ApiError, post } from "../app/api";
import type { Card, CardAction, CardBlock, FeedView, WorkItemView } from "../types";
import { DetachedLink } from "../ui/DetachedLink";
import { FormattedText } from "../ui/FormattedText";
+import { gmailHref, GmailLinks } from "./GmailLinks";
import { ReadingIdentity } from "./ReadingIdentity";
import { ReadingPreferenceFooter } from "./ReadingPreferenceFooter";
import { visibleCardActions } from "./selectors";
@@ -124,7 +125,7 @@ function videoEmbedUrl(href: string): string | null {
return null;
}
-function Block({ feedId, cardId, block, onChanged, readingFace = false }: { feedId: string; cardId: string; block: CardBlock; onChanged: () => void; readingFace?: boolean }) {
+function Block({ feedId, cardId, block, sourceMailbox, onChanged, readingFace = false }: { feedId: string; cardId: string; block: CardBlock; sourceMailbox?: string; onChanged: () => void; readingFace?: boolean }) {
const [value, setValue] = useState(block.value ?? "");
useEffect(() => setValue(block.value ?? ""), [block.value]);
@@ -219,7 +220,7 @@ function Block({ feedId, cardId, block, onChanged, readingFace = false }: { feed
{typeof item === "string"
?
: item.href
- ? {item.label}
+ ? {item.label}
: }
))}
@@ -460,6 +461,7 @@ function ReadingCardView({ card, active, reaction, group, preference, onVersion,
{card.title}
+
{card.why}
@@ -468,7 +470,7 @@ function ReadingCardView({ card, active, reaction, group, preference, onVersion,
{card.blocks.map((block) => block.type === "quote"
?
{block.text}
{block.attribution && {block.attribution}}
- :
)}
+ :
)}
View this source run
@@ -545,6 +547,7 @@ export function CardView({
{card.eyebrow}
{card.title}
+
@@ -552,6 +555,7 @@ export function CardView({
{card.blocks.map((block) =>
();
+ const gmailReceipt = card.blocks.some(block => block.type === "receipt" && /\bGmail\b/i.test(block.text ?? ""));
+ const addIds = (text: string) => {
+ for (const match of text.matchAll(/\b[0-9a-f]{12,20}\b/gi)) {
+ links.add(`https://mail.google.com/mail/u/?authuser=${encodeURIComponent(mailbox)}#all/${match[0].toLowerCase()}`);
+ }
+ };
+ for (const block of card.blocks) {
+ if (block.type === "receipt" && gmailReceipt) {
+ for (const match of (block.text ?? "").matchAll(/\b(?:Gmail\s+)?thread\s+IDs?\s*:\s*([^.;\n]+)/gi)) addIds(match[1]);
+ }
+ if (block.type !== "evidence") continue;
+ for (const item of block.items ?? []) {
+ if (typeof item === "string") continue;
+ if (/^Gmail\s+thread(?:\s+IDs?)?$/i.test(item.label.trim())
+ || (gmailReceipt && /^thread(?:\s+IDs?)?$/i.test(item.label.trim()))) addIds(item.detail ?? "");
+ const href = item.href && gmailHref(item.href, mailbox);
+ if (!href) continue;
+ const hash = new URL(href).hash;
+ const thread = hash.match(/^#(?:inbox|all|sent|trash|spam)\/([0-9a-f]{12,20})$/i);
+ if (thread) addIds(thread[1]);
+ else if (hash.includes("/") || hash.startsWith("#thread-f:")) links.add(href);
+ }
+ }
+ return [...links];
+}
+
+export function GmailLinks({ card }: { card: Card }) {
+ const links = gmailLinks(card);
+ if (!links.length) return null;
+ return {links.map((href, index) => (
+
+ {links.length === 1 ? "Open in Gmail" : `Open email ${index + 1} in Gmail`}
+
+ ))}
;
+}
diff --git a/src/styles.css b/src/styles.css
index e31aa8b..0ef74e2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -57,6 +57,7 @@ kbd { font: 11px/1 var(--mono); color: var(--ink-3); }
.attention-card.has-context-influence .card-rule { background: #b68552; }
.attention-card.has-context-influence.is-active .card-rule { background: var(--blue); }
.card-head { display: flex; align-items: flex-start; gap: 12px; }
+.gmail-links { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 8px; font-size: 12px; }
.kind-dot { flex: 0 0 auto; width: 10px; height: 10px; margin-top: 7px; border-radius: 50%; background: #82909b; }
.kind-dot.proposal { background: #b98649; }
.eyebrow { margin-bottom: 5px; color: var(--ink-3); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
diff --git a/test/gmail-links.test.tsx b/test/gmail-links.test.tsx
new file mode 100644
index 0000000..30bca34
--- /dev/null
+++ b/test/gmail-links.test.tsx
@@ -0,0 +1,73 @@
+import { expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { Card } from "../shared/types";
+import { gmailHref, gmailLinks, GmailLinks } from "../src/feed/GmailLinks";
+import { CardView } from "../src/feed/CardView";
+
+const card: Card = {
+ id: "source-email", feedId: "inbox", kind: "attention", status: "to_review_new",
+ title: "Workshop attendee list", eyebrow: "Email", why: "Review the attendee list.",
+ sourceMailbox: "owner+work@example.com",
+ blocks: [{ id: "receipt", type: "receipt", text: "Gmail thread IDs: 1234567890abcdef and fedcba0987654321." }],
+ readyForPass: 1, createdAt: "2026-09-16T12:00:00Z", updatedAt: "2026-09-16T12:00:00Z", history: [],
+};
+
+test("opens each referenced conversation in the source account, including archived mail", () => {
+ expect(gmailLinks(card)).toEqual([
+ "https://mail.google.com/mail/u/?authuser=owner%2Bwork%40example.com#all/1234567890abcdef",
+ "https://mail.google.com/mail/u/?authuser=owner%2Bwork%40example.com#all/fedcba0987654321",
+ ]);
+});
+
+test("uses evidence references and deduplicates the same thread", () => {
+ expect(gmailLinks({ ...card, blocks: [{
+ id: "sources", type: "evidence", items: [
+ { label: "Gmail thread", detail: "1234567890abcdef" },
+ { label: "Gmail thread", detail: "1234567890abcdef" },
+ { label: "Original", href: "https://mail.google.com/mail/u/0/#inbox/fedcba0987654321" },
+ ],
+ }] })).toEqual(gmailLinks(card));
+});
+
+test("does not guess from the title, card ID, arbitrary text or an unrelated URL", () => {
+ expect(gmailLinks({ ...card, id: "email-1234567890abcdef", blocks: [
+ { id: "memo", type: "memo", text: "Thread ID: 1234567890abcdef" },
+ { id: "receipt", type: "receipt", text: "Transaction ID: 1234567890abcdef" },
+ { id: "source", type: "evidence", items: [{ label: "Original", href: "https://mail.google.com.evil.example/#all/1234567890abcdef" }] },
+ ] })).toEqual([]);
+ expect(gmailLinks({ ...card, sourceMailbox: undefined })).toEqual([]);
+});
+
+test("renders detached links and omits the control when no reference exists", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("Open email 1 in Gmail");
+ expect(html).toContain("Open email 2 in Gmail");
+ expect(html).toContain('target="_blank" rel="noopener noreferrer"');
+ expect(renderToStaticMarkup()).toBe("");
+});
+
+
+test("keeps explicit modern Gmail references and fixes their account selector", () => {
+ const href = "https://mail.google.com/mail/u/0/#inbox/FMfcgzExample";
+ expect(gmailLinks({ ...card, blocks: [{ id: "source", type: "evidence", items: [{ label: "Original", href }] }] }))
+ .toEqual(["https://mail.google.com/mail/u/?authuser=owner%2Bwork%40example.com#inbox/FMfcgzExample"]);
+ expect(gmailHref(href, " owner@example.com ")).toContain("authuser=owner%40example.com");
+ expect(gmailLinks({ ...card, sourceMailbox: " " })).toEqual([]);
+});
+
+test("requires Gmail context before interpreting plain thread IDs", () => {
+ expect(gmailLinks({ ...card, blocks: [{ id: "receipt", type: "receipt", text: "Thread ID: 1234567890abcdef." }] })).toEqual([]);
+});
+
+test("uses the source account in both the header and original evidence link", () => {
+ const html = renderToStaticMarkup( {}} onChanged={() => {}}
+ onAction={() => {}} onReturnToReview={() => {}}
+ />);
+ expect(html).toContain("Open in Gmail");
+ expect(html).not.toContain("/mail/u/0/");
+ expect(html.match(/authuser=owner%2Bwork%40example.com/g)).toHaveLength(2);
+});