From c71d506355b849eea51605e048e5d458f96c1e11 Mon Sep 17 00:00:00 2001 From: Chao Wu Date: Sat, 26 Sep 2026 07:28:19 -0700 Subject: [PATCH 1/2] Support Gmail send-as identities in composers --- README.md | 2 +- docs/SPEC.md | 27 +- e2e/fixtures/seed-send-as.json | 51 +++ e2e/send-as.spec.ts | 116 +++++++ src/main/db/migrations.ts | 5 + src/main/db/queries.ts | 7 +- src/main/db/schema.ts | 3 +- src/main/db/schemaUpgrade.test.ts | 29 ++ src/main/gmail/provider.ts | 9 + src/main/gmail/quota.ts | 2 + src/main/outbox/draftMime.ts | 2 + src/main/outbox/draftSync.ts | 15 +- src/main/outbox/drafts.ts | 17 +- src/main/outbox/mime.ts | 4 + src/main/outbox/mirror.ts | 6 +- src/main/outbox/queue.ts | 7 +- src/main/outbox/replyPlan.test.ts | 66 ++++ src/main/outbox/replyPlan.ts | 48 ++- src/main/outbox/row.ts | 3 + src/main/outbox/sendAs.ts | 86 +++++- src/main/outbox/sendAsIdentities.test.ts | 290 ++++++++++++++++++ src/main/outbox/sender.test.ts | 1 + src/main/outbox/sender.ts | 28 +- src/main/service/handlers.ts | 57 +++- src/main/service/testOperations.ts | 7 +- src/main/sync/provider.ts | 3 + src/main/testIpc.ts | 1 + src/preload/index.ts | 1 + src/renderer/src/commands.ts | 1 + src/renderer/src/composer/ComposerChrome.tsx | 61 +++- src/renderer/src/composer/SenderMenu.tsx | 146 +++++++++ src/renderer/src/composer/useComposerDraft.ts | 5 +- src/shared/drafts.ts | 13 +- src/shared/ipc.ts | 3 + 34 files changed, 1055 insertions(+), 67 deletions(-) create mode 100644 e2e/fixtures/seed-send-as.json create mode 100644 e2e/send-as.spec.ts create mode 100644 src/main/outbox/sendAsIdentities.test.ts create mode 100644 src/renderer/src/composer/SenderMenu.tsx diff --git a/README.md b/README.md index ec586960..30c0bebc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/b25c6b19-3167-426b-a47b-5d72373083da - Work from the keyboard. Navigate conversations, archive, snooze, label, and undo actions, including bulk changes. - Keep accounts separate. Switch between Gmail inboxes while your other accounts continue to sync. -- Write and send. Use rich text, attachments, saved snippets, Gmail signatures, and an undo-send delay. Drafts save locally and sync with Gmail. +- Write and send. Use rich text, attachments, saved snippets, verified Gmail send-as identities, Gmail signatures, and an undo-send delay. Drafts save locally and sync with Gmail. - Find and organize mail. Search cached messages, search Gmail for older mail, create inbox splits with rules, and set follow-up reminders. - Make it your own. Choose light or dark themes, desktop notifications, and unread badges. - Add optional AI. Use your own provider for reply drafts and inline autocomplete. Smart splits use a separate TypeSafe key to sort mail from a description you write. diff --git a/docs/SPEC.md b/docs/SPEC.md index 1416e6c3..68eb6edc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -33,7 +33,7 @@ Attn supports these capabilities: Calendar, mobile, web, and Linux product support are outside the current scope. Outlook, IMAP, a unified inbox, scheduled send, and read tracking are also excluded. -Google Contacts import, custom send-as aliases, Gmail-native snooze, and Gmail filter administration are not supported. Contact autocomplete derives from mail headers. Gmail confidential-mode messages can contain only API placeholders. +Google Contacts import, send-as alias administration, Gmail-native snooze, and Gmail filter administration are not supported. Contact autocomplete derives from mail headers. Gmail confidential-mode messages can contain only API placeholders. ## 3. Design decisions @@ -242,7 +242,7 @@ Conflict rule: server state wins, except locally-pending actions replay on top o - `Enter` or clicking a row opens the **full-window conversation** at a responsive readable measure up to 896px, positioned at its newest message or restored thread-bound draft. Its header contains the subject and a clickable `Esc` control that returns to the list. It omits the queue-position counter. The newest message is expanded; older messages start as one-line summaries and their bodies (including HTML frames) are not mounted until expanded. Clicking an expanded message's header collapses it into that same summary row; clicking the summary reopens it. - While reading, `J`/`K` opens the next/previous conversation at its newest message or restored draft; at the first conversation, `K` returns to the full-width list instead of remaining in the reader. The adjacent conversations are fetched into the local renderer cache beforehand so this usually has no loading state. Unmodified `ArrowUp`/`ArrowDown`, `Space`/`Shift+Space`, and `PageUp`/`PageDown` scroll the current conversation, while `Shift+ArrowUp`/`Shift+ArrowDown` extend the selection exactly as `Shift+J`/`Shift+K` do — the arrow aliases behave the same in the list and the reader. Modifier+key chords retain their platform/browser meaning. Keyboard handling continues after clicking recipient, attachment, or trim controls and while focus is inside an HTML-mail frame; `Enter` on a focused mail link retains its native link action. When no inline draft is open and no transient overlay consumes it first, `Esc` or Back/List returns to the full-width list from every Tab stop—including focused buttons and mail links—with selection and scroll intact. - **Reader layout:** conversation messages use flat rows with top separators and no trailing bottom border. Expanded headers have no filled banner. The active message has a short accent line beside an outlined avatar in both expanded and collapsed states. `N` and `P` move that marker; `O` expands or collapses the active message. Expand all messages and Collapse all messages affect readable messages without revealing hidden Trash copies. These controls are also available through the command palette. Expanded messages expose Reply, Reply all, and Forward actions when no draft is open. The reader summary shows message count and current user-label chips without a participant summary. An outlined or filled star beside the subject shows the current thread star state and updates after the Star command. Back to the originating list shows an Escape keycap only when no inline draft is open. The reader hint bar shows Reply, Mark done, Snooze or Change snooze, and Back. Wide windows also show N/P and O; narrow windows hide those hints while retaining their keyboard commands. Reply, Reply all, and Forward buttons show their shortcuts on hover. Message expansion controls have no hover hints. -- **Message display:** each message card shows the sender, with the active account rendered consistently as `Me` before and after send confirmation, plus a recipient summary ("to me, Priya · cc Daniel") that expands on click to the full From/To/Cc/Bcc/Reply-To set with the full date, the body, and attachment chips (filename + size — click downloads to the OS Downloads folder and reveals the file). The actual outgoing `From` header uses the primary Gmail send-as display name so recipients see the configured identity. Bare HTTP(S) and `www.` URLs in plain text or unlinked HTML text render as external links. Quoted trails and signatures auto-collapse behind a plain-text `...` control rendered inline at the trim boundary; the control stays in place while expanding/collapsing and a second click collapses again. `Tab` always retains native focus navigation across the product. For collapsed HTML mail, the `...` control precedes links inside the mail frame in keyboard order; reaching it reveals the hidden trail without changing the reading viewport dimensions, and the next Tab continues into the mail links. Revealing a long trail makes the existing reading surface scroll instead of growing the window. Text-like HTML and fallback text use Attn's padded native reading surface. Typography, media, tables, dimensions, alignment, and layout-only CSS remain native because they do not require a white document. Meaningful inline text colors remain distinct on the native dark surface, with low-contrast hues brightened and ordinary dark foregrounds normalized to the native text color. Uncolored quoted text is dimmed so preserved answer colors remain easy to distinguish. HTML whose rendered meaning depends on the winning non-neutral background or background image keeps a light document canvas shared by its body, trim control, and attachments. Attn does not add padding inside light documents; sender-authored body padding still takes precedence. Light HTML body containers retain a 10px corner radius. Decorative markup confined to a signature does not promote the message. A real authored canvas inside a quoted trail is content and retains the light treatment, while ordinary quoted formatting does not turn every later reply white. Wide mail gets an in-frame horizontal scrollbar, and the conversation reserves its vertical scrollbar gutter so expanding content does not shift the reader. Bcc appears only on the user's own sent copies — Gmail never exposes other senders' Bcc. +- **Message display:** each message card shows the sender, with the active account rendered consistently as `Me` before and after send confirmation, plus a recipient summary ("to me, Priya · cc Daniel") that expands on click to the full From/To/Cc/Bcc/Reply-To set with the full date, the body, and attachment chips (filename + size — click downloads to the OS Downloads folder and reveals the file). The actual outgoing `From` header uses the selected Gmail send-as address and display name so recipients see the configured identity. Bare HTTP(S) and `www.` URLs in plain text or unlinked HTML text render as external links. Quoted trails and signatures auto-collapse behind a plain-text `...` control rendered inline at the trim boundary; the control stays in place while expanding/collapsing and a second click collapses again. `Tab` always retains native focus navigation across the product. For collapsed HTML mail, the `...` control precedes links inside the mail frame in keyboard order; reaching it reveals the hidden trail without changing the reading viewport dimensions, and the next Tab continues into the mail links. Revealing a long trail makes the existing reading surface scroll instead of growing the window. Text-like HTML and fallback text use Attn's padded native reading surface. Typography, media, tables, dimensions, alignment, and layout-only CSS remain native because they do not require a white document. Meaningful inline text colors remain distinct on the native dark surface, with low-contrast hues brightened and ordinary dark foregrounds normalized to the native text color. Uncolored quoted text is dimmed so preserved answer colors remain easy to distinguish. HTML whose rendered meaning depends on the winning non-neutral background or background image keeps a light document canvas shared by its body, trim control, and attachments. Attn does not add padding inside light documents; sender-authored body padding still takes precedence. Light HTML body containers retain a 10px corner radius. Decorative markup confined to a signature does not promote the message. A real authored canvas inside a quoted trail is content and retains the light treatment, while ordinary quoted formatting does not turn every later reply white. Wide mail gets an in-frame horizontal scrollbar, and the conversation reserves its vertical scrollbar gutter so expanding content does not shift the reader. Bcc appears only on the user's own sent copies — Gmail never exposes other senders' Bcc. - Bodies for the selected and adjacent conversations are preloaded so opening never shows a spinner. - When the last row in a date group exits, its date heading fades in place while the mail row slides out. - **Auto-advance:** after done/snooze/trash, selection (and the open reader) moves to the next conversation automatically (setting: next / previous / back to list). @@ -410,12 +410,21 @@ Inline replies initially show a compact recipient summary. Clicking it exposes r The inline composer's Save & close button and `Esc` save and close the draft, then restore focus to the reader at its source message. Back to the originating list is hidden while replying. A second `Esc` returns to that list. Attachment mutations, invalid recipients, and save errors still prevent closing. The inline close control shows the sole visible Escape keycap. Thread-bound drafts opened from Drafts return to this same inline context whenever the parent conversation is locally available. A full-window composer hides the global mail shortcut footer. An inline draft keeps that footer with composer hints, while its own action footer remains inside the draft. -- **From identity:** every draft belongs to exactly one account, and its read-only From field shows that - account. New mail binds to the account active when the composer opened; replies, reply-alls, and forwards - always bind to the account that owns the source thread, whatever account is active. Reassigning a draft's - account and Gmail send-as aliases remain unsupported in v1 — to write from another account, switch - accounts first (F18, F18). -- **Gmail signature:** Attn caches the primary send-as signature under the existing `gmail.modify` grant and +- **From identity:** every draft belongs to exactly one account. The From selector lists that account's + primary address and verified Gmail send-as identities. Configure identities in Gmail settings. + New messages use the cached Gmail default identity, with the primary address as the offline fallback. + Replies, reply-all, and forwards match the source message against the account's verified identities. + An owned From address takes precedence, followed by the first matching To, Cc, or Bcc address. + Matching ignores case. If no identity matches, use the cached Gmail default identity. + Existing drafts retain their selected identity, including when upgrading a reply to reply-all. + Choose sender address in the command palette focuses the selector in full and inline composers. + Changing From preserves the body and signature. The selected address survives autosave, restart, + Gmail draft sync, undo-send, and retry. Imported drafts retain their From address. A removed identity + remains visible on its saved draft but cannot send until the user chooses an available identity. + Sends use the chosen identity's display name and Reply-To. Attn never substitutes another sender. + Replies exclude all verified identities from self-recipients. Draft ownership remains unchanged. + Identity lists refresh with account sync and remain available offline. SMTP configuration stays in Gmail. +- **Gmail signature:** Attn caches send-as signatures under the existing `gmail.modify` grant and inserts it as editable content when a new-message, reply, reply-all, or forward draft opens. The cache refreshes at sync start and once per poll cycle, so compose never waits on the network and the last fetched signature remains available offline. A composer that still contains only its planned fields and @@ -423,7 +432,7 @@ return to this same inline context whenever the parent conversation is locally a uses the signature applied to the draft rather than the latest account cache. A Gmail setting change therefore cannot turn an older untouched signature into authored content. Gmail does not expose its separate reply and forward signature-default choices or the checkbox that removes the `-- ` separator - through this resource. Attn uses the primary signature for every local composer and does not invent a + through this resource. Attn uses the initially selected identity's signature for each local composer and does not invent a separator absent from that HTML. A signature already present in an imported Gmail draft remains editable and round-trips with that draft. An adjacent Gmail-marked separator collapses with the signature in Attn, but exports before it with one line break, retaining its trailing space in plain text. diff --git a/e2e/fixtures/seed-send-as.json b/e2e/fixtures/seed-send-as.json new file mode 100644 index 00000000..6c4d5c7d --- /dev/null +++ b/e2e/fixtures/seed-send-as.json @@ -0,0 +1,51 @@ +{ + "account": "seed@attn.test", + "labels": [], + "threads": [ + { + "id": "t-received", + "messages": [ + { + "id": "m-received", + "labelIds": ["INBOX"], + "receivedDaysAgo": 0, + "receivedAt": "10:00", + "from": "Maya ", + "to": "WORK@example.org", + "subject": "received identity", + "bodyText": "Message for sender selection." + } + ] + }, + { + "id": "t-sent", + "messages": [ + { + "id": "m-sent", + "labelIds": ["INBOX"], + "receivedDaysAgo": 0, + "receivedAt": "10:00", + "from": "Work ", + "to": "Maya ", + "subject": "sent identity", + "bodyText": "Message for sender selection." + } + ] + }, + { + "id": "t-unmatched", + "messages": [ + { + "id": "m-unmatched", + "labelIds": ["INBOX"], + "receivedDaysAgo": 0, + "receivedAt": "10:00", + "from": "Maya ", + "to": "list@example.com", + "subject": "unmatched identity", + "bodyText": "Message for sender selection." + } + ] + } + ] +} diff --git a/e2e/send-as.spec.ts b/e2e/send-as.spec.ts new file mode 100644 index 00000000..75394514 --- /dev/null +++ b/e2e/send-as.spec.ts @@ -0,0 +1,116 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { TEST_CHANNELS } from '../src/shared/ipc' +import { ComposerPage } from './composer' +import { expect, test } from './electron' +import { runPaletteCommand, threadRow } from './nav' +import { emitSeam } from './seams' + +test.use({ seed: 'fixtures/seed-inbox.json' }) + +for (const appearance of ['dark', 'light'] as const) { + test(`Gmail send-as selection persists in ${appearance} theme`, async ({ app, page, boot }, testInfo) => { + await page.getByTestId('thread-list').waitFor() + await emitSeam(app, TEST_CHANNELS.setSendAsIdentities, [ + { sendAsEmail: 'work@example.org', displayName: 'Work Identity', verificationStatus: 'accepted' }, + { sendAsEmail: 'pending@example.org', verificationStatus: 'pending' } + ]) + await runPaletteCommand(page, `Use ${appearance === 'dark' ? 'Dark' : 'Light'} theme`) + const composer = new ComposerPage(page) + await composer.openNew() + const from = page.getByTestId('composer-from-select') + await runPaletteCommand(page, 'Choose sender address') + await expect(from).toBeFocused() + await from.press('ArrowDown') + await expect(page.getByTestId('composer-from-option')).toHaveCount(2) + await page.keyboard.press('Escape') + await expect(page.getByTestId('composer-from-menu')).toBeHidden() + await expect(from).toBeFocused() + await from.press('Enter') + await page.keyboard.press('End') + await page.keyboard.press('Enter') + await expect(page.getByTestId('composer-from')).toHaveAttribute('data-email', 'work@example.org') + await composer.addRecipient('recipient@example.com') + await composer.subject.fill('Sender persistence') + await composer.editor.fill('This draft uses my work identity.') + await composer.expectSaved() + const dir = join('e2e', '.artifacts') + mkdirSync(dir, { recursive: true }) + const path = join(dir, `send-as-full-${appearance}.png`) + await page.getByTestId('composer-from-select').click() + await page.screenshot({ path }) + await page.keyboard.press('Escape') + await testInfo.attach('send-as-full', { path, contentType: 'image/png' }) + await page.getByTestId('composer-close').click() + const relaunched = await boot.relaunch() + await runPaletteCommand(relaunched.page, 'Go to Drafts') + await relaunched.page.getByTestId('draft-row').filter({ hasText: 'Sender persistence' }).click() + await expect(relaunched.page.getByTestId('composer-from')).toHaveAttribute( + 'data-email', + 'work@example.org' + ) + await expect(relaunched.page.getByTestId('composer-editor')).toContainText( + 'This draft uses my work identity.' + ) + }) + + test(`inline replies expose Gmail send-as in ${appearance} theme`, async ({ app, page }, testInfo) => { + await page.getByTestId('thread-list').waitFor() + await emitSeam(app, TEST_CHANNELS.setSendAsIdentities, [ + { sendAsEmail: 'work@example.org', displayName: 'Work Identity', verificationStatus: 'accepted' } + ]) + await runPaletteCommand(page, `Use ${appearance === 'dark' ? 'Dark' : 'Light'} theme`) + await threadRow(page, 'Q3 roadmap review').click() + await page.keyboard.press('Enter') + const composer = new ComposerPage(page) + await composer.openReply() + await page.getByTestId('composer-from-select').click() + await page.getByTestId('composer-from-option').filter({ hasText: 'work@example.org' }).click() + await composer.editor.fill('Reply from my work identity.') + await composer.expectSaved() + const dir = join('e2e', '.artifacts') + mkdirSync(dir, { recursive: true }) + const path = join(dir, `send-as-inline-${appearance}.png`) + await page.getByTestId('composer-from-select').click() + await page.screenshot({ path }) + await page.keyboard.press('Escape') + await testInfo.attach('send-as-inline', { path, contentType: 'image/png' }) + }) +} + +test.describe('source message identity', () => { + test.use({ seed: 'fixtures/seed-send-as.json' }) + for (const [source, command, expected] of [ + ['received', 'Reply', 'work@example.org'], + ['received', 'Reply all', 'work@example.org'], + ['received', 'Forward', 'work@example.org'], + ['sent', 'Reply', 'work@example.org'], + ['sent', 'Forward', 'work@example.org'], + ['unmatched', 'Reply', 'default@example.org'] + ]) { + test(`${command} uses the ${source} identity`, async ({ app, page }) => { + await page.getByTestId('thread-list').waitFor() + await emitSeam(app, TEST_CHANNELS.setSendAsIdentities, [ + { + sendAsEmail: 'work@example.org', + verificationStatus: 'accepted', + signature: '

Work signature

' + }, + { + sendAsEmail: 'default@example.org', + verificationStatus: 'accepted', + isDefault: true, + signature: '

Default signature

' + } + ]) + await threadRow(page, `${source} identity`).click() + await page.keyboard.press(command === 'Forward' ? 'f' : command === 'Reply all' ? 'a' : 'r') + const composer = new ComposerPage(page) + await expect(page.getByTestId('composer-from')).toHaveAttribute('data-email', expected) + await composer.revealSignature() + await expect(composer.editor).toContainText( + expected === 'work@example.org' ? 'Work signature' : 'Default signature' + ) + }) + } +}) diff --git a/src/main/db/migrations.ts b/src/main/db/migrations.ts index 35de9b26..f3b9ff0c 100644 --- a/src/main/db/migrations.ts +++ b/src/main/db/migrations.ts @@ -116,6 +116,11 @@ export const SCHEMA_MIGRATIONS: readonly SchemaMigration[] = [ from: 28, to: 29, sql: 'ALTER TABLE split_rules ADD COLUMN description TEXT;' + }, + { + from: 29, + to: 30, + sql: 'ALTER TABLE outbox ADD COLUMN sender_email TEXT;' } ] diff --git a/src/main/db/queries.ts b/src/main/db/queries.ts index 298c40eb..da791fe7 100644 --- a/src/main/db/queries.ts +++ b/src/main/db/queries.ts @@ -731,6 +731,7 @@ function readConversation( } interface OutboxConversationRow { + sender_email: string | null id: string state: 'queued' | 'sending' | 'sent' to_json: string @@ -788,7 +789,7 @@ export function getConversationForDisplay( ) const rows = db .prepare( - `SELECT id, state, to_json, cc_json, bcc_json, body_html, body_text, attachments_json, + `SELECT id, state, sender_email, to_json, cc_json, bcc_json, body_html, body_text, attachments_json, quote_html, quote_text, references_json, rfc_message_id, gmail_message_id, updated_at FROM outbox WHERE account_id = ? AND thread_id = ? @@ -822,7 +823,7 @@ export function getConversationForDisplay( (message) => canonicalBody.length > 0 && !claimedConfirmedIds.has(message.id) && - normalizeEmailKey(message.fromEmail) === normalizeEmailKey(account) && + normalizeEmailKey(message.fromEmail) === normalizeEmailKey(row.sender_email ?? account) && Math.abs(message.at - row.updated_at) <= LEGACY_SENT_MATCH_WINDOW_MS && canonicalSentBody(message.bodyText) === canonicalBody ) @@ -842,7 +843,7 @@ export function getConversationForDisplay( rfcMessageId: row.rfc_message_id, references: parseJson(row.references_json, []), fromName: 'Me', - fromEmail: account ?? accountId, + fromEmail: row.sender_email ?? account ?? accountId, at: row.updated_at, recipients: { to: parseJson(row.to_json, []), diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 17047572..e48b116a 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -1,7 +1,7 @@ // Current schema snapshot for new profiles. Every change bumps this version and // adds the matching ordered step in migrations.ts; the registry test makes a // version-only bump fail. -export const CURRENT_SCHEMA_VERSION = 29 +export const CURRENT_SCHEMA_VERSION = 30 // The oldest profile this build can upgrade in place. Keep the complete path // from this version to CURRENT_SCHEMA_VERSION in migrations.ts. @@ -287,6 +287,7 @@ CREATE TABLE outbox ( local_revision INTEGER NOT NULL DEFAULT 0, mirror_revision INTEGER NOT NULL DEFAULT 0, default_signature_fingerprint TEXT, + sender_email TEXT, remote_fingerprint TEXT, rfc_message_id TEXT, -- "Remind me if no reply" deadline chosen at compose (T35/F9); the reminder diff --git a/src/main/db/schemaUpgrade.test.ts b/src/main/db/schemaUpgrade.test.ts index 6151c246..ebbdc5a3 100644 --- a/src/main/db/schemaUpgrade.test.ts +++ b/src/main/db/schemaUpgrade.test.ts @@ -332,6 +332,7 @@ it('openDatabase migrates an existing profile before returning it', () => { try { const old = openDatabase(path) old.exec(` + ALTER TABLE outbox DROP COLUMN sender_email; ALTER TABLE split_rules DROP COLUMN description; DROP TABLE split_judgments; ALTER TABLE accounts ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0; @@ -370,6 +371,7 @@ it('upgrades a populated v21 profile through every retained migration', () => { // Reverse only the recorded v21..v28 changes to build a complete v21 // profile from the authoritative current snapshot. db.exec(` + ALTER TABLE outbox DROP COLUMN sender_email; ALTER TABLE split_rules DROP COLUMN description; DROP TABLE split_judgments; DROP TABLE thread_mailboxes; @@ -460,3 +462,30 @@ it('rejects databases older than the retained migration history', () => { db.close() } }) + +it('upgrades v29 outbox rows without changing their contents or owning account', () => { + const db = new Database(':memory:') + const fresh = openDatabase(':memory:') + try { + db.exec(`CREATE TABLE outbox (id TEXT PRIMARY KEY, account_id TEXT, body_text TEXT, state TEXT); + INSERT INTO outbox VALUES ('saved', 'me@example.com', 'Keep this draft', 'drafted'); + PRAGMA user_version = 29;`) + migrateSchema(db, 29) + expect(db.prepare('SELECT * FROM outbox').get()).toEqual({ + id: 'saved', + account_id: 'me@example.com', + body_text: 'Keep this draft', + state: 'drafted', + sender_email: null + }) + expect(db.pragma('user_version', { simple: true })).toBe(CURRENT_SCHEMA_VERSION) + expect( + (fresh.pragma('table_info(outbox)') as { name: string }[]).some( + (column) => column.name === 'sender_email' + ) + ).toBe(true) + } finally { + db.close() + fresh.close() + } +}) diff --git a/src/main/gmail/provider.ts b/src/main/gmail/provider.ts index 9928dbd2..6e320dc5 100644 --- a/src/main/gmail/provider.ts +++ b/src/main/gmail/provider.ts @@ -213,6 +213,15 @@ export class GmailMailProvider implements MailProvider { return this.client.get(`/settings/sendAs/${encodeURIComponent(email)}`, undefined, options) } + async listSendAs(options?: ProviderRequestOptions): Promise { + const result = await this.client.get<{ sendAs?: ProviderSendAs[] }>( + '/settings/sendAs', + undefined, + options + ) + return result.sendAs ?? [] + } + async listLabels(options?: ProviderRequestOptions): Promise { const result = await this.client.get<{ labels?: ProviderLabel[] }>('/labels', undefined, options) return result.labels ?? [] diff --git a/src/main/gmail/quota.ts b/src/main/gmail/quota.ts index 7610e188..590277f7 100644 --- a/src/main/gmail/quota.ts +++ b/src/main/gmail/quota.ts @@ -22,6 +22,7 @@ export const GMAIL_QUOTA_UNITS = { 'messages.get': 20, 'messages.list': 5, 'settings.sendAs.get': 1, + 'settings.sendAs.list': 1, 'threads.get': 40, 'threads.list': 10, 'threads.modify': 10 @@ -292,6 +293,7 @@ export function quotaMethod(method: string, path: string): GmailQuotaMethod { if (path === '/messages') return 'messages.list' if (/^\/messages\/[^/]+\/attachments\/[^/]+$/.test(path)) return 'messages.attachments.get' if (/^\/messages\/[^/]+$/.test(path)) return 'messages.get' + if (path === '/settings/sendAs') return 'settings.sendAs.list' if (/^\/settings\/sendAs\/[^/]+$/.test(path)) return 'settings.sendAs.get' if (path === '/threads') return 'threads.list' if (/^\/threads\/[^/]+\/modify$/.test(path)) return 'threads.modify' diff --git a/src/main/outbox/draftMime.ts b/src/main/outbox/draftMime.ts index 1257fb88..d0b90558 100644 --- a/src/main/outbox/draftMime.ts +++ b/src/main/outbox/draftMime.ts @@ -33,6 +33,7 @@ export interface DraftMimeStreamAttachment extends AttachmentIdentity { } export interface DraftMimeInput { + senderEmail?: string to: readonly MailAddress[] cc: readonly MailAddress[] bcc: readonly MailAddress[] @@ -83,6 +84,7 @@ function draftMimeSegments(input: DraftMimeInput '?').join(', ')}) AND gmail_draft_id IS NULL AND state IN ('composing', 'drafted') ORDER BY updated_at DESC` @@ -369,9 +374,10 @@ function writeRemoteDraft( id, account_id, gmail_draft_id, gmail_message_id, state, kind, to_json, cc_json, bcc_json, subject, body_html, body_text, attachments_json, thread_id, source_message_id, in_reply_to, references_json, quote_html, quote_text, created_at, updated_at, local_revision, mirror_revision, - remote_fingerprint - ) VALUES (?, ?, ?, ?, 'drafted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + remote_fingerprint, sender_email + ) VALUES (?, ?, ?, ?, 'drafted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET + sender_email = excluded.sender_email, gmail_draft_id = excluded.gmail_draft_id, gmail_message_id = excluded.gmail_message_id, state = 'drafted', kind = excluded.kind, to_json = excluded.to_json, cc_json = excluded.cc_json, @@ -405,7 +411,8 @@ function writeRemoteDraft( remote.updatedAt, revision, revision, - remote.fingerprint + remote.fingerprint, + input.senderEmail ?? null ) } diff --git a/src/main/outbox/drafts.ts b/src/main/outbox/drafts.ts index 2d3a28bd..f1f67847 100644 --- a/src/main/outbox/drafts.ts +++ b/src/main/outbox/drafts.ts @@ -29,13 +29,14 @@ interface DraftRow { created_at: number updated_at: number local_revision: number + sender_email?: string | null default_signature_fingerprint: string | null } const DRAFT_COLUMNS = `id, account_id, gmail_draft_id, state, kind, to_json, cc_json, bcc_json, subject, body_html, body_text, attachments_json, thread_id, source_message_id, in_reply_to, references_json, quote_html, quote_text, follow_up_at, created_at, updated_at, local_revision, - default_signature_fingerprint` + default_signature_fingerprint, sender_email` function toDraft(row: DraftRow): Draft { const content = outboxDraftContent(row) @@ -315,6 +316,13 @@ export function saveDraft( isEffectivelyEmptyDraft(input, defaultSignatureFingerprint) ? 0 : 1, defaultSignatureFingerprint ) + db.prepare('UPDATE outbox SET sender_email = ? WHERE account_id = ? AND id = ?').run( + input.senderEmail && normalizeEmailKey(input.senderEmail) !== normalizeEmailKey(accountId) + ? input.senderEmail + : null, + accountId, + id + ) return } @@ -348,6 +356,13 @@ export function saveDraft( id ) if (result.changes === 0) throw new Error('draft is unavailable') + if (input.senderEmail !== undefined) { + db.prepare('UPDATE outbox SET sender_email = ? WHERE account_id = ? AND id = ?').run( + normalizeEmailKey(input.senderEmail) === normalizeEmailKey(accountId) ? null : input.senderEmail, + accountId, + id + ) + } })() return id } diff --git a/src/main/outbox/mime.ts b/src/main/outbox/mime.ts index 951e133d..11a94b02 100644 --- a/src/main/outbox/mime.ts +++ b/src/main/outbox/mime.ts @@ -51,6 +51,7 @@ export interface MimeDraft { export interface BuildMimeOptions { accountEmail: string accountName?: string + replyToAddress?: string /** Stable, caller-owned id. The outbox persists this before any send attempt. */ rfcMessageId: string date: Date @@ -407,6 +408,9 @@ function buildMimeSegments( ...addressHeader('Bcc', draft.bcc ?? []), ...foldHeader('Subject', encodeSubject(draft.subject)), ...foldHeader('Message-ID', messageId), + ...(options.replyToAddress + ? addressHeader('Reply-To', [{ name: '', email: options.replyToAddress }], true) + : []), ...(draft.inReplyTo ? foldHeader('In-Reply-To', singleLine(draft.inReplyTo)) : []), ...(draft.references?.length ? foldHeader('References', draft.references.map(singleLine).filter(Boolean).join(' ')) diff --git a/src/main/outbox/mirror.ts b/src/main/outbox/mirror.ts index bc359455..c99ab45c 100644 --- a/src/main/outbox/mirror.ts +++ b/src/main/outbox/mirror.ts @@ -25,6 +25,7 @@ interface DraftMirrorRow { state: 'composing' | 'drafted' | 'discarding' kind: DraftKind gmail_draft_id: string | null + sender_email?: string | null to_json: string cc_json: string bcc_json: string @@ -59,7 +60,7 @@ function nextPending( ): DraftMirrorRow | undefined { const rows = db .prepare( - `SELECT id, state, kind, gmail_draft_id, to_json, cc_json, bcc_json, subject, body_html, + `SELECT id, state, kind, gmail_draft_id, sender_email, to_json, cc_json, bcc_json, subject, body_html, body_text, attachments_json, thread_id, in_reply_to, references_json, quote_html, quote_text, source_message_id, local_revision, default_signature_fingerprint FROM outbox @@ -246,7 +247,8 @@ async function mirrorComposing( const content = outboxDraftContent(row) // The MIME body carries authored content only: attachments are prepared // separately below, and the thread id rides on the Gmail request instead. - const { attachments: _attachments, threadId: _threadId, ...body } = content + const { attachments: _attachments, threadId: _threadId, ...fields } = content + const body = { ...fields, senderEmail: content.senderEmail ?? accountId } const onRemoteMissing = (): boolean => { db.prepare( `UPDATE outbox SET gmail_draft_id = NULL, mirror_revision = 0 diff --git a/src/main/outbox/queue.ts b/src/main/outbox/queue.ts index d630c2e8..d793e565 100644 --- a/src/main/outbox/queue.ts +++ b/src/main/outbox/queue.ts @@ -15,10 +15,12 @@ import { getDraft } from './drafts' import { persistPlan, type StoredMachineRow } from './machine' import { validateMimeRecipients } from './mime' import { outboxAddresses } from './row' +import { resolveSendAs } from './sendAs' interface QueueRow { id: string account_id: string + sender_email: string | null state: PendingOutboxState | 'composing' kind: DraftKind to_json: string @@ -43,12 +45,13 @@ export function undoSendDelayMs(db: Db): number { export function queueSend(db: Db, accountId: string, draftId: string, now = Date.now()): QueueSendResult { const row = db .prepare( - `SELECT id, account_id, state, kind, to_json, cc_json, bcc_json, subject, updated_at, + `SELECT id, account_id, sender_email, state, kind, to_json, cc_json, bcc_json, subject, updated_at, gmail_draft_id, rfc_message_id, send_at, attempts, verify_attempts, last_error FROM outbox WHERE account_id = ? AND id = ? AND state = 'composing'` ) .get(accountId, draftId) as QueueRow | undefined if (!row) throw new Error('draft is unavailable') + resolveSendAs(db, accountId, row.sender_email ?? accountId) const accountSeparator = accountId.lastIndexOf('@') if (accountSeparator <= 0 || accountSeparator === accountId.length - 1) { @@ -78,7 +81,7 @@ export function queueSend(db: Db, accountId: string, draftId: string, now = Date export function listPendingOutbox(db: Db, accountId: string): OutboxItem[] { const rows = db .prepare( - `SELECT id, account_id, state, kind, to_json, cc_json, bcc_json, subject, updated_at, + `SELECT id, account_id, sender_email, state, kind, to_json, cc_json, bcc_json, subject, updated_at, gmail_draft_id, rfc_message_id, send_at, attempts, verify_attempts, last_error FROM outbox WHERE account_id = ? AND state IN ('queued', 'sending', 'failed', 'needs-review') diff --git a/src/main/outbox/replyPlan.test.ts b/src/main/outbox/replyPlan.test.ts index f4a57f63..c2a09d22 100644 --- a/src/main/outbox/replyPlan.test.ts +++ b/src/main/outbox/replyPlan.test.ts @@ -311,3 +311,69 @@ describe('quote HTML sanitizer', () => { expect(sanitized).not.toContain('Cell ACell B') }) }) + +it('treats verified send-as addresses as self when choosing a reply source and recipients', () => { + const alias = 'work@example.org' + const source = message({ + recipients: { + ...EMPTY_RECIPIENTS, + to: [address('Work', alias), address('Me', SELF)], + cc: [address('Other', 'other@example.com')] + } + }) + const sent = message({ id: 'sent', fromEmail: alias, at: source.at + 1000 }) + const plan = planReply('replyAll', conversation([source, sent]), SELF, undefined, [alias]) + expect(plan.sourceMessageId).toBe(source.id) + expect(plan.to.map((item) => item.email)).toEqual(['maya@example.com']) + expect(plan.cc.map((item) => item.email)).toEqual(['other@example.com']) +}) + +describe('reply sender identity', () => { + const alias = 'work@example.org' + it.each(['reply', 'replyAll', 'forward'])('matches received aliases for %s', (kind) => { + const source = message({ recipients: { ...EMPTY_RECIPIENTS, to: [address('', 'WORK@example.org')] } }) + expect(planReply(kind, conversation([source]), SELF, undefined, [alias]).senderEmail).toBe(alias) + }) + + it.each(['reply', 'replyAll', 'forward'])('preserves an owned From for %s', (kind) => { + const source = message({ fromEmail: alias, recipients: { ...EMPTY_RECIPIENTS, to: [address('', SELF)] } }) + expect(planReply(kind, conversation([source]), SELF, source.id, [alias]).senderEmail).toBe(alias) + }) + + it('prefers To over Cc and Bcc, then uses their first matching address', () => { + const source = message({ + recipients: { + to: [address('', SELF)], + cc: [address('', alias)], + bcc: [address('', 'hidden@example.org')], + replyTo: [] + } + }) + const aliases = [alias, 'hidden@example.org'] + expect(planReply('reply', conversation([source]), SELF, undefined, aliases).senderEmail).toBe(SELF) + source.recipients.to = [] + expect(planReply('reply', conversation([source]), SELF, undefined, aliases).senderEmail).toBe(alias) + source.recipients.cc = [] + expect(planReply('reply', conversation([source]), SELF, undefined, aliases).senderEmail).toBe( + 'hidden@example.org' + ) + }) + + it('leaves unmatched identities to the default and ignores Reply-To for sender selection', () => { + const source = message({ + recipients: { ...EMPTY_RECIPIENTS, to: [address('', alias)], replyTo: [address('', SELF)] } + }) + expect(planReply('reply', conversation([source]), SELF).senderEmail).toBeUndefined() + }) + + it('uses the selected source instead of an identity elsewhere in the thread', () => { + const older = message({ recipients: { ...EMPTY_RECIPIENTS, to: [address('', alias)] } }) + const newer = message({ + id: 'newer', + at: older.at + 1000, + recipients: { ...EMPTY_RECIPIENTS, to: [address('', SELF)] } + }) + expect(planReply('reply', conversation([older, newer]), SELF, older.id, [alias]).senderEmail).toBe(alias) + expect(planReply('reply', conversation([older, newer]), SELF, undefined, [alias]).senderEmail).toBe(SELF) + }) +}) diff --git a/src/main/outbox/replyPlan.ts b/src/main/outbox/replyPlan.ts index 031f32cc..87045a85 100644 --- a/src/main/outbox/replyPlan.ts +++ b/src/main/outbox/replyPlan.ts @@ -10,6 +10,7 @@ export { sanitizeQuoteHtml } from './quoteSanitizer' export type ReplyKind = 'reply' | 'replyAll' | 'forward' export interface ReplyPlan { + senderEmail?: string to: MailAddress[] cc: MailAddress[] subject: string @@ -52,10 +53,14 @@ function latestMessage(messages: readonly ConversationMsg[]): ConversationMsg { return messages.reduce((latest, message) => (message.at >= latest.at ? message : latest)) } -function latestReplyMessage(messages: readonly ConversationMsg[], accountEmail: string): ConversationMsg { +function latestReplyMessage( + messages: readonly ConversationMsg[], + accountEmail: string, + aliases: readonly string[] +): ConversationMsg { const latest = latestMessage(messages) - const self = normalizeEmailKey(accountEmail) - const nonSelf = messages.filter((message) => normalizeEmailKey(message.fromEmail) !== self) + const self = new Set([accountEmail, ...aliases].map(normalizeEmailKey)) + const nonSelf = messages.filter((message) => !self.has(normalizeEmailKey(message.fromEmail))) return nonSelf.length > 0 ? latestMessage(nonSelf) : latest } @@ -146,7 +151,8 @@ export function replySourceMessage( kind: ReplyKind, conversation: Conversation, accountEmail: string, - sourceMessageId?: string + sourceMessageId?: string, + aliases: readonly string[] = [] ): ConversationMsg { if (sourceMessageId !== undefined) { const source = conversation.messages.find((message) => message.id === sourceMessageId) @@ -155,27 +161,40 @@ export function replySourceMessage( } return kind === 'forward' ? latestMessage(conversation.messages) - : latestReplyMessage(conversation.messages, accountEmail) + : latestReplyMessage(conversation.messages, accountEmail, aliases) } export function planReply( kind: ReplyKind, conversation: Conversation, accountEmail: string, - sourceMessageId?: string + sourceMessageId?: string, + aliases: readonly string[] = [] ): ReplyPlan { - const source = replySourceMessage(kind, conversation, accountEmail, sourceMessageId) - const self = new Set([normalizeEmailKey(accountEmail)]) - const replyTargets = - normalizeEmailKey(source.fromEmail) === normalizeEmailKey(accountEmail) - ? source.recipients.to - : source.recipients.replyTo.length > 0 - ? source.recipients.replyTo - : [{ name: source.fromName, email: source.fromEmail }] + const source = replySourceMessage(kind, conversation, accountEmail, sourceMessageId, aliases) + const identities = [accountEmail, ...aliases] + const self = new Set(identities.map(normalizeEmailKey)) + // Preserve an outgoing identity; otherwise prefer the address that received this message. + const candidates = [ + source.fromEmail, + ...source.recipients.to.map((address) => address.email), + ...source.recipients.cc.map((address) => address.email), + ...source.recipients.bcc.map((address) => address.email) + ] + const matched = candidates.find((email) => self.has(normalizeEmailKey(email))) + const senderEmail = identities.find( + (email) => matched && normalizeEmailKey(email) === normalizeEmailKey(matched) + ) + const replyTargets = self.has(normalizeEmailKey(source.fromEmail)) + ? source.recipients.to + : source.recipients.replyTo.length > 0 + ? source.recipients.replyTo + : [{ name: source.fromName, email: source.fromEmail }] if (kind === 'forward') { const quote = forwardQuote(source, conversation.subject) return { + senderEmail, to: [], cc: [], subject: prefixedSubject(kind, conversation.subject), @@ -196,6 +215,7 @@ export function planReply( const quote = replyQuote(source) return { + senderEmail, to, cc, subject: prefixedSubject(kind, conversation.subject), diff --git a/src/main/outbox/row.ts b/src/main/outbox/row.ts index a735234b..3707adcf 100644 --- a/src/main/outbox/row.ts +++ b/src/main/outbox/row.ts @@ -9,6 +9,7 @@ import { parseStoredDraftAttachments, type StoredDraftAttachment } from './draft * drifted apart in the first place. */ export interface OutboxContentRow { + sender_email?: string | null to_json: string cc_json: string bcc_json: string @@ -32,6 +33,7 @@ export interface OutboxDraftRow extends OutboxContentRow { /** Authored content, with attachments as the main-process-owned stored shape. */ export interface OutboxDraftContent { + senderEmail?: string to: MailAddress[] cc: MailAddress[] bcc: MailAddress[] @@ -56,6 +58,7 @@ export function outboxReferences(value: string): string[] { export function outboxDraftContent(row: OutboxContentRow): OutboxDraftContent { return { + ...(row.sender_email ? { senderEmail: row.sender_email } : {}), to: outboxAddresses(row.to_json), cc: outboxAddresses(row.cc_json), bcc: outboxAddresses(row.bcc_json), diff --git a/src/main/outbox/sendAs.ts b/src/main/outbox/sendAs.ts index e4715c99..541e546b 100644 --- a/src/main/outbox/sendAs.ts +++ b/src/main/outbox/sendAs.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' -import type { DraftSaveInput } from '../../shared/drafts' +import { isValidEmail, normalizeEmailKey } from '../../shared/address' +import type { DraftSaveInput, SendAsIdentity } from '../../shared/drafts' import { ATTN_SIGNATURE_LINE, ATTN_SIGNATURE_URL } from '../../shared/settings' import type { Db } from '../db' import { textFromRaw } from '../gmail/parse' @@ -106,9 +107,18 @@ export function cachePrimarySendAs(db: Db, accountId: string, sendAs: ProviderSe export async function syncPrimarySendAs( db: Db, accountId: string, - provider: Pick, + provider: Pick, options?: ProviderRequestOptions ): Promise { + if (provider.listSendAs) { + const identities = await provider.listSendAs(options) + cacheSendAsIdentities(db, accountId, identities) + const primary = identities.find( + (identity) => normalizeEmailKey(identity.sendAsEmail) === normalizeEmailKey(accountId) + ) + if (primary) cachePrimarySendAs(db, accountId, primary) + return primary ?? null + } if (!provider.getSendAs) return null const sendAs = await provider.getSendAs(accountId, options) cachePrimarySendAs(db, accountId, sendAs) @@ -166,10 +176,20 @@ export function prepareDraftWithCachedPrimarySignature( accountId: string, draft: DraftSaveInput ): PreparedPrimarySignatureDraft { + const identities = cachedSendAsIdentities(db, accountId) + const identity = + identities.find((item) => item.sendAsEmail === draft.senderEmail) ?? + identities.find((item) => item.isDefault) ?? + identities[0] + draft = { ...draft, senderEmail: draft.senderEmail ?? identity.sendAsEmail } if (draft.bodyHtml.trim() || draft.bodyText.trim()) { return { draft, defaultSignatureFingerprint: null } } - const signature = cachedPrimarySignature(db, accountId) + const signature = identity.isPrimary + ? cachedPrimarySignature(db, accountId) + : identity.signature + ? signatureBody(identity.signature) + : null const footerWanted = attnSignatureEnabled(db, accountId) if (!signature && !footerWanted) return { draft, defaultSignatureFingerprint: null } const applied = @@ -395,3 +415,63 @@ function rememberUntouchedSignature(key: string, untouched: boolean): void { const oldest = untouchedSignatureCache.keys().next() if (!oldest.done) untouchedSignatureCache.delete(oldest.value) } + +const SEND_AS_IDENTITIES_SETTING = 'sendAsIdentities' + +export function cacheSendAsIdentities(db: Db, accountId: string, identities: ProviderSendAs[]): void { + const accepted = identities + .filter( + (identity) => + isValidEmail(identity.sendAsEmail) && + (normalizeEmailKey(identity.sendAsEmail) === normalizeEmailKey(accountId) || + identity.verificationStatus === 'accepted') + ) + .map((identity) => ({ + sendAsEmail: identity.sendAsEmail, + displayName: identity.displayName ?? '', + replyToAddress: + identity.replyToAddress && isValidEmail(identity.replyToAddress) + ? identity.replyToAddress + : undefined, + isPrimary: normalizeEmailKey(identity.sendAsEmail) === normalizeEmailKey(accountId), + isDefault: identity.isDefault === true, + signature: sanitizeQuoteHtml(identity.signature ?? '') + })) + writeAccountSetting(db, accountId, SEND_AS_IDENTITIES_SETTING, JSON.stringify(accepted)) +} + +export function cachedSendAsIdentities(db: Db, accountId: string): ProviderSendAs[] { + const raw = readAccountSetting(db, accountId, SEND_AS_IDENTITIES_SETTING) + const identities: ProviderSendAs[] = raw ? JSON.parse(raw) : [] + if ( + !identities.some((identity) => normalizeEmailKey(identity.sendAsEmail) === normalizeEmailKey(accountId)) + ) { + identities.unshift({ + sendAsEmail: accountId, + isPrimary: true, + displayName: readAccountSetting(db, accountId, SEND_AS_DISPLAY_NAME_SETTING) ?? '' + }) + } + return identities +} + +export function publicSendAsIdentities(db: Db, accountId: string): SendAsIdentity[] { + return cachedSendAsIdentities(db, accountId).map( + ({ signature: _signature, verificationStatus: _status, ...identity }) => identity + ) +} + +export class SendAsUnavailableError extends Error { + constructor() { + super('This sender is no longer available. Choose a verified From address.') + this.name = 'SendAsUnavailableError' + } +} + +export function resolveSendAs(db: Db, accountId: string, email: string): ProviderSendAs { + const identity = cachedSendAsIdentities(db, accountId).find( + (item) => normalizeEmailKey(item.sendAsEmail) === normalizeEmailKey(email) + ) + if (!identity) throw new SendAsUnavailableError() + return identity +} diff --git a/src/main/outbox/sendAsIdentities.test.ts b/src/main/outbox/sendAsIdentities.test.ts new file mode 100644 index 00000000..2f05797e --- /dev/null +++ b/src/main/outbox/sendAsIdentities.test.ts @@ -0,0 +1,290 @@ +import { expect, it, vi } from 'vitest' +import { emptyDraftInput } from '../../shared/drafts' +import { openDatabase } from '../db' +import { fakeMailProvider } from '../testing/fakes' +import { encodeDraftMessage } from './draftMime' +import { draftContentFingerprint } from './draftSync' +import { closeDraft, getDraft, reopenDraft, saveDraft } from './drafts' +import { queueSend, undoQueuedSend } from './queue' +import { + cacheSendAsIdentities, + prepareDraftWithCachedPrimarySignature, + publicSendAsIdentities, + resolveSendAs, + syncPrimarySendAs +} from './sendAs' +import { OutboxSender } from './sender' + +const ACCOUNT = 'me@example.com' +const ALIAS = 'work@example.org' +const identities = [ + { sendAsEmail: ACCOUNT, isPrimary: true }, + { + sendAsEmail: ALIAS, + displayName: 'Work Name', + replyToAddress: 'reply@example.org', + isDefault: true, + verificationStatus: 'accepted', + signature: 'Work signature' + }, + { sendAsEmail: 'pending@example.org', verificationStatus: 'pending' } +] + +it('caches only verified identities, strips private fields, and isolates accounts', async () => { + const db = openDatabase(':memory:') + try { + await syncPrimarySendAs(db, ACCOUNT, { listSendAs: async () => identities }) + expect(publicSendAsIdentities(db, ACCOUNT).map((identity) => identity.sendAsEmail)).toEqual([ + ACCOUNT, + ALIAS + ]) + expect(publicSendAsIdentities(db, ACCOUNT)[1]).not.toHaveProperty('signature') + expect(() => resolveSendAs(db, 'other@example.com', ALIAS)).toThrow('no longer available') + const prepared = prepareDraftWithCachedPrimarySignature(db, ACCOUNT, emptyDraftInput()) + expect(prepared.draft.senderEmail).toBe(ALIAS) + expect(prepared.draft.bodyHtml).toContain('Work signature') + expect(prepared.draft.bodyHtml).not.toContain('