Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file added docs/pr-images/gmail-link-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-images/gmail-link-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 7 additions & 3 deletions src/feed/CardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -219,7 +220,7 @@ function Block({ feedId, cardId, block, onChanged, readingFace = false }: { feed
{typeof item === "string"
? <FormattedText text={item} />
: item.href
? <DetachedLink href={item.href}>{item.label}</DetachedLink>
? <DetachedLink href={gmailHref(item.href, sourceMailbox) ?? item.href}>{item.label}</DetachedLink>
: <FormattedText text={item.label} />}
</li>
))}</ul>
Expand Down Expand Up @@ -460,6 +461,7 @@ function ReadingCardView({ card, active, reaction, group, preference, onVersion,
</div>
</div>
<h2>{card.title}</h2>
<GmailLinks card={card} />
</div>
</header>
<p className="why reading-face">{card.why}</p>
Expand All @@ -468,7 +470,7 @@ function ReadingCardView({ card, active, reaction, group, preference, onVersion,
<div className="blocks">
{card.blocks.map((block) => block.type === "quote"
? <blockquote className="block block-quote" key={block.id}><p>{block.text}</p>{block.attribution && <cite>{block.attribution}</cite>}</blockquote>
: <Block key={block.id} feedId={card.feedId} cardId={card.id} block={block.type === "editable_text" ? { ...block, type: "memo", text: block.value ?? block.text } : block} onChanged={onChanged} />)}
: <Block key={block.id} sourceMailbox={card.sourceMailbox} feedId={card.feedId} cardId={card.id} block={block.type === "editable_text" ? { ...block, type: "memo", text: block.value ?? block.text } : block} onChanged={onChanged} />)}
</div>
<a className="reading-run-link" href={`/feed/${encodeURIComponent(card.feedId)}/prompts#source-run-${encodeURIComponent(reading.runId)}`}>View this source run</a>
<CardHistory card={card} />
Expand Down Expand Up @@ -545,13 +547,15 @@ export function CardView({
<div>
<div className="eyebrow">{card.eyebrow}</div>
<h2>{card.title}</h2>
<GmailLinks card={card} />
</div>
</header>
<p className={`why${card.readingPresentation ? " reading-face" : ""}`}><FormattedText text={card.why} /></p>
<ContextInfluenceReceipt card={card} />
<div className="blocks">
{card.blocks.map((block) => <Block
key={block.id}
sourceMailbox={card.sourceMailbox}
feedId={card.feedId}
cardId={card.id}
block={block}
Expand Down
55 changes: 55 additions & 0 deletions src/feed/GmailLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Card } from "../../shared/types";
import { DetachedLink } from "../ui/DetachedLink";

export function gmailHref(href: string, mailbox?: string): string | null {
if (!mailbox?.trim()) return null;
try {
const url = new URL(href);
if (url.origin !== "https://mail.google.com" || !url.pathname.startsWith("/mail/")) return null;
url.pathname = "/mail/u/";
url.searchParams.set("authuser", mailbox.trim());
return url.href;
} catch {
return null;
}
}

export function gmailLinks(card: Card): string[] {
const mailbox = card.sourceMailbox?.trim();
if (!mailbox) return [];
const links = new Set<string>();
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 <div className="gmail-links">{links.map((href, index) => (
<DetachedLink key={href} href={href}>
{links.length === 1 ? "Open in Gmail" : `Open email ${index + 1} in Gmail`}
</DetachedLink>
))}</div>;
}
1 change: 1 addition & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
73 changes: 73 additions & 0 deletions test/gmail-links.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GmailLinks card={card} />);
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(<GmailLinks card={{ ...card, blocks: [] }} />)).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(<CardView
card={{ ...card, blocks: [{ id: "source", type: "evidence", items: [
{ label: "Original", href: "https://mail.google.com/mail/u/0/#inbox/1234567890abcdef" },
] }] }}
active={false} onActivate={() => {}} 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);
});