diff --git a/tools/v1/individual/email-to-todo-converter/README.md b/tools/v1/individual/email-to-todo-converter/README.md index a64001d1..fff7e80c 100644 --- a/tools/v1/individual/email-to-todo-converter/README.md +++ b/tools/v1/individual/email-to-todo-converter/README.md @@ -14,26 +14,28 @@ Do not wire this tool into the main app, routing, inbox architecture, wallet cor ## Contributor Setup -This tool does not ship executable code yet. Until a feature issue adds the -implementation, contributors should use the local documentation in this folder -as the launch contract: +This tool now ships a self-contained implementation plus tests and docs. Use the +local documentation in this folder as the launch contract: - `specs.md` defines the behavior and folder ownership boundary. - `docs/test-plan.md` lists the acceptance scenarios that future tests should cover. +- `docs/API.md` documents the local UI, helper, and data contracts. - `docs/fixtures.md` describes the fixture emails and expected task outputs. - `REVIEW_NOTES.md` gives reviewers a quick checklist for this isolated work. ## Intended Usage -The tool converts an email into one or more actionable tasks. A future feature -implementation should accept a normalized email object, extract the task title, -due date, priority, source metadata, and completion state, then return a -reviewable task draft without mutating the mailbox or main application state. +The tool converts an email into one or more actionable tasks. It accepts a +normalized email object, extracts the task title, due date, priority, and +source metadata, then returns a reviewable task draft without mutating the +mailbox or main application state. ## Known Limitations -- No production code is present in this folder yet. -- The documented tests are a plan, not an executable suite. +- The implementation remains isolated from the main app until a future + integration issue allows wiring. +- The component is still review-first; saving to an external task system is not + wired up. - Main app routing, inbox integration, and persistence are intentionally out of scope until a future integration issue allows them. diff --git a/tools/v1/individual/email-to-todo-converter/REVIEW_NOTES.md b/tools/v1/individual/email-to-todo-converter/REVIEW_NOTES.md index a3d59a41..1de8d9c5 100644 --- a/tools/v1/individual/email-to-todo-converter/REVIEW_NOTES.md +++ b/tools/v1/individual/email-to-todo-converter/REVIEW_NOTES.md @@ -1,7 +1,7 @@ # Review Notes -This issue is documentation and test-plan work for the isolated -Email-to-Todo Converter folder. +This issue covers the isolated Email-to-Todo Converter implementation, +including docs, deterministic extraction behavior, and test coverage. ## What Changed @@ -9,9 +9,9 @@ Email-to-Todo Converter folder. contract. - Added a contributor-facing setup, usage, and limitations section to `README.md`. -- Added `docs/test-plan.md` with unit, component, and non-goal coverage. -- Added `docs/fixtures.md` with representative email inputs and expected task - draft outcomes. +- Added `docs/test-plan.md`, `docs/API.md`, and `docs/fixtures.md` to document + the local contract. +- Added the deterministic UI helpers and review-first component under `ui/`. ## Review Checklist diff --git a/tools/v1/individual/email-to-todo-converter/docs/API.md b/tools/v1/individual/email-to-todo-converter/docs/API.md new file mode 100644 index 00000000..3f603989 --- /dev/null +++ b/tools/v1/individual/email-to-todo-converter/docs/API.md @@ -0,0 +1,93 @@ +# Email-to-Todo Converter API + +## Overview + +This folder exposes a small, deterministic UI surface plus pure helper +functions. The implementation stays local to the tool folder and does not depend +on the main app shell, routing, inbox, wallet, Stellar, or database layers. + +## UI + +### `EmailToTodoConverter` + +```ts +interface EmailToTodoConverterProps { + email: NormalizedEmail | null; + onSaveDraft?: (draft: TaskDraft) => void; + idPrefix?: string; +} +``` + +Renders the current email, a convert action, validation/error feedback, and a +reviewable task draft. + +### Behavior + +- Disabled when no convertible email is available. +- Announces loading, success, and error states through accessible live regions. +- Preserves review-before-save behavior; nothing is persisted automatically. + +## Helpers + +### `buildTaskTitle(email)` + +Extracts a task title from the subject when it is actionable. If the subject is +generic, it falls back to the first actionable sentence in the body. + +### `buildTaskNotes(email)` + +Produces a normalized notes field from the full body text. + +### `detectPriority(email)` + +Returns `high` for urgent language and `normal` otherwise. + +### `suggestDueDate(email, priority)` + +Uses an explicit due date when one is present in the email text. Otherwise, it +falls back to a deterministic offset from `receivedAt`. + +### `buildTaskDraft(email)` + +Produces the full draft object, including source metadata and suggested due +date/priority. + +### `hasConvertibleContent(email)` + +Checks whether a selected email contains enough content to convert. + +## Data Types + +### `NormalizedEmail` + +```ts +interface NormalizedEmail { + id?: string; + subject: string; + sender: string; + receivedAt: string; + body: string; + labels?: string[]; +} +``` + +### `TaskDraft` + +```ts +interface TaskDraft { + title: string; + notes: string; + sourceEmailId?: string; + sourceSubject: string; + sourceSender: string; + sourceReceivedAt: string; + suggestedDueDate: string; + suggestedPriority: "normal" | "high"; +} +``` + +## Notes + +- The draft is intentionally review-first. +- Saving is delegated to the host application through `onSaveDraft`. +- The helper functions are deterministic and suitable for unit testing. diff --git a/tools/v1/individual/email-to-todo-converter/docs/test-plan.md b/tools/v1/individual/email-to-todo-converter/docs/test-plan.md index 95d49276..3a43b8fd 100644 --- a/tools/v1/individual/email-to-todo-converter/docs/test-plan.md +++ b/tools/v1/individual/email-to-todo-converter/docs/test-plan.md @@ -1,16 +1,16 @@ # Email-to-Todo Converter Test Plan -This folder does not contain executable tool code yet, so this document is the -folder-local test plan for issue #358. Convert each scenario below into unit or -component tests when the feature implementation lands. +This folder contains the isolated tool implementation for issue #358, and this +document is the folder-local test plan for the converter behavior. Use the +scenarios below as the acceptance contract for unit or component tests. ## Unit Scenarios 1. Extracts a task title from a direct request in the email subject. 2. Extracts a task title from the first actionable sentence in the body when the subject is generic. -3. Preserves the source sender, source subject, and received timestamp in task - metadata. +3. Preserves the source email id, sender, subject, and received timestamp in + task metadata when available. 4. Converts explicit due dates such as "by Friday" or "due 2026-07-01" into a normalized due-date field. 5. Leaves the due-date field empty when the email has no deadline. diff --git a/tools/v1/individual/email-to-todo-converter/tests/emailToTodoView.test.ts b/tools/v1/individual/email-to-todo-converter/tests/emailToTodoView.test.ts index 2f71b52a..60b84fd9 100644 --- a/tools/v1/individual/email-to-todo-converter/tests/emailToTodoView.test.ts +++ b/tools/v1/individual/email-to-todo-converter/tests/emailToTodoView.test.ts @@ -27,13 +27,9 @@ describe("detectPriority", () => { expect(detectPriority(baseEmail({ subject: "URGENT: sign the contract" }))).toBe("high"); }); - it("returns medium when a soft keyword is present", () => { - expect(detectPriority(baseEmail({ subject: "Reminder: timesheet" }))).toBe("medium"); - }); - - it("returns low when no priority keywords are present", () => { + it("returns normal when no urgent keyword is present", () => { expect(detectPriority(baseEmail({ subject: "Lunch menu", body: "Soup and salad." }))).toBe( - "low", + "normal", ); }); }); @@ -45,12 +41,24 @@ describe("suggestDueDate", () => { }); it("uses the default offset for lower priorities", () => { - expect(suggestDueDate(baseEmail(), "low")).toBe("2026-01-13"); + expect(suggestDueDate(baseEmail(), "normal")).toBe("2026-01-13"); expect(DEFAULT_DUE_DATE_OFFSET_DAYS).toBe(3); }); it("returns an empty string for an unparseable timestamp", () => { - expect(suggestDueDate(baseEmail({ receivedAt: "not-a-date" }), "low")).toBe(""); + expect(suggestDueDate(baseEmail({ receivedAt: "not-a-date" }), "normal")).toBe(""); + }); + + it("uses an explicit due date when one is present", () => { + expect( + suggestDueDate( + baseEmail({ + subject: "Please review the invoice by Friday", + body: "Please review the attached invoice by Friday and let me know if anything is missing.", + }), + "normal", + ), + ).toBe("2026-01-16"); }); }); @@ -61,14 +69,44 @@ describe("buildTaskDraft", () => { const draft = buildTaskDraft(email); expect(draft.title).toBe("Project kickoff notes"); expect(draft.sourceSender).toBe("alex@example.com"); - expect(draft.suggestedPriority).toBe("low"); + expect(draft.suggestedPriority).toBe("normal"); + }); + + it("preserves the source email id when present", () => { + const draft = buildTaskDraft(baseEmail({ id: "email-direct-request" })); + expect(draft.sourceEmailId).toBe("email-direct-request"); + }); + + it("extracts a title from a direct request in the subject", () => { + const draft = buildTaskDraft( + baseEmail({ + subject: "Please review the invoice by Friday", + body: "Please review the attached invoice by Friday and let me know if anything is missing.", + }), + ); + expect(draft.title).toBe("Review the invoice"); + expect(draft.suggestedDueDate).toBe("2026-01-17"); + }); + + it("uses the first actionable sentence in the body when the subject is generic", () => { + const draft = buildTaskDraft( + baseEmail({ + subject: "Weekly product updates", + body: "Here are this week's product updates.\nPlease follow up with the partner today.", + }), + ); + expect(draft.title).toBe("Follow up with the partner"); + expect(draft.suggestedDueDate).toBe("2026-01-10"); }); - it("falls back to the first body line when the subject is empty", () => { + it("strips trailing deadline words from a body-derived title", () => { const draft = buildTaskDraft( - baseEmail({ subject: " ", body: "Call the bank about the invoice." }), + baseEmail({ + subject: "Weekly product updates", + body: "Please call the bank today.", + }), ); - expect(draft.title).toBe("Call the bank about the invoice."); + expect(draft.title).toBe("Call the bank"); }); it("falls back to a placeholder when subject and body are empty", () => { diff --git a/tools/v1/individual/email-to-todo-converter/ui/emailToTodoView.ts b/tools/v1/individual/email-to-todo-converter/ui/emailToTodoView.ts index f0ac3dae..4a061980 100644 --- a/tools/v1/individual/email-to-todo-converter/ui/emailToTodoView.ts +++ b/tools/v1/individual/email-to-todo-converter/ui/emailToTodoView.ts @@ -5,11 +5,12 @@ // required by the tool spec. Everything here is pure and deterministic so the // UI layer can stay thin and testable without a DOM. -export type TaskPriority = "low" | "medium" | "high"; +export type TaskPriority = "normal" | "high"; export type ConverterStatus = "empty" | "ready" | "loading" | "success" | "error"; export interface NormalizedEmail { + id?: string; subject: string; sender: string; receivedAt: string; // ISO-8601 timestamp @@ -20,6 +21,7 @@ export interface NormalizedEmail { export interface TaskDraft { title: string; notes: string; + sourceEmailId?: string; sourceSubject: string; sourceSender: string; sourceReceivedAt: string; @@ -43,7 +45,56 @@ export interface EmailToTodoConverterProps { } export const HIGH_PRIORITY_KEYWORDS = ["urgent", "asap", "immediately", "critical"]; -export const MEDIUM_PRIORITY_KEYWORDS = ["soon", "today", "reminder", "follow up", "follow-up"]; +export const GENERIC_SUBJECT_KEYWORDS = [ + "update", + "updates", + "newsletter", + "digest", + "summary", + "status", + "reminder", + "notes", + "note", + "fyi", + "weekly", + "daily", + "report", + "announcements", +]; +export const ACTIONABLE_KEYWORDS = [ + "review", + "follow up", + "follow-up", + "send", + "reply", + "respond", + "confirm", + "approve", + "sign", + "schedule", + "call", + "book", + "prepare", + "check", + "share", + "finish", + "fix", + "create", + "draft", + "submit", + "pay", + "coordinate", + "meet", +]; +const WEEKDAY_INDEX: Record = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, +}; export const DEFAULT_DUE_DATE_OFFSET_DAYS = 3; export const HIGH_PRIORITY_DUE_DATE_OFFSET_DAYS = 1; @@ -64,15 +115,123 @@ function firstNonEmptyLine(body: string): string { return ""; } +function normalizeTitleCase(value: string): string { + if (value.length === 0) { + return value; + } + return value[0].toUpperCase() + value.slice(1); +} + +function stripDecorativePrefixes(value: string): string { + return value + .replace(/^(please|kindly)\s+/i, "") + .replace(/^(could you|can you|would you)\s+/i, "") + .replace(/^(please )?(could you|can you|would you)\s+/i, "") + .replace(/^(we need to|need to)\s+/i, "") + .replace(/^(action required|action needed|fyi|urgent|asap|critical|important)\s*[:,-]?\s*/i, "") + .replace(/^(re|fw|fwd)\s*:\s*/i, "") + .replace(/^the attached\s+/i, "") + .replace(/^attached\s+/i, ""); +} + +function stripTrailingCoordinationClauses(value: string): string { + return value + .replace(/\s+\b(and let me know|and please|so that|so I can|so we can|because|while)\b.*$/i, "") + .replace( + /\s+\b(by|due|before)\s+(?:today|tomorrow|(?:mon|tues|wednes|thurs|fri|satur|sun)day|\d{4}-\d{2}-\d{2})\b.*$/i, + "", + ) + .replace(/\s+\b(today|tomorrow|tonight|now)\b\.?$/i, "") + .replace(/\s+[,.!?;:]\s*$/, "") + .trim(); +} + +function normalizeActionableText(value: string): string { + const cleaned = stripTrailingCoordinationClauses( + stripDecorativePrefixes(normalizeWhitespace(value)), + ); + return cleaned.length > 0 ? normalizeTitleCase(cleaned) : ""; +} + +function isGenericSubject(subject: string): boolean { + const normalized = normalizeWhitespace(subject).toLowerCase(); + if (normalized.length === 0) { + return true; + } + if (/^(urgent|asap|critical|important)\b[:,-]?\s*/i.test(normalized)) { + return true; + } + return GENERIC_SUBJECT_KEYWORDS.some((keyword) => normalized.includes(keyword)); +} + +function splitSentences(body: string): string[] { + return body + .replace(/\r\n/g, "\n") + .split(/(?:[.!?]\s+|\n+)/) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +function findActionableSentence(body: string): string { + const sentences = splitSentences(body); + for (const sentence of sentences) { + const normalized = sentence.toLowerCase(); + if (ACTIONABLE_KEYWORDS.some((keyword) => normalized.includes(keyword))) { + return sentence; + } + } + return sentences[0] ?? ""; +} + +function parseExplicitDueDate(value: string, receivedAt: string): string { + const normalized = normalizeWhitespace(value).toLowerCase(); + const isoMatch = normalized.match(/\b(20\d{2}-\d{2}-\d{2})\b/); + if (isoMatch) { + return isoMatch[1]; + } + + const weekdayMatch = normalized.match( + /\b(?:by|due|before)\s+(today|tomorrow|sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/, + ); + const fallbackWeekdayMatch = normalized.match( + /\b(today|tomorrow|sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/, + ); + const dueTarget = weekdayMatch?.[1] ?? fallbackWeekdayMatch?.[1]; + if (!dueTarget) { + return ""; + } + + const baseDate = new Date(receivedAt); + if (Number.isNaN(baseDate.getTime())) { + return ""; + } + + if (dueTarget === "today") { + return baseDate.toISOString().slice(0, 10); + } + + if (dueTarget === "tomorrow") { + baseDate.setUTCDate(baseDate.getUTCDate() + 1); + return baseDate.toISOString().slice(0, 10); + } + + const targetWeekday = WEEKDAY_INDEX[dueTarget]; + if (targetWeekday === undefined) { + return ""; + } + + const currentWeekday = baseDate.getUTCDay(); + const daysUntilTarget = (targetWeekday - currentWeekday + 7) % 7 || 7; + baseDate.setUTCDate(baseDate.getUTCDate() + daysUntilTarget); + return baseDate.toISOString().slice(0, 10); +} + export function detectPriority(email: NormalizedEmail): TaskPriority { const haystack = (email.subject + " " + email.body).toLowerCase(); if (HIGH_PRIORITY_KEYWORDS.some((word) => haystack.includes(word))) { return "high"; } - if (MEDIUM_PRIORITY_KEYWORDS.some((word) => haystack.includes(word))) { - return "medium"; - } - return "low"; + return "normal"; } function addDays(isoTimestamp: string, days: number): string { @@ -85,6 +244,10 @@ function addDays(isoTimestamp: string, days: number): string { } export function suggestDueDate(email: NormalizedEmail, priority: TaskPriority): string { + const explicitDueDate = parseExplicitDueDate(`${email.subject} ${email.body}`, email.receivedAt); + if (explicitDueDate) { + return explicitDueDate; + } const offset = priority === "high" ? HIGH_PRIORITY_DUE_DATE_OFFSET_DAYS : DEFAULT_DUE_DATE_OFFSET_DAYS; return addDays(email.receivedAt, offset); @@ -92,15 +255,23 @@ export function suggestDueDate(email: NormalizedEmail, priority: TaskPriority): export function buildTaskTitle(email: NormalizedEmail): string { const subject = normalizeWhitespace(email.subject); - if (subject.length > 0) { - return subject; + const normalizedSubject = normalizeActionableText(subject); + if (normalizedSubject.length > 0 && !isGenericSubject(subject)) { + return normalizedSubject; + } + const actionableBody = normalizeActionableText(findActionableSentence(email.body)); + if (actionableBody.length > 0) { + return actionableBody; + } + if (normalizedSubject.length > 0) { + return normalizedSubject; } const fallback = normalizeWhitespace(firstNonEmptyLine(email.body)); - return fallback.length > 0 ? fallback : "Untitled task"; + return fallback.length > 0 ? normalizeTitleCase(fallback) : "Untitled task"; } export function buildTaskNotes(email: NormalizedEmail): string { - const summary = normalizeWhitespace(firstNonEmptyLine(email.body)); + const summary = normalizeWhitespace(email.body); if (summary.length <= MAX_NOTES_LENGTH) { return summary; } @@ -112,6 +283,7 @@ export function buildTaskDraft(email: NormalizedEmail): TaskDraft { return { title: buildTaskTitle(email), notes: buildTaskNotes(email), + sourceEmailId: email.id, sourceSubject: normalizeWhitespace(email.subject), sourceSender: normalizeWhitespace(email.sender), sourceReceivedAt: email.receivedAt, diff --git a/tools/v1/individual/email-to-todo-converter/ui/index.ts b/tools/v1/individual/email-to-todo-converter/ui/index.ts index d21b73b1..543cef31 100644 --- a/tools/v1/individual/email-to-todo-converter/ui/index.ts +++ b/tools/v1/individual/email-to-todo-converter/ui/index.ts @@ -12,7 +12,8 @@ export { HIGH_PRIORITY_DUE_DATE_OFFSET_DAYS, HIGH_PRIORITY_KEYWORDS, MAX_NOTES_LENGTH, - MEDIUM_PRIORITY_KEYWORDS, + ACTIONABLE_KEYWORDS, + GENERIC_SUBJECT_KEYWORDS, } from "./emailToTodoView"; export type { ConverterStatus,