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
7 changes: 7 additions & 0 deletions MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ The normal loop is:

The local Tend runtime owns feed state. Codex Desktop owns the agent threads and connector access.

### Email dates

Email cards show the received date and time from their saved source metadata beneath the title, including older cards with that evidence.
Times use your browser's timezone and include the timezone label.
Cards citing several email threads show each matched thread's subject and date.
Collection and card creation times are never substituted for an email date.

## Creating And Connecting A Feed

### Create The Feed
Expand Down
Binary file added docs/pr-images/email-date-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/email-date-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 18 additions & 1 deletion server/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync } from "node:fs";
import { appendFile, mkdir, readdir, readFile, rename, stat } from "node:fs/promises";
import path from "node:path";
import { sourceEmailDates } from "../shared/emailTimestamp";
import type {
AgentPresence,
AgentPresenceLiveness,
Expand Down Expand Up @@ -450,6 +451,21 @@ export class AttentionStore {
this.readDrainState(feedId),
]);
cards.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const emailRunIds = new Set(cards.flatMap((card) => card.sourceRunIds ?? []));
const emailSnapshots = new Map<string, readonly unknown[]>(await Promise.all(runs.filter((run) => emailRunIds.has(run.id)).map(async (run) => {
try {
const snapshots = await this.readSourceSnapshots(run);
return [run.id, snapshots.map((snapshot) => snapshot.value)] as const;
} catch {
console.warn(`Could not load email dates for source run ${run.id}.`);
return [run.id, []] as const;
}
})));
for (const card of cards) {
const dates = sourceEmailDates(card, (card.sourceRunIds ?? []).flatMap((id) => emailSnapshots.get(id) ?? []));
if (dates.length) card.emailDates = dates;
else delete card.emailDates;
}
cards.forEach(projectReadingPresentation);
runs.sort((a, b) => (a.completedAt ?? "").localeCompare(b.completedAt ?? "") || a.id.localeCompare(b.id));
routineActions.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
Expand Down Expand Up @@ -665,7 +681,8 @@ export class AttentionStore {
card.updatedAt = card.reading && existing && now <= existing.updatedAt
? new Date(Date.parse(existing.updatedAt) + 1).toISOString()
: now;
await this.cards.write(card);
const { emailDates: _emailDates, ...stored } = card;
await this.cards.write(stored);
}

async removeCard(feedId: string, cardId: string): Promise<void> {
Expand Down
42 changes: 42 additions & 0 deletions shared/emailTimestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Card } from "./types";

export function isEmailTimestamp(value: unknown): value is string {
return typeof value === "string"
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
&& Number.isFinite(Date.parse(value));
}

function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}

export function sourceEmailDates(card: Card, snapshots: unknown[]): NonNullable<Card["emailDates"]> {
const references = JSON.stringify([card.id, card.blocks]);
const emails = new Map<string, NonNullable<Card["emailDates"]>[number]>();
for (const snapshot of snapshots) {
const source = record(snapshot);
if (!source) continue;
const threadId = source.threadId ?? (Array.isArray(source.messages) ? source.id : undefined);
if (typeof threadId !== "string" || !threadId) continue;
if (card.sourceMailbox && source.sourceMailbox && source.sourceMailbox !== card.sourceMailbox) continue;
const escapedId = threadId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!new RegExp(`(?<![a-zA-Z0-9])${escapedId}(?![a-zA-Z0-9_-])`).test(references)) continue;
const dates = isEmailTimestamp(source.receivedAt) ? [source.receivedAt] : [];
for (const value of Array.isArray(source.messages) ? source.messages : []) {
const message = record(value);
const timestamp = message?.internalDate ?? message?.internal_date;
if ((typeof timestamp !== "string" && typeof timestamp !== "number") || !/^\d+$/.test(String(timestamp))) continue;
const date = new Date(Number(timestamp));
if (Number.isFinite(date.getTime())) dates.push(date.toISOString());
}
for (const receivedAt of dates) {
const previous = emails.get(threadId);
if (previous && Date.parse(previous.receivedAt) >= Date.parse(receivedAt)) continue;
emails.set(threadId, {
threadId, receivedAt,
...(typeof source.subject === "string" ? { subject: source.subject } : {}),
});
}
}
return [...emails.values()].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt));
}
1 change: 1 addition & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export interface Card {
eyebrow: string;
why: string;
sourceMailbox?: string;
emailDates?: Array<{ threadId: string; receivedAt: string; subject?: string }>;
sourceRunIds?: string[];
contextInfluence?: CardContextInfluence;
reading?: CardReading;
Expand Down
3 changes: 3 additions & 0 deletions src/feed/CardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Card, CardAction, CardBlock, FeedView, WorkItemView } from "../typ
import { DetachedLink } from "../ui/DetachedLink";
import { FormattedText } from "../ui/FormattedText";
import { ReadingIdentity } from "./ReadingIdentity";
import { EmailTimestamp } from "./EmailTimestamp";
import { ReadingPreferenceFooter } from "./ReadingPreferenceFooter";
import { visibleCardActions } from "./selectors";

Expand Down Expand Up @@ -460,6 +461,7 @@ function ReadingCardView({ card, active, reaction, group, preference, onVersion,
</div>
</div>
<h2>{card.title}</h2>
<EmailTimestamp card={card} />
</div>
</header>
<p className="why reading-face">{card.why}</p>
Expand Down Expand Up @@ -545,6 +547,7 @@ export function CardView({
<div>
<div className="eyebrow">{card.eyebrow}</div>
<h2>{card.title}</h2>
<EmailTimestamp card={card} />
</div>
</header>
<p className={`why${card.readingPresentation ? " reading-face" : ""}`}><FormattedText text={card.why} /></p>
Expand Down
17 changes: 17 additions & 0 deletions src/feed/EmailTimestamp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { isEmailTimestamp } from "../../shared/emailTimestamp";
import type { Card } from "../../shared/types";

export function EmailTimestamp({ card }: { card: Card }) {
const dates = card.emailDates?.filter((email) => isEmailTimestamp(email.receivedAt)) ?? [];
if (!dates.length) return null;
const format = new Intl.DateTimeFormat(undefined, {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", timeZoneName: "short",
});
return <div className="email-timestamp">{dates.map((email) => (
<div key={email.threadId}>
{dates.length > 1 ? `${email.subject ?? "Email"}: ` : "Email received "}
<time dateTime={email.receivedAt}>{format.format(new Date(email.receivedAt))}</time>
</div>
))}</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; }
.email-timestamp { margin-top: 6px; color: var(--ink-2); 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
7 changes: 7 additions & 0 deletions test/card-render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1485,3 +1485,10 @@ test("engagement distinguishes source expansion, collapse and links without pres
expect(engagementClickTarget(ui.getByRole("link", { name: "Fixture transcript" }))).toBe("source_link");
expect(engagementClickTarget(ui.getByRole("heading", { name: readingCard().title }))).toBe("card");
});

test("reading cards keep the source email timestamp visible outside Sources", () => {
const card = readingCard({ emailDates: [{ threadId: "abc123", receivedAt: "2026-07-15T11:47:00+02:00" }] });
const html = renderToStaticMarkup(<CardView card={card} active={false} onActivate={() => {}} onChanged={() => {}} onAction={() => {}} onReturnToReview={() => {}} />);
expect(html).toContain('dateTime="2026-07-15T11:47:00+02:00"');
expect(html.indexOf("Email received")).toBeLessThan(html.indexOf("</header>"));
});
22 changes: 22 additions & 0 deletions test/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4073,3 +4073,25 @@ describe("local card dismissal (Tend-only, no source cleanup)", () => {
expect((await store.readCard("inbox", legacy.id)).actions).toEqual(legacy.actions);
});
});

test("feed reads project saved email dates without rewriting old cards", async () => {
const { store, domain } = await setup();
const snapshot = {
kind: "gmail_thread_metadata", threadId: "abc123", sourceMailbox: "owner@example.com",
subject: "Workshop", receivedAt: "2026-07-15T11:47:00+02:00",
};
const run = await domain.recordSourceRun("inbox", "gmail-inbox", [snapshot], [], {});
await domain.recordSweepBatch("inbox", [run]);
const original = await domain.upsertCard("inbox", {
id: "email-abc123", title: "Confirm the attendee list", why: "The organizer needs a reply.",
sourceRunIds: [run],
blocks: [{ id: "source", type: "receipt", text: "Thread ID: abc123." }],
});
const view = await store.readFeed("inbox");
expect(view.cards.find((card) => card.id === original.id)?.emailDates).toEqual([
{ threadId: snapshot.threadId, receivedAt: snapshot.receivedAt, subject: snapshot.subject },
]);
expect(await store.readCard("inbox", original.id)).toEqual(original);
await store.writeCard(view.cards.find((card) => card.id === original.id)!);
expect(await store.readCard("inbox", original.id)).not.toHaveProperty("emailDates");
});
68 changes: 68 additions & 0 deletions test/email-timestamp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { sourceEmailDates } from "../shared/emailTimestamp";
import type { Card } from "../shared/types";
import { CardView } from "../src/feed/CardView";

const card: Card = {
id: "mail-abc123", feedId: "inbox", kind: "attention", status: "to_review_new",
title: "Confirm the attendee list", eyebrow: "Workshop", why: "The organizer needs a reply.",
sourceMailbox: "owner@example.com", sourceRunIds: ["run-example"],
blocks: [{ id: "source", type: "receipt", text: "Thread ID: abc123." }],
readyForPass: 1, createdAt: "2026-09-16T12:00:00Z", updatedAt: "2026-09-16T12:00:00Z", history: [],
};
const source = {
kind: "gmail_thread_metadata", threadId: "abc123", sourceMailbox: "owner@example.com",
receivedAt: "2026-07-15T11:47:00+02:00", subject: "Attendee list",
};

test("old cards recover email dates from their exact source thread and mailbox", () => {
expect(sourceEmailDates(card, [
{ ...source, threadId: "abc1234" },
{ ...source, sourceMailbox: "someone@example.com" },
{ ...source, receivedAt: "2026-07-15" },
{ ...source, receivedAt: "2026-07-15T11:47:00" },
{ ...source, receivedAt: "invalid" },
{ kind: "collection_receipt", collectedAt: card.createdAt },
])).toEqual([]);
expect(sourceEmailDates(card, [{ ...source, kind: undefined }])).toEqual([
{ threadId: source.threadId, receivedAt: source.receivedAt, subject: source.subject },
]);
expect(card).not.toHaveProperty("emailDates");
});

test("source dates are deduplicated per thread and ordered newest first", () => {
const second = { ...source, threadId: "def456", receivedAt: "2026-07-16T12:00:00Z" };
const referenced = { ...card, blocks: [{ id: "source", type: "receipt" as const, text: "Thread IDs: abc123 and def456." }] };
const dates = sourceEmailDates(referenced, [source, second, source, { ...source, receivedAt: "2026-07-14T00:00:00Z" }]);
expect(dates.map((date) => date.threadId)).toEqual(["def456", "abc123"]);
expect(dates[1].receivedAt).toBe(source.receivedAt);
});

function render(card: Card) {
return renderToStaticMarkup(<CardView card={card} active={false} onActivate={() => {}} onChanged={() => {}} onAction={() => {}} onReturnToReview={() => {}} />);
}

test("the card header formats the source instant in the viewer's timezone", () => {
const dates = sourceEmailDates(card, [source]);
const html = render({ ...card, emailDates: dates });
const expected = new Intl.DateTimeFormat(undefined, {
year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short",
}).format(new Date(source.receivedAt));
expect(html).toContain(`dateTime="${source.receivedAt}"`);
expect(html).toContain(expected);
expect(html.indexOf("Email received")).toBeLessThan(html.indexOf("</header>"));
expect(html).not.toContain(card.createdAt);
expect(render(card)).not.toContain("<time");
});

test("legacy Gmail thread snapshots work without a card mailbox and with punctuated IDs", () => {
const legacy = { ...card, id: "gmail-thread-thread-reviewed", sourceMailbox: undefined,
blocks: [{ id: "source", type: "evidence" as const, items: [{ label: "Email", href: "https://mail.google.com/mail/u/0/#inbox/thread-reviewed" }] }],
};
const instant = "2026-07-15T09:47:00.000Z";
expect(sourceEmailDates(legacy, [{ id: "thread-reviewed", history_id: "history-1", messages: [
{ id: "message-1", internalDate: String(Date.parse(instant)) },
] }])).toEqual([{ threadId: "thread-reviewed", receivedAt: instant }]);
expect(sourceEmailDates(legacy, [{ ...source, threadId: "thread-review" }])).toEqual([]);
});
Loading