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); +});