From e1b609b50344fbc9d0cc7f7fe2a2691474593883 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:01:17 -0700 Subject: [PATCH] feat: sync Masked Email addresses when the JMAP server offers the capability Extends the JMAP identity sync to also mirror Fastmail's Masked Email addresses into the private sendable_addresses set, but only when a JMAP session actually advertises the vendor capability (https://www.fastmail.com/dev/maskedemail) on a usable primary account. Servers without it -- Stalwart, or any standard JMAP server -- are completely unaffected: the capability is detected, never required. Only 'enabled' masks are synced (pending/disabled/deleted are not currently usable to send as); the display name falls back to the mask's forDomain when no description is set. If the server stops advertising the capability, every previously-synced masked row for that account is removed on the next sync, while identity rows are untouched -- the two reconcile together in one transaction. A masked-email provider_id can collide with an identity's provider_id without conflict, since the table's uniqueness is scoped per kind. Fixed a latent bug this surfaced: replySender.js and senderAuthorization.js both filtered sendable_addresses on kind = 'identity', which would have silently hidden every masked row from reply resolution and send authorization. Both are now kind-agnostic -- kind only disambiguates how a row was sourced, never whether it's usable. No UI surfaces, no masked-email creation, no per-mask settings -- a read-only mirror of what already exists on the server, like the identity sync itself. Co-Authored-By: Claude Fable 5 --- .../0035_message_delivery_addresses.sql | 8 + .../migrations/0036_jmap_identity_sync.sql | 34 ++ backend/src/routes/accounts.jmap.test.js | 334 +++++++++++++ backend/src/routes/accounts.js | 181 ++++++- backend/src/routes/mail.js | 38 +- backend/src/routes/mail.replySender.test.js | 105 ++++ backend/src/routes/send.js | 18 +- backend/src/routes/send.sender.test.js | 199 ++++++++ backend/src/services/identitySync.js | 139 ++++++ backend/src/services/identitySync.test.js | 386 +++++++++++++++ backend/src/services/imapManager.js | 34 +- backend/src/services/imapManager.test.js | 11 + backend/src/services/jmapClient.js | 255 ++++++++++ backend/src/services/jmapClient.test.js | 468 ++++++++++++++++++ .../services/messageParser.headers.test.js | 45 ++ backend/src/services/messageParser.js | 23 + backend/src/services/messageService.js | 6 +- backend/src/services/messageService.test.js | 25 + backend/src/services/replySender.js | 107 ++++ backend/src/services/replySender.test.js | 224 +++++++++ backend/src/services/senderAddress.js | 45 ++ backend/src/services/senderAuthorization.js | 57 +++ .../src/services/senderAuthorization.test.js | 103 ++++ frontend/src/components/AdminPanel.jsx | 102 +++- frontend/src/components/ComposeModal.jsx | 68 ++- frontend/src/components/MessagePane.jsx | 46 +- frontend/src/locales/de.json | 18 +- frontend/src/locales/en.json | 18 +- frontend/src/locales/es.json | 18 +- frontend/src/locales/fr.json | 18 +- frontend/src/locales/i18n.test.js | 2 + frontend/src/locales/it.json | 18 +- frontend/src/locales/ru.json | 18 +- frontend/src/locales/zhCN.json | 18 +- frontend/src/utils/api.js | 10 +- frontend/src/utils/fromValue.js | 33 ++ frontend/src/utils/fromValue.test.js | 42 ++ frontend/src/utils/replyAlias.js | 29 ++ frontend/src/utils/replyAlias.test.js | 153 ++++++ 39 files changed, 3366 insertions(+), 90 deletions(-) create mode 100644 backend/migrations/0035_message_delivery_addresses.sql create mode 100644 backend/migrations/0036_jmap_identity_sync.sql create mode 100644 backend/src/routes/accounts.jmap.test.js create mode 100644 backend/src/routes/mail.replySender.test.js create mode 100644 backend/src/routes/send.sender.test.js create mode 100644 backend/src/services/identitySync.js create mode 100644 backend/src/services/identitySync.test.js create mode 100644 backend/src/services/jmapClient.js create mode 100644 backend/src/services/jmapClient.test.js create mode 100644 backend/src/services/replySender.js create mode 100644 backend/src/services/replySender.test.js create mode 100644 backend/src/services/senderAddress.js create mode 100644 backend/src/services/senderAuthorization.js create mode 100644 backend/src/services/senderAuthorization.test.js create mode 100644 frontend/src/utils/fromValue.js create mode 100644 frontend/src/utils/fromValue.test.js create mode 100644 frontend/src/utils/replyAlias.js create mode 100644 frontend/src/utils/replyAlias.test.js diff --git a/backend/migrations/0035_message_delivery_addresses.sql b/backend/migrations/0035_message_delivery_addresses.sql new file mode 100644 index 0000000..0970dce --- /dev/null +++ b/backend/migrations/0035_message_delivery_addresses.sql @@ -0,0 +1,8 @@ +-- Addresses a message was delivered to (Delivered-To / X-Delivered-To / +-- X-Original-To / Envelope-To), captured at ingest. Reply alias selection +-- matches these in addition to To/Cc, so a message delivered to an alias +-- via BCC or a catch-all still replies from that alias. +-- Nullable like list_unsubscribe: NULL marks rows ingested before capture +-- existed, so the sync upsert's COALESCE backfills them on a later re-sync. +ALTER TABLE messages + ADD COLUMN IF NOT EXISTS delivery_addresses JSONB; diff --git a/backend/migrations/0036_jmap_identity_sync.sql b/backend/migrations/0036_jmap_identity_sync.sql new file mode 100644 index 0000000..f562d24 --- /dev/null +++ b/backend/migrations/0036_jmap_identity_sync.sql @@ -0,0 +1,34 @@ +-- Read-only JMAP identity sync (host-configurable — Fastmail is one provider +-- profile, not a pinned default; Stalwart and other self-hosted JMAP servers +-- work the same way). Only Identity/get is ever called; nothing is written +-- back to the provider. +-- +-- sendable_addresses is a private authorization set, never returned as a list +-- by any API and never rendered in any UI list or From picker enumeration — +-- "delivered to X" does not mean "allowed to send as X". It exists only so +-- reply-time and send-time can transiently authorize a From that the +-- provider has actually verified. +ALTER TABLE email_accounts + ADD COLUMN IF NOT EXISTS jmap_session_url TEXT, + ADD COLUMN IF NOT EXISTS jmap_api_token TEXT, + ADD COLUMN IF NOT EXISTS jmap_identity_sync_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS jmap_identity_sync_error TEXT; + +CREATE TABLE IF NOT EXISTS sendable_addresses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + -- Lowercase exact address, or a `*@domain` catch-all pattern (Fastmail custom-domain + -- identities can be wildcarded). + address VARCHAR(255) NOT NULL, + name TEXT NOT NULL DEFAULT '', + reply_to JSONB NOT NULL DEFAULT '[]', + -- 'identity' is the only kind synced by this PR; 'masked' is reserved for the optional + -- Masked Email follow-up (synced only when the provider advertises that capability). + kind VARCHAR(20) NOT NULL CHECK (kind IN ('identity', 'masked')), + provider_id VARCHAR(255) NOT NULL, + synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (account_id, kind, provider_id) +); + +CREATE INDEX IF NOT EXISTS sendable_addresses_account_address_idx + ON sendable_addresses (account_id, address); diff --git a/backend/src/routes/accounts.jmap.test.js b/backend/src/routes/accounts.jmap.test.js new file mode 100644 index 0000000..919310c --- /dev/null +++ b/backend/src/routes/accounts.jmap.test.js @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; + +// JMAP identity sync surface of accounts.js end-to-end: token encrypt-on-write, the +// validate-before-insert/update gate (JMAP_CONFIG -> 422, JMAP_SYNC -> accept + best-effort +// sync), the token-clear transaction (wipe token/sync columns + delete every synced +// sendable_addresses row), the manual refresh endpoint's status shape + rate limit, and +// that the token itself never appears in any response. syncAccountIdentities and +// loadJmapSession are mocked — their own behavior is covered by identitySync.test.js and +// jmapClient.test.js; this file is about how the route wires them. +vi.mock('../services/db.js', () => ({ query: vi.fn(), withTransaction: vi.fn() })); +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req, _res, next) => { req.session = { userId: 'u1' }; next(); }, +})); +vi.mock('../index.js', () => ({ + imapManager: { connectAccount: vi.fn().mockResolvedValue(undefined), disconnectAccount: vi.fn().mockResolvedValue(undefined) }, +})); +vi.mock('../services/encryption.js', () => ({ encrypt: vi.fn(v => (v ? `enc:${v}` : v)) })); +vi.mock('../services/jmapClient.js', () => ({ loadJmapSession: vi.fn() })); +vi.mock('../services/identitySync.js', () => ({ syncAccountIdentities: vi.fn() })); +vi.mock('../services/rateLimiter.js', () => ({ consume: vi.fn().mockResolvedValue({ limited: false, resetMs: 0 }) })); +// Real DNS-resolving validateHost would make every jmap_session_url-carrying test a real +// network call; mock it so it approves by default, and let individual tests override it +// to exercise the SSRF-rejection paths. +vi.mock('../services/hostValidation.js', () => ({ validateHost: vi.fn().mockResolvedValue(null) })); +// getConnectionPolicy caches its result at module scope for 30s (see connectionPolicy.js) +// across every test in this file — mock it directly rather than through query() so each +// test's mockResolvedValueOnce chain matches only the calls this route actually makes. +vi.mock('../services/connectionPolicy.js', () => ({ + getConnectionPolicy: vi.fn().mockResolvedValue({ allowPrivateHosts: false, allowInsecureTls: false, allowNonstandardPorts: false }), +})); + +import express from 'express'; +import { query, withTransaction } from '../services/db.js'; +import { encrypt } from '../services/encryption.js'; +import { loadJmapSession } from '../services/jmapClient.js'; +import { syncAccountIdentities } from '../services/identitySync.js'; +import { consume } from '../services/rateLimiter.js'; +import { validateHost } from '../services/hostValidation.js'; +import accountRoutes from './accounts.js'; + +const ACCOUNT_ID = 'acct-1'; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/api/accounts', accountRoutes); + return app; +} + +let server; +let base; + +beforeAll(async () => { + await new Promise((resolve) => { server = buildApp().listen(0, resolve); }); + base = `http://127.0.0.1:${server.address().port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +beforeEach(() => { + query.mockReset(); + withTransaction.mockReset(); + encrypt.mockClear(); + loadJmapSession.mockReset(); + syncAccountIdentities.mockReset(); + consume.mockReset().mockResolvedValue({ limited: false, resetMs: 0 }); + validateHost.mockReset().mockResolvedValue(null); +}); + +const post = (path, body) => fetch(`${base}/api/accounts${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), +}); +const put = (path, body) => fetch(`${base}/api/accounts${path}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), +}); + +function insertedRow(overrides = {}) { + return { + id: ACCOUNT_ID, user_id: 'u1', name: 'Work', sender_name: null, email_address: 'me@example.com', + color: '#6366f1', protocol: 'imap', imap_host: null, imap_port: 993, imap_skip_tls_verify: false, + smtp_host: null, smtp_port: 587, smtp_tls: 'STARTTLS', auth_user: null, oauth_provider: null, + enabled: true, last_sync: null, sync_error: null, sort_order: 0, folder_mappings: {}, + signature: null, created_at: new Date().toISOString(), categorization_enabled: false, + gtd_enabled: false, gtd_folders: {}, + jmap_session_url: null, jmap_api_token: null, jmap_identity_sync_at: null, jmap_identity_sync_error: null, + ...overrides, + }; +} + +describe('POST /accounts — jmap_api_token', () => { + it('rejects a token without a session URL, before any DB or JMAP call', async () => { + const res = await post('/', { name: 'Work', email_address: 'me@example.com', jmap_api_token: 'secret-token' }); + + expect(res.status).toBe(400); + expect(query).not.toHaveBeenCalled(); + expect(loadJmapSession).not.toHaveBeenCalled(); + }); + + it('rejects a bad token/config with 422 and never writes the account row', async () => { + loadJmapSession.mockRejectedValue(Object.assign(new Error('token rejected'), { code: 'JMAP_CONFIG', status: 422 })); + + const res = await post('/', { + name: 'Work', email_address: 'me@example.com', + jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'secret-token', + }); + const body = await res.json(); + + expect(res.status).toBe(422); + expect(body.error).toBe('token rejected'); + expect(query).not.toHaveBeenCalled(); // no INSERT — rejected before any DB write + }); + + it('accepts the save on a transient (JMAP_SYNC) failure and still creates the account', async () => { + query + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:secret-token' })] }) // INSERT + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:secret-token', jmap_identity_sync_error: 'Could not reach the JMAP server' })] }); // loadOwnedAccount refetch + loadJmapSession.mockRejectedValue(Object.assign(new Error('unreachable'), { code: 'JMAP_SYNC' })); + syncAccountIdentities.mockRejectedValue(Object.assign(new Error('unreachable'), { code: 'JMAP_SYNC' })); + + const res = await post('/', { + name: 'Work', email_address: 'me@example.com', + jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'secret-token', + }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.jmap_identity_sync_configured).toBe(true); + expect(body.jmap_identity_sync_error).toBe('Could not reach the JMAP server'); + expect(body.jmap_api_token).toBeUndefined(); + }); + + it('encrypts the token before storing it, and never returns it', async () => { + query + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:secret-token' })] }) + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:secret-token', jmap_identity_sync_at: new Date().toISOString() })] }); + loadJmapSession.mockResolvedValue({ sessionUrl: 'https://mail.example.com/jmap' }); + syncAccountIdentities.mockResolvedValue({ syncedAt: new Date() }); + + const res = await post('/', { + name: 'Work', email_address: 'me@example.com', + jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'secret-token', + }); + const rawBody = await res.text(); + + expect(res.status).toBe(200); + expect(encrypt).toHaveBeenCalledWith('secret-token'); + // INSERT params include the encrypted (not plaintext) token. + const insertCall = query.mock.calls[0]; + expect(insertCall[1]).toContain('enc:secret-token'); + expect(rawBody).not.toContain('secret-token'); + expect(syncAccountIdentities).toHaveBeenCalledWith(ACCOUNT_ID); + }); +}); + +describe('POST/PUT /accounts — jmap_session_url SSRF guard', () => { + it('POST rejects a plaintext http session URL when private hosts are not allowed, before any DB call', async () => { + const res = await post('/', { name: 'Work', email_address: 'me@example.com', jmap_session_url: 'http://mail.example.com/jmap' }); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toMatch(/JMAP:.*HTTPS/i); + expect(query).not.toHaveBeenCalled(); + expect(validateHost).not.toHaveBeenCalled(); // rejected on the policy check alone + }); + + it('POST rejects a session URL whose host resolves to a private/reserved address', async () => { + validateHost.mockResolvedValue('Host resolves to a private or reserved IP address'); + + const res = await post('/', { name: 'Work', email_address: 'me@example.com', jmap_session_url: 'https://internal.example.com/jmap' }); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe('JMAP: Host resolves to a private or reserved IP address'); + expect(query).not.toHaveBeenCalled(); + }); + + it('PUT rejects an updated session URL whose host resolves to a private/reserved address', async () => { + query.mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID, gtd_folders: {}, jmap_session_url: 'https://mail.example.com/jmap' }] }); + validateHost.mockResolvedValue('Host resolves to a private or reserved IP address'); + + const res = await put(`/${ACCOUNT_ID}`, { jmap_session_url: 'https://internal.example.com/jmap' }); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe('JMAP: Host resolves to a private or reserved IP address'); + expect(query).toHaveBeenCalledTimes(1); // only the ownership check — no UPDATE + }); +}); + +describe('PUT /accounts/:id — jmap_api_token', () => { + it('clears the token, sync status, and every synced sendable address in one transaction', async () => { + query.mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID, gtd_folders: {}, jmap_session_url: 'https://mail.example.com/jmap' }] }); // ownership check + const clientCalls = []; + const client = { query: vi.fn(async (sql, params) => { clientCalls.push([sql, params]); return { rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap' })] }; }) }; + withTransaction.mockImplementation(async fn => fn(client)); + + const res = await put(`/${ACCOUNT_ID}`, { jmap_api_token: '' }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.jmap_identity_sync_configured).toBe(false); + expect(body.jmap_api_token).toBeUndefined(); + expect(clientCalls.some(([sql]) => /SET\s+jmap_api_token\s*=\s*NULL/.test(sql))).toBe(true); + expect(clientCalls.some(([sql]) => sql.includes('DELETE FROM sendable_addresses'))).toBe(true); + expect(syncAccountIdentities).not.toHaveBeenCalled(); + }); + + it('clearing the token in the same request as other field edits still persists those edits (regression: the settings form bundles every field into one Save)', async () => { + query.mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID, gtd_folders: {}, jmap_session_url: 'https://mail.example.com/jmap' }] }); // ownership check + const clientCalls = []; + const client = { + query: vi.fn(async (sql, params) => { + clientCalls.push([sql, params]); + return { rows: [insertedRow({ name: 'New Name', jmap_session_url: 'https://mail.example.com/jmap' })] }; + }), + }; + withTransaction.mockImplementation(async fn => fn(client)); + + const res = await put(`/${ACCOUNT_ID}`, { jmap_api_token: '', name: 'New Name' }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.name).toBe('New Name'); + expect(body.jmap_identity_sync_configured).toBe(false); + const updateCall = clientCalls.find(([sql]) => sql.startsWith('UPDATE email_accounts')); + expect(updateCall[0]).toMatch(/name\s*=\s*\$1/); // the name change is not dropped + expect(updateCall[1]).toContain('New Name'); + expect(updateCall[0]).toMatch(/jmap_api_token\s*=\s*NULL/); + expect(updateCall[0]).toMatch(/jmap_identity_sync_at\s*=\s*NULL/); + expect(updateCall[0]).toMatch(/jmap_identity_sync_error\s*=\s*NULL/); + expect(clientCalls.some(([sql]) => sql.includes('DELETE FROM sendable_addresses'))).toBe(true); + expect(syncAccountIdentities).not.toHaveBeenCalled(); + }); + + it('rejects a 400 when a token is set with no session URL configured or provided', async () => { + query.mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID, gtd_folders: {}, jmap_session_url: null }] }); + + const res = await put(`/${ACCOUNT_ID}`, { jmap_api_token: 'secret-token' }); + + expect(res.status).toBe(400); + expect(loadJmapSession).not.toHaveBeenCalled(); + }); + + it('validates against the existing stored session URL when the request only sends a token', async () => { + query + .mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID, gtd_folders: {}, jmap_session_url: 'https://mail.example.com/jmap' }] }) // ownership check + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:new-token' })] }) // UPDATE + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:new-token', jmap_identity_sync_at: new Date().toISOString() })] }); // loadOwnedAccount + loadJmapSession.mockResolvedValue({}); + syncAccountIdentities.mockResolvedValue({ syncedAt: new Date() }); + + const res = await put(`/${ACCOUNT_ID}`, { jmap_api_token: 'new-token' }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(loadJmapSession).toHaveBeenCalledWith('https://mail.example.com/jmap', 'new-token', { allowPrivate: false }); + expect(body.jmap_identity_sync_configured).toBe(true); + expect(syncAccountIdentities).toHaveBeenCalledWith(ACCOUNT_ID); + }); + + it('rejects a bad token with 422 before writing anything', async () => { + query.mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID, gtd_folders: {}, jmap_session_url: 'https://mail.example.com/jmap' }] }); + loadJmapSession.mockRejectedValue(Object.assign(new Error('bad token'), { code: 'JMAP_CONFIG', status: 422 })); + + const res = await put(`/${ACCOUNT_ID}`, { jmap_api_token: 'bad-token' }); + const body = await res.json(); + + expect(res.status).toBe(422); + expect(body.error).toBe('bad token'); + expect(query).toHaveBeenCalledTimes(1); // only the ownership check — no UPDATE + }); +}); + +describe('POST /accounts/:id/identities/refresh', () => { + it('returns 404 when the account is not owned by the user', async () => { + query.mockResolvedValueOnce({ rows: [] }); + + const res = await post(`/${ACCOUNT_ID}/identities/refresh`, {}); + + expect(res.status).toBe(404); + }); + + it('returns 409 when JMAP identity sync is not configured', async () => { + query.mockResolvedValueOnce({ rows: [insertedRow({ jmap_api_token: null })] }); + + const res = await post(`/${ACCOUNT_ID}/identities/refresh`, {}); + + expect(res.status).toBe(409); + expect(syncAccountIdentities).not.toHaveBeenCalled(); + }); + + it('runs the sync and returns only a { synced_at, error } status shape — never addresses', async () => { + const syncedAt = new Date().toISOString(); + query + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_api_token: 'enc:token' })] }) // loadOwnedAccount (ownership + configured check) + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_api_token: 'enc:token', jmap_identity_sync_at: syncedAt, jmap_identity_sync_error: null })] }); // loadOwnedAccount refetch + syncAccountIdentities.mockResolvedValue({ syncedAt: new Date(syncedAt) }); + + const res = await post(`/${ACCOUNT_ID}/identities/refresh`, {}); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(Object.keys(body).sort()).toEqual(['error', 'synced_at']); + expect(body.synced_at).toBe(syncedAt); + expect(body.error).toBeNull(); + expect(syncAccountIdentities).toHaveBeenCalledWith(ACCOUNT_ID); + }); + + it('reports a recorded sync error rather than throwing', async () => { + query + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_api_token: 'enc:token' })] }) + .mockResolvedValueOnce({ rows: [insertedRow({ jmap_api_token: 'enc:token', jmap_identity_sync_error: 'bad token' })] }); + syncAccountIdentities.mockRejectedValue(Object.assign(new Error('bad token'), { code: 'JMAP_CONFIG' })); + + const res = await post(`/${ACCOUNT_ID}/identities/refresh`, {}); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.error).toBe('bad token'); + }); + + it('is rate-limited per user, with a Retry-After header on 429', async () => { + consume.mockResolvedValue({ limited: true, resetMs: 3000 }); + + const res = await post(`/${ACCOUNT_ID}/identities/refresh`, {}); + + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toBe('3'); + expect(query).not.toHaveBeenCalled(); + expect(consume.mock.calls[0][0]).toContain('u1'); + }); +}); diff --git a/backend/src/routes/accounts.js b/backend/src/routes/accounts.js index 39f2202..6a82ac2 100644 --- a/backend/src/routes/accounts.js +++ b/backend/src/routes/accounts.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { query } from '../services/db.js'; +import { query, withTransaction } from '../services/db.js'; import { requireAuth } from '../middleware/auth.js'; import { imapManager } from '../index.js'; import { encrypt } from '../services/encryption.js'; @@ -8,6 +8,9 @@ import { validateHost } from '../services/hostValidation.js'; import { getConnectionPolicy } from '../services/connectionPolicy.js'; import { invalidateGtdConfigCache, sanitizeGtdFoldersDetailed, findGtdFolderCollisions, DEFAULT_GTD_FOLDERS } from '../services/gtdConfig.js'; import { createKeyedSerializer } from '../utils/keyedSerializer.js'; +import { loadJmapSession } from '../services/jmapClient.js'; +import { syncAccountIdentities } from '../services/identitySync.js'; +import { consume as rlConsume } from '../services/rateLimiter.js'; // Serialize an account's reconnect triggers so a rapid settings change (e.g. a // gtd_enabled double-toggle) can't fire two overlapping disconnect→connect chains — @@ -37,6 +40,33 @@ function hasHeaderInjectionChars(str) { return typeof str === 'string' && /[\r\n\0]/.test(str); } +// Save-time SSRF guard for the user-configured JMAP session URL — the Bearer token +// travels to whatever host this points at, so it gets the same host policy as the +// imap_host/smtp_host fields above, plus the scheme rule carddavAccount.js uses for its +// own user-configured server URL: HTTPS is required unless the host is genuinely +// private/local AND the admin policy allows private hosts (never plaintext to a public +// host). jmapClient.js enforces this same rule again at request time; this is the +// friendlier save-time 400 in front of it. +async function validateJmapSessionUrl(rawUrl, policy) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + return 'Invalid JMAP session URL'; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return 'JMAP session URL must be http(s)'; + } + if (parsed.protocol === 'http:') { + if (!policy.allowPrivateHosts) return 'JMAP session URL must use HTTPS'; + const publicErr = await validateHost(parsed.hostname, { allowPrivate: false }); + if (!publicErr) { // resolves to a public address + return 'HTTPS is required for a public host; plaintext HTTP is only allowed for a private/local address'; + } + } + return validateHost(parsed.hostname, { allowPrivate: policy.allowPrivateHosts }); +} + const router = Router(); router.use(requireAuth); @@ -49,20 +79,34 @@ const SAFE_FIELDS = [ 'last_sync', 'sync_error', 'sort_order', 'folder_mappings', 'signature', 'created_at', 'categorization_enabled', 'gtd_enabled', 'gtd_folders', + 'jmap_session_url', 'jmap_identity_sync_at', 'jmap_identity_sync_error', ]; function safeAccount(row) { const obj = Object.fromEntries(SAFE_FIELDS.map(k => [k, row[k]])); // Sanitize on read so legacy values stored before the write-time sanitizer are safe if (obj.signature) obj.signature = sanitizeSignature(obj.signature); + // Never the token itself, never the synced address list (sendable_addresses stays + // private) — just a boolean the settings UI can render a "configured" state + // from. row.jmap_identity_sync_configured is set when the caller's SELECT already + // computed it (e.g. the GET / list query); otherwise fall back to the raw token column, + // which is present when row comes from an INSERT/UPDATE RETURNING *. + obj.jmap_identity_sync_configured = row.jmap_identity_sync_configured ?? Boolean(row.jmap_api_token); return obj; } +async function loadOwnedAccount(id, userId) { + const result = await query('SELECT * FROM email_accounts WHERE id = $1 AND user_id = $2', [id, userId]); + return result.rows[0] || null; +} + router.get('/', async (req, res) => { const result = await query( `SELECT id, name, sender_name, email_address, color, protocol, imap_host, imap_port, imap_tls, imap_skip_tls_verify, smtp_host, smtp_port, smtp_tls, auth_user, oauth_provider, enabled, last_sync, sync_error, sort_order, folder_mappings, signature, created_at, - categorization_enabled, gtd_enabled, gtd_folders + categorization_enabled, gtd_enabled, gtd_folders, + jmap_session_url, jmap_identity_sync_at, jmap_identity_sync_error, + (jmap_api_token IS NOT NULL) AS jmap_identity_sync_configured FROM email_accounts WHERE user_id = $1 ORDER BY sort_order, created_at`, [req.session.userId] ); @@ -99,7 +143,8 @@ router.post('/', async (req, res) => { smtp_host, smtp_port = 587, smtp_tls = 'STARTTLS', auth_user, auth_pass, oauth_provider, oauth_access_token, oauth_refresh_token, - signature = null + signature = null, + jmap_session_url = null, jmap_api_token = null, } = req.body; if (!name || !email_address) return res.status(400).json({ error: 'Name and email required' }); @@ -109,6 +154,9 @@ router.post('/', async (req, res) => { if (sender_name && hasHeaderInjectionChars(sender_name)) { return res.status(400).json({ error: 'Sender name cannot contain control characters' }); } + if (jmap_api_token && !jmap_session_url) { + return res.status(400).json({ error: 'JMAP session URL is required to set an API token' }); + } const policy = await getConnectionPolicy(); @@ -122,6 +170,22 @@ router.post('/', async (req, res) => { || (!policy.allowNonstandardPorts && validatePort(smtp_port, ALLOWED_SMTP_PORTS)); if (err) return res.status(400).json({ error: `SMTP: ${err}` }); } + if (jmap_session_url) { + const err = await validateJmapSessionUrl(jmap_session_url, policy); + if (err) return res.status(400).json({ error: `JMAP: ${err}` }); + } + + // Validate the token/session URL before writing anything — a config fault (bad token, + // wrong URL, missing capability) rejects the save with 422; a transient reach failure is + // tolerated here and instead recorded by the post-insert syncAccountIdentities call below. + if (jmap_api_token) { + try { + await loadJmapSession(jmap_session_url, jmap_api_token, { allowPrivate: policy.allowPrivateHosts }); + } catch (error) { + if (error.code === 'JMAP_CONFIG') return res.status(error.status).json({ error: error.message }); + if (error.code !== 'JMAP_SYNC') throw error; + } + } try { const result = await query(` @@ -129,14 +193,14 @@ router.post('/', async (req, res) => { user_id, name, sender_name, email_address, color, protocol, imap_host, imap_port, imap_tls, imap_skip_tls_verify, smtp_host, smtp_port, smtp_tls, auth_user, auth_pass, oauth_provider, oauth_access_token, oauth_refresh_token, - signature - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + signature, jmap_session_url, jmap_api_token + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) RETURNING * `, [ req.session.userId, name, sender_name || null, email_address, color, protocol, imap_host, imap_port, Number(imap_port) % 1000 === 993, !!imap_skip_tls_verify, smtp_host, smtp_port, smtp_tls, auth_user, encrypt(auth_pass), oauth_provider, encrypt(oauth_access_token), encrypt(oauth_refresh_token), - sanitizeSignature(signature) || null + sanitizeSignature(signature) || null, jmap_session_url, encrypt(jmap_api_token) ]); const account = result.rows[0]; @@ -146,6 +210,13 @@ router.post('/', async (req, res) => { imapManager.connectAccount(account).catch(console.error); } + if (jmap_api_token) { + try { + await syncAccountIdentities(account.id); + } catch { /* recorded on the row by syncAccountIdentities; reported below */ } + return res.json(safeAccount(await loadOwnedAccount(account.id, req.session.userId))); + } + res.json(safeAccount(account)); } catch (err) { console.error(err); @@ -157,18 +228,42 @@ router.put('/:id', async (req, res) => { const { id } = req.params; const updates = req.body; - // Verify ownership. gtd_folders comes back too so a folder remap can be compared - // against the stored value below (a change must reconnect to backfill the new folder). - const check = await query('SELECT id, gtd_folders FROM email_accounts WHERE id = $1 AND user_id = $2', [id, req.session.userId]); + // Verify ownership. gtd_folders/jmap_session_url come back too: a folder remap is + // compared against the stored value below (a change must reconnect to backfill the new + // folder), and jmap_session_url is the fallback validation target when a token is set + // without also setting a new session URL in the same request. + const check = await query('SELECT id, gtd_folders, jmap_session_url FROM email_accounts WHERE id = $1 AND user_id = $2', [id, req.session.userId]); if (!check.rows.length) return res.status(404).json({ error: 'Account not found' }); + const policy = await getConnectionPolicy(); + + // Clearing the token (explicit empty/null) wipes the token, sync status, and every + // synced identity. This is not an early return: the settings form's single Save button + // bundles every edited field into one PUT, so a request can clear the token alongside a + // name/host/signature change in the same call — the clear folds into the general field + // loop below and the sendable_addresses wipe happens in the same transaction as that + // UPDATE (see the `clearingToken` branch there). + const clearingToken = 'jmap_api_token' in updates && !updates.jmap_api_token; + + if (updates.jmap_api_token) { + const effectiveSessionUrl = 'jmap_session_url' in updates ? updates.jmap_session_url : check.rows[0].jmap_session_url; + if (!effectiveSessionUrl) { + return res.status(400).json({ error: 'JMAP session URL is required to set an API token' }); + } + try { + await loadJmapSession(effectiveSessionUrl, updates.jmap_api_token, { allowPrivate: policy.allowPrivateHosts }); + } catch (error) { + if (error.code === 'JMAP_CONFIG') return res.status(error.status).json({ error: error.message }); + if (error.code !== 'JMAP_SYNC') throw error; + } + } + if ('name' in updates && hasHeaderInjectionChars(updates.name)) { return res.status(400).json({ error: 'Name cannot contain control characters' }); } if ('sender_name' in updates && updates.sender_name && hasHeaderInjectionChars(updates.sender_name)) { return res.status(400).json({ error: 'Sender name cannot contain control characters' }); } - const policy = await getConnectionPolicy(); if ('imap_host' in updates && updates.imap_host) { const err = await validateHost(updates.imap_host, { allowPrivate: policy.allowPrivateHosts }); @@ -190,6 +285,10 @@ router.put('/:id', async (req, res) => { if (err) return res.status(400).json({ error: `SMTP: ${err}` }); } } + if ('jmap_session_url' in updates && updates.jmap_session_url) { + const err = await validateJmapSessionUrl(updates.jmap_session_url, policy); + if (err) return res.status(400).json({ error: `JMAP: ${err}` }); + } if ('imap_port' in updates) updates.imap_tls = Number(updates.imap_port) % 1000 === 993; @@ -219,14 +318,15 @@ router.put('/:id', async (req, res) => { gtdFoldersChanged = JSON.stringify(before) !== JSON.stringify(folders); } - const allowed = ['name', 'sender_name', 'color', 'enabled', 'auth_user', 'auth_pass', 'sort_order', 'imap_host', 'imap_port', 'imap_tls', 'imap_skip_tls_verify', 'smtp_host', 'smtp_port', 'smtp_tls', 'folder_mappings', 'signature', 'categorization_enabled', 'gtd_enabled', 'gtd_folders']; + const allowed = ['name', 'sender_name', 'color', 'enabled', 'auth_user', 'auth_pass', 'sort_order', 'imap_host', 'imap_port', 'imap_tls', 'imap_skip_tls_verify', 'smtp_host', 'smtp_port', 'smtp_tls', 'folder_mappings', 'signature', 'categorization_enabled', 'gtd_enabled', 'gtd_folders', 'jmap_session_url', 'jmap_api_token']; const sets = []; const values = []; let i = 1; for (const key of allowed) { + if (key === 'jmap_api_token' && clearingToken) continue; // NULLed as a literal below, alongside the sync-status columns if (key in updates) { sets.push(`${key} = $${i++}`); - const value = (key === 'auth_pass' && updates[key]) ? encrypt(updates[key]) + const value = ((key === 'auth_pass' || key === 'jmap_api_token') && updates[key]) ? encrypt(updates[key]) : (key === 'signature') ? sanitizeSignature(updates[key]) || null : (key === 'gtd_enabled') ? !!updates[key] : (key === 'gtd_folders') ? gtdFoldersValue @@ -234,18 +334,32 @@ router.put('/:id', async (req, res) => { values.push(value); } } + // Clearing the token also wipes sync status; the synced sendable_addresses rows are + // deleted in the same transaction as this UPDATE below. + if (clearingToken) sets.push('jmap_api_token = NULL', 'jmap_identity_sync_at = NULL', 'jmap_identity_sync_error = NULL'); if (!sets.length) return res.status(400).json({ error: 'No valid fields to update' }); values.push(id); - const result = await query( - `UPDATE email_accounts SET ${sets.join(', ')} WHERE id = $${i} RETURNING *`, - values - ); - const updated = result.rows[0]; - const payload = safeAccount(updated); + const updateSql = `UPDATE email_accounts SET ${sets.join(', ')} WHERE id = $${i} RETURNING *`; + const updated = clearingToken + ? await withTransaction(async (client) => { + const result = await client.query(updateSql, values); + await client.query('DELETE FROM sendable_addresses WHERE account_id = $1', [id]); + return result.rows[0]; + }) + : (await query(updateSql, values)).rows[0]; + let payload = safeAccount(updated); // Tell the client which submitted folder values were rejected (over-long / // traversal) and reset to defaults, so the settings form can surface it. if ('gtd_folders' in updates) payload.gtd_folders_rejected = gtdRejected; + + if (updates.jmap_api_token) { + try { + await syncAccountIdentities(updated.id); + } catch { /* recorded on the row by syncAccountIdentities; reported below */ } + payload = safeAccount(await loadOwnedAccount(updated.id, req.session.userId)); + if ('gtd_folders' in updates) payload.gtd_folders_rejected = gtdRejected; + } res.json(payload); // A GTD config change must drop the cached { enabled, folders } so the tick, @@ -317,6 +431,37 @@ router.post('/:id/reconnect', async (req, res) => { res.json({ ok: true }); }); +// A manual refresh reaches out to the user-configured JMAP server, so it gets its own +// per-user limit (mirrors auth.js's rateLimit wrapper) rather than running unbounded. +function identitiesRefreshRateLimit(req, res, next) { + rlConsume(`jmap-refresh:${req.session.userId}`, 10, 60000).then(({ limited, resetMs }) => { + if (limited) { + res.setHeader('Retry-After', Math.ceil(resetMs / 1000)); + return res.status(429).json({ error: 'Too many requests. Please try again shortly.' }); + } + next(); + }).catch(next); +} + +// Manually re-sync an account's JMAP identities. Returns only a sync-status shape +// ({ synced_at, error }) — never the synced address list, which stays private. +router.post('/:id/identities/refresh', identitiesRefreshRateLimit, async (req, res) => { + const account = await loadOwnedAccount(req.params.id, req.session.userId); + if (!account) return res.status(404).json({ error: 'Account not found' }); + if (!account.jmap_api_token) { + return res.status(409).json({ error: 'JMAP identity sync is not configured for this account' }); + } + + try { + await syncAccountIdentities(account.id); + } catch { /* recorded on the row; reported below */ } + const refreshed = await loadOwnedAccount(account.id, req.session.userId); + res.json({ + synced_at: refreshed?.jmap_identity_sync_at || null, + error: refreshed?.jmap_identity_sync_error || null, + }); +}); + // ── Alias CRUD ───────────────────────────────────────────────────────────── router.get('/:id/aliases', async (req, res) => { diff --git a/backend/src/routes/mail.js b/backend/src/routes/mail.js index d1dde19..a275cd7 100644 --- a/backend/src/routes/mail.js +++ b/backend/src/routes/mail.js @@ -12,6 +12,8 @@ import { emitGtdIfRelevant } from '../services/gtdSections.js'; import { listMessages } from '../services/messageService.js'; import { validateHost } from '../services/hostValidation.js'; import { safeFetch } from '../services/safeFetch.js'; +import { resolveReplySender } from '../services/replySender.js'; +import { consume as rlConsume } from '../services/rateLimiter.js'; const router = Router(); router.use(requireAuth); @@ -100,6 +102,18 @@ function emitGtdSectionsRefresh(rows, userId) { } } +// Reply-sender resolution can trigger a JMAP round trip on a stale-sync miss, so it gets +// its own per-user limit (mirrors auth.js's rateLimit wrapper) rather than running unbounded. +function replySenderRateLimit(req, res, next) { + rlConsume(`reply-sender:${req.session.userId}`, 30, 60000).then(({ limited, resetMs }) => { + if (limited) { + res.setHeader('Retry-After', Math.ceil(resetMs / 1000)); + return res.status(429).json({ error: 'Too many requests. Please try again shortly.' }); + } + next(); + }).catch(next); +} + // Get messages (unified or per-account/folder) router.get('/messages', async (req, res) => { const { accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, category } = req.query; @@ -140,7 +154,7 @@ router.get('/messages/:id', async (req, res) => { m.reply_to, m.in_reply_to, m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, - m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, + m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses, a.name AS account_name, a.email_address AS account_email, a.color AS account_color FROM messages m @@ -172,7 +186,7 @@ router.get('/resolve-message', async (req, res) => { m.reply_to, m.in_reply_to, m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, - m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, + m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses, a.name AS account_name, a.email_address AS account_email, a.color AS account_color`; try { @@ -247,7 +261,7 @@ router.get('/thread/:threadId', async (req, res) => { m.reply_to, m.in_reply_to, m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, - m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, + m.list_unsubscribe, m.list_unsubscribe_post, m.unsubscribed_at, m.delivery_addresses, a.name AS account_name, a.email_address AS account_email, a.color AS account_color FROM messages m JOIN email_accounts a ON m.account_id = a.id @@ -499,6 +513,24 @@ router.get('/messages/:id/headers', async (req, res) => { } }); +// Resolve the transient reply From for a message: an address the message was +// delivered/addressed to that the account's synced JMAP identities authorize, but that +// isn't already an address MailFlow trusts today (account primary / a saved alias — those +// are matched client-side). Never returns more than the single matched sender — no +// address enumeration; the sendable set stays private. +router.get('/messages/:id/reply-sender', replySenderRateLimit, async (req, res) => { + const { id } = req.params; + if (!UUID_RE.test(id)) return res.status(400).json({ error: 'Invalid message id' }); + try { + const result = await resolveReplySender({ messageId: id, userId: req.session.userId }); + res.json(result); + } catch (err) { + if (err.status === 404) return res.status(404).json({ error: 'Message not found' }); + console.error('Reply sender resolution error:', err.message); + res.status(500).json({ error: 'Failed to resolve reply sender' }); + } +}); + const ZIP_MAX_FILES = 100; const ZIP_MAX_TOTAL_BYTES = 150 * 1024 * 1024; // 150 MB const ZIP_MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MB per file diff --git a/backend/src/routes/mail.replySender.test.js b/backend/src/routes/mail.replySender.test.js new file mode 100644 index 0000000..cead794 --- /dev/null +++ b/backend/src/routes/mail.replySender.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; + +// GET /messages/:id/reply-sender end-to-end — the HTTP-layer contract the pure +// resolveReplySender test (services/replySender.test.js) can't reach: route mounting, +// invalid-id validation, ownership 404 propagation, the rate limit, and the response +// shape. Matching precedence itself (delivered-to/wildcard/alias-skip/stale-sync) is +// exercised there against a mocked db, not re-derived here — this file mocks +// resolveReplySender directly and checks how the route wraps it. +vi.mock('../index.js', () => ({ imapManager: {} })); +vi.mock('../services/db.js', () => ({ query: vi.fn() })); +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req, _res, next) => { req.session = { userId: 'u1' }; next(); }, +})); +vi.mock('../services/replySender.js', () => ({ resolveReplySender: vi.fn() })); +vi.mock('../services/rateLimiter.js', () => ({ consume: vi.fn().mockResolvedValue({ limited: false, resetMs: 0 }) })); + +import express from 'express'; +import { resolveReplySender } from '../services/replySender.js'; +import { consume } from '../services/rateLimiter.js'; +import mailRoutes from './mail.js'; + +const MESSAGE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/api/mail', mailRoutes); + return app; +} + +let server; +let base; + +beforeAll(async () => { + await new Promise((resolve) => { server = buildApp().listen(0, resolve); }); + base = `http://127.0.0.1:${server.address().port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +beforeEach(() => { + resolveReplySender.mockReset(); + consume.mockReset().mockResolvedValue({ limited: false, resetMs: 0 }); +}); + +const getReplySender = (id) => fetch(`${base}/api/mail/messages/${id}/reply-sender`); + +describe('GET /messages/:id/reply-sender', () => { + it('rejects a non-UUID message id with 400 before touching the resolver', async () => { + const res = await getReplySender('not-a-uuid'); + expect(res.status).toBe(400); + expect(resolveReplySender).not.toHaveBeenCalled(); + }); + + it('returns the resolved sender for a delivered-to hit', async () => { + resolveReplySender.mockResolvedValue({ sender: { fromEmail: 'sales@example.com', name: 'Sales' } }); + + const res = await getReplySender(MESSAGE_ID); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ sender: { fromEmail: 'sales@example.com', name: 'Sales' } }); + expect(resolveReplySender).toHaveBeenCalledWith({ messageId: MESSAGE_ID, userId: 'u1' }); + }); + + it('returns a null sender on a miss — never an address list', async () => { + resolveReplySender.mockResolvedValue({ sender: null }); + + const res = await getReplySender(MESSAGE_ID); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ sender: null }); + }); + + it('maps a resolver 404 (message not owned / not found) to a 404 response', async () => { + resolveReplySender.mockRejectedValue(Object.assign(new Error('Message not found'), { status: 404 })); + + const res = await getReplySender(MESSAGE_ID); + + expect(res.status).toBe(404); + }); + + it('maps an unexpected resolver failure to a generic 500 (no internal detail leaked)', async () => { + resolveReplySender.mockRejectedValue(new Error('db exploded: password=hunter2')); + + const res = await getReplySender(MESSAGE_ID); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(JSON.stringify(body)).not.toContain('hunter2'); + }); + + it('is rate-limited per user, with a Retry-After header on 429', async () => { + consume.mockResolvedValue({ limited: true, resetMs: 5000 }); + + const res = await getReplySender(MESSAGE_ID); + + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toBe('5'); + expect(resolveReplySender).not.toHaveBeenCalled(); + expect(consume.mock.calls[0][0]).toContain('u1'); + }); +}); diff --git a/backend/src/routes/send.js b/backend/src/routes/send.js index 380a68f..80b04b9 100644 --- a/backend/src/routes/send.js +++ b/backend/src/routes/send.js @@ -15,6 +15,7 @@ import { resolveForConnection } from '../services/hostValidation.js'; import { getConnectionPolicy } from '../services/connectionPolicy.js'; import { imapManager } from '../index.js'; import { runTransitionsForSentMessage } from '../services/gtdTransitions.js'; +import { authorizeSendableAddress } from '../services/senderAuthorization.js'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -130,10 +131,11 @@ router.use(requireAuth); router.post('/send', async (req, res) => { - const { accountId, aliasId, to, cc = [], bcc = [], subject, body, bodyIsHtml = false, quotedBody, quotedBodyHtml, inReplyTo, references, attachments, editedSignature, forwardedAttachments, priority } = req.body; + const { accountId, aliasId, fromEmail: requestedFromEmail, to, cc = [], bcc = [], subject, body, bodyIsHtml = false, quotedBody, quotedBodyHtml, inReplyTo, references, attachments, editedSignature, forwardedAttachments, priority } = req.body; const VALID_PRIORITIES = new Set(['high', 'normal', 'low']); const emailPriority = VALID_PRIORITIES.has(priority) ? priority : 'normal'; if (!accountId || !to?.length) return res.status(400).json({ error: 'accountId and to required' }); + if (aliasId && requestedFromEmail) return res.status(400).json({ error: 'aliasId and fromEmail are mutually exclusive' }); // Idempotency guard. The client sends a stable X-Idempotency-Key per logical send: a // sequential retry after a lost success response returns the cached result, and a @@ -205,6 +207,20 @@ router.post('/send', async (req, res) => { // null (DB default) means inherit from account; only override when alias has an explicit signature set if (alias.signature !== null) fromSignature = alias.signature; } + } else if (requestedFromEmail) { + // A transient From (e.g. the reply-time delivered-to match) — authorized against + // the account's private sendable_addresses set, never saved as an alias. + try { + const authorized = await authorizeSendableAddress({ accountId, fromEmail: requestedFromEmail }); + fromName = authorized.fromName || fromName; + fromEmail = authorized.fromEmail; + fromReplyTo = authorized.replyTo; + } catch (err) { + if (err.code === 'SENDER_UNAVAILABLE') { + return res.status(422).json({ error: err.message, code: 'SENDER_UNAVAILABLE' }); + } + throw err; + } } // Allow the client to override the signature per-send (editedSignature === undefined means use DB value). diff --git a/backend/src/routes/send.sender.test.js b/backend/src/routes/send.sender.test.js new file mode 100644 index 0000000..72ae346 --- /dev/null +++ b/backend/src/routes/send.sender.test.js @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; + +// POST /api/mail/send — the optional fromEmail sender-authorization surface added on top +// of the existing accountId/aliasId shape: mutual exclusivity with aliasId, exact/wildcard +// authorization against sendable_addresses, the 422 SENDER_UNAVAILABLE miss, and the +// resulting mailOptions (From/Reply-To) actually handed to the SMTP transport. The +// underlying matching precedence is covered by senderAuthorization.test.js; this file is +// about how the send route wires it end-to-end (real authorizeSendableAddress, mocked db +// + SMTP transport). +const { sendMailMock, streamSendMock } = vi.hoisted(() => ({ + sendMailMock: vi.fn().mockResolvedValue({}), + streamSendMock: vi.fn().mockResolvedValue({ + message: { on(event, cb) { if (event === 'end') setImmediate(cb); return this; } }, + }), +})); + +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn((opts) => (opts?.streamTransport ? { sendMail: streamSendMock } : { sendMail: sendMailMock })), + }, +})); +vi.mock('../services/db.js', () => ({ query: vi.fn() })); +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req, _res, next) => { req.session = { userId: 'u1' }; next(); }, +})); +vi.mock('../index.js', () => ({ imapManager: {} })); +vi.mock('../services/encryption.js', () => ({ decrypt: vi.fn(v => (v ? `plain-${v}` : v)) })); +vi.mock('../services/hostValidation.js', () => ({ + resolveForConnection: vi.fn().mockResolvedValue({ host: 'smtp.example.com', servername: null }), +})); +vi.mock('../services/connectionPolicy.js', () => ({ + getConnectionPolicy: vi.fn().mockResolvedValue({ allowPrivateHosts: false, allowInsecureTls: false, allowNonstandardPorts: false }), +})); +vi.mock('../services/redis.js', () => ({ + redisClient: { get: vi.fn().mockResolvedValue(null), set: vi.fn().mockResolvedValue('OK'), del: vi.fn().mockResolvedValue(1) }, +})); +vi.mock('../services/gtdTransitions.js', () => ({ runTransitionsForSentMessage: vi.fn().mockResolvedValue(undefined) })); +vi.mock('./oauth.js', () => ({ refreshMicrosoftToken: vi.fn() })); + +import express from 'express'; +import { query } from '../services/db.js'; +import sendRoutes from './send.js'; + +function accountRow(overrides = {}) { + return { + id: 'acct-1', user_id: 'u1', sender_name: null, name: 'Work', email_address: 'me@example.com', + smtp_host: 'smtp.example.com', smtp_port: 587, smtp_tls: 'STARTTLS', imap_skip_tls_verify: false, + auth_user: 'me@example.com', auth_pass: 'enc:pass', oauth_provider: null, oauth_access_token: null, + signature: null, folder_mappings: {}, + ...overrides, + }; +} + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/api/mail', sendRoutes); + return app; +} + +let server; +let base; + +beforeAll(async () => { + await new Promise((resolve) => { server = buildApp().listen(0, resolve); }); + base = `http://127.0.0.1:${server.address().port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +// A dispatcher keyed on SQL text, not call order — the send route schedules a +// fire-and-forget setImmediate (contact auto-learn) that can issue its OWN query() calls +// interleaved with (or after) the next test's, since real loopback HTTP traffic yields the +// event loop many times. Matching by SQL content instead of a positional mockResolvedValueOnce +// queue makes that interleaving harmless: an unmatched/deferred call always safely gets {rows: []}. +function mockQueries({ account, sendable = [], alias = null, sentFolder = null } = {}) { + query.mockImplementation(async (sql) => { + if (sql.includes('FROM email_accounts WHERE id')) return { rows: account ? [account] : [] }; + if (sql.includes('SELECT preferences FROM users')) return { rows: [{}] }; + if (sql.includes('FROM sendable_addresses')) return { rows: sendable }; + if (sql.includes('FROM account_aliases WHERE id')) return { rows: alias ? [alias] : [] }; + if (sql.includes('FROM folders WHERE account_id')) return { rows: sentFolder ? [{ path: sentFolder }] : [] }; + return { rows: [] }; + }); +} + +beforeEach(() => { + query.mockReset(); + sendMailMock.mockClear(); +}); + +const send = (body) => fetch(`${base}/api/mail/send`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), +}); + +const BASE_BODY = { accountId: 'acct-1', to: ['them@example.com'], subject: 'Hi', body: 'hello' }; + +describe('POST /mail/send — aliasId/fromEmail mutual exclusivity', () => { + it('rejects a request with both aliasId and fromEmail before touching the DB', async () => { + const res = await send({ ...BASE_BODY, aliasId: 'alias-1', fromEmail: 'sales@example.com' }); + + expect(res.status).toBe(400); + expect(query).not.toHaveBeenCalled(); + }); +}); + +describe('POST /mail/send — fromEmail authorization', () => { + it('authorizes an exact sendable-address match and sends From that address', async () => { + mockQueries({ account: accountRow(), sendable: [{ address: 'sales@example.com', name: 'Sales', reply_to: [] }] }); + + const res = await send({ ...BASE_BODY, fromEmail: 'sales@example.com' }); + + expect(res.status).toBe(200); + expect(sendMailMock).toHaveBeenCalledOnce(); + const mailOptions = sendMailMock.mock.calls[0][0]; + expect(mailOptions.from).toBe('Sales '); + }); + + // End-to-end seam proof: a Masked Email address synced into sendable_addresses + // (kind='masked') must authorize a real send exactly like a synced identity — + // kind is never selected/filtered by this route or senderAuthorization.js. + it('authorizes and sends from a Masked Email-sourced sendable address', async () => { + mockQueries({ account: accountRow(), sendable: [{ address: 'random1@fastmail.example', name: 'Private address', reply_to: [] }] }); + + const res = await send({ ...BASE_BODY, fromEmail: 'random1@fastmail.example' }); + + expect(res.status).toBe(200); + const mailOptions = sendMailMock.mock.calls[0][0]; + expect(mailOptions.from).toBe('Private address '); + }); + + it('authorizes a wildcard-covered address and sends From the exact requested address', async () => { + mockQueries({ account: accountRow(), sendable: [{ address: '*@example.com', name: 'Catch-all', reply_to: [] }] }); + + const res = await send({ ...BASE_BODY, fromEmail: 'anything@example.com' }); + + expect(res.status).toBe(200); + const mailOptions = sendMailMock.mock.calls[0][0]; + expect(mailOptions.from).toBe('Catch-all '); + }); + + it('rejects a fromEmail not in the sendable set with 422 SENDER_UNAVAILABLE, never reaching SMTP', async () => { + mockQueries({ account: accountRow(), sendable: [] }); + + const res = await send({ ...BASE_BODY, fromEmail: 'unknown@example.com' }); + const resBody = await res.json(); + + expect(res.status).toBe(422); + expect(resBody.code).toBe('SENDER_UNAVAILABLE'); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it('rejects a matched row whose stored name contains header control characters (defense in depth)', async () => { + mockQueries({ account: accountRow(), sendable: [{ address: 'sales@example.com', name: 'Evil\r\nBcc: x@evil.example', reply_to: [] }] }); + + const res = await send({ ...BASE_BODY, fromEmail: 'sales@example.com' }); + + expect(res.status).toBe(422); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it('omits the Reply-To header entirely when the identity has an empty reply_to', async () => { + mockQueries({ account: accountRow(), sendable: [{ address: 'sales@example.com', name: 'Sales', reply_to: [] }] }); + + await send({ ...BASE_BODY, fromEmail: 'sales@example.com' }); + + const mailOptions = sendMailMock.mock.calls[0][0]; + expect(mailOptions).not.toHaveProperty('replyTo'); + }); + + it('sets Reply-To from the matched identity when present', async () => { + mockQueries({ + account: accountRow(), + sendable: [{ address: 'sales@example.com', name: 'Sales', reply_to: [{ name: 'Help', email: 'help@example.com' }] }], + }); + + await send({ ...BASE_BODY, fromEmail: 'sales@example.com' }); + + const mailOptions = sendMailMock.mock.calls[0][0]; + expect(mailOptions.replyTo).toEqual([{ name: 'Help', address: 'help@example.com' }]); + }); +}); + +describe('POST /mail/send — aliasId path is unchanged', () => { + it('still sends from an alias when aliasId is given (no fromEmail involved)', async () => { + mockQueries({ + account: accountRow(), + alias: { id: 'alias-1', account_id: 'acct-1', name: 'Sales', email: 'sales@example.com', reply_to: null, signature: null }, + }); + + const res = await send({ ...BASE_BODY, aliasId: 'alias-1' }); + + expect(res.status).toBe(200); + const mailOptions = sendMailMock.mock.calls[0][0]; + expect(mailOptions.from).toBe('Sales '); + }); +}); diff --git a/backend/src/services/identitySync.js b/backend/src/services/identitySync.js new file mode 100644 index 0000000..670b908 --- /dev/null +++ b/backend/src/services/identitySync.js @@ -0,0 +1,139 @@ +import { query, withTransaction } from './db.js'; +import { decrypt } from './encryption.js'; +import { loadJmapSession, fetchIdentities, fetchMaskedEmails, sessionHasMaskedEmail } from './jmapClient.js'; +import { getConnectionPolicy } from './connectionPolicy.js'; +import { hasHeaderControlCharacters, normalizeEmailAddress, normalizeSenderAddress } from './senderAddress.js'; + +// A Fastmail custom-domain identity can be a `*@domain` catch-all; normalizeSenderAddress +// accepts both that and a normal exact address, so wildcard identities sync the same way +// as any other. +function normalizeReplyToList(value) { + if (!Array.isArray(value)) return []; + const out = []; + for (const entry of value) { + const email = normalizeEmailAddress(entry?.email); + if (!email) continue; + const name = typeof entry?.name === 'string' && !hasHeaderControlCharacters(entry.name) ? entry.name : undefined; + out.push(name ? { name, email } : { email }); + } + return out; +} + +// Identities with an unusable address or a header-control-character name are skipped +// individually rather than failing the whole sync — one malformed identity on the +// provider side shouldn't block every other identity from syncing. +function normalizeIdentities(identities) { + const normalized = []; + for (const identity of identities || []) { + if (!identity || typeof identity.id !== 'string' || !identity.id) continue; + const address = normalizeSenderAddress(identity.email); + if (!address) continue; + const name = typeof identity.name === 'string' ? identity.name : ''; + if (hasHeaderControlCharacters(name)) continue; + normalized.push({ providerId: identity.id, address, name, replyTo: normalizeReplyToList(identity.replyTo) }); + } + return normalized; +} + +// Only 'enabled' masks are usable to send as — 'pending' hasn't been activated yet, +// 'disabled'/'deleted' no longer receive mail (matches the old alias sync's reasoning). +// A mask's address is always concrete (never a `*@domain` wildcard), unlike identities. +// No reply_to: Masked Email has no separate reply-to field in JMAP. +function normalizeMaskedEmails(maskedEmails) { + const normalized = []; + for (const mask of maskedEmails || []) { + if (!mask || typeof mask.id !== 'string' || !mask.id) continue; + if (mask.state !== 'enabled') continue; + const address = normalizeEmailAddress(mask.email); + if (!address) continue; + const name = (typeof mask.description === 'string' && mask.description.trim()) + ? mask.description + : (typeof mask.forDomain === 'string' ? mask.forDomain : ''); + if (hasHeaderControlCharacters(name)) continue; + normalized.push({ providerId: mask.id, address, name, replyTo: [] }); + } + return normalized; +} + +// Upsert every synced row by (account_id, kind, provider_id) and delete any existing row of +// that kind whose provider_id is no longer present. Both kinds reconcile inside one +// transaction so a manual refresh racing an account save can't leave a half-applied set +// across identities and masked addresses. `provider_id` is only unique per kind (the +// table's UNIQUE constraint is on (account_id, kind, provider_id)), so an identity and a +// masked address can legitimately share the same JMAP id without conflict. +async function reconcileSendableAddresses(accountId, groups) { + await withTransaction(async (client) => { + for (const { kind, rows } of groups) { + for (const row of rows) { + await client.query( + `INSERT INTO sendable_addresses (account_id, kind, provider_id, address, name, reply_to, synced_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (account_id, kind, provider_id) DO UPDATE + SET address = EXCLUDED.address, name = EXCLUDED.name, reply_to = EXCLUDED.reply_to, synced_at = NOW()`, + [accountId, kind, row.providerId, row.address, row.name, JSON.stringify(row.replyTo)], + ); + } + await client.query( + `DELETE FROM sendable_addresses + WHERE account_id = $1 AND kind = $2 AND NOT (provider_id = ANY($3::varchar[]))`, + [accountId, kind, rows.map(row => row.providerId)], + ); + } + }); +} + +function safeSyncErrorMessage(err) { + return (err?.code === 'JMAP_CONFIG' || err?.code === 'JMAP_SYNC') + ? err.message + : 'JMAP synchronization failed'; +} + +// Load the account's identities (and, only when the session advertises it, Masked Email +// addresses) over JMAP and reconcile them into sendable_addresses. No-op (not an error) +// when the account has no session URL or token configured. On failure, records the safe +// error message on the account row and rethrows so callers (accounts.js best-effort +// save-time sync, the manual refresh route, replySender's sync-on-miss) each decide how to +// surface it. +// +// Looks up the connection policy itself (rather than requiring every caller to fetch and +// pass it down) so the same allowPrivate the IMAP/SMTP fields honor also gates the user- +// configured JMAP session URL, however this function got invoked. +export async function syncAccountIdentities(accountId) { + const result = await query( + 'SELECT jmap_session_url, jmap_api_token FROM email_accounts WHERE id = $1', + [accountId], + ); + const account = result.rows[0]; + if (!account) return { syncedAt: null }; + + const token = decrypt(account.jmap_api_token); + if (!account.jmap_session_url || !token) return { syncedAt: null }; + + const { allowPrivateHosts: allowPrivate } = await getConnectionPolicy(); + try { + const session = await loadJmapSession(account.jmap_session_url, token, { allowPrivate }); + const identities = await fetchIdentities(session, token, { allowPrivate }); + // Reconciling 'masked' to [] when the capability is absent is deliberate, not just a + // default: a server that stops advertising Masked Email (or never did) must not leave + // stale, permanently-unauthorizable masked rows behind. + const maskedEmails = sessionHasMaskedEmail(session) ? await fetchMaskedEmails(session, token, { allowPrivate }) : []; + + await reconcileSendableAddresses(accountId, [ + { kind: 'identity', rows: normalizeIdentities(identities) }, + { kind: 'masked', rows: normalizeMaskedEmails(maskedEmails) }, + ]); + + const syncedAt = new Date(); + await query( + 'UPDATE email_accounts SET jmap_identity_sync_at = $1, jmap_identity_sync_error = NULL WHERE id = $2', + [syncedAt, accountId], + ); + return { syncedAt }; + } catch (err) { + await query( + 'UPDATE email_accounts SET jmap_identity_sync_error = $1 WHERE id = $2', + [safeSyncErrorMessage(err), accountId], + ).catch(() => {}); + throw err; + } +} diff --git a/backend/src/services/identitySync.test.js b/backend/src/services/identitySync.test.js new file mode 100644 index 0000000..9e6818c --- /dev/null +++ b/backend/src/services/identitySync.test.js @@ -0,0 +1,386 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./db.js', () => ({ query: vi.fn(), withTransaction: vi.fn() })); +vi.mock('./encryption.js', () => ({ decrypt: vi.fn(v => v) })); +vi.mock('./jmapClient.js', () => ({ + loadJmapSession: vi.fn(), + fetchIdentities: vi.fn(), + fetchMaskedEmails: vi.fn(), + sessionHasMaskedEmail: vi.fn(), +})); +vi.mock('./connectionPolicy.js', () => ({ + getConnectionPolicy: vi.fn().mockResolvedValue({ allowPrivateHosts: false, allowInsecureTls: false, allowNonstandardPorts: false }), +})); + +const { query, withTransaction } = await import('./db.js'); +const { decrypt } = await import('./encryption.js'); +const { loadJmapSession, fetchIdentities, fetchMaskedEmails, sessionHasMaskedEmail } = await import('./jmapClient.js'); +const { getConnectionPolicy } = await import('./connectionPolicy.js'); +const { syncAccountIdentities } = await import('./identitySync.js'); + +const ACCOUNT_ID = 'acct-1'; + +// withTransaction's real implementation begins/commits/rolls back around fn(client); the +// mock just needs to hand back a client whose query calls the tests can inspect. +function fakeClient() { + const calls = []; + const client = { query: vi.fn(async (sql, params) => { calls.push([sql, params]); return { rows: [] }; }) }; + return { client, calls }; +} + +// Upserts/deletes are shared across both kinds now (identity + masked reconcile in one +// transaction), so tests select by kind (params[1]) rather than assuming there's only one. +function upsertsFor(calls, kind) { + return calls.filter(([sql, params]) => sql.includes('ON CONFLICT') && params[1] === kind); +} +function deleteFor(calls, kind) { + return calls.find(([sql, params]) => sql.trim().startsWith('DELETE') && params[1] === kind); +} + +beforeEach(() => { + query.mockReset(); + withTransaction.mockReset(); + decrypt.mockReset().mockImplementation(v => v); + loadJmapSession.mockReset(); + fetchIdentities.mockReset(); + getConnectionPolicy.mockReset().mockResolvedValue({ allowPrivateHosts: false, allowInsecureTls: false, allowNonstandardPorts: false }); + fetchMaskedEmails.mockReset(); + // Most tests are about identity reconciliation; default the capability off so they don't + // also have to stub a masked-email fetch. + sessionHasMaskedEmail.mockReset().mockReturnValue(false); +}); + +describe('syncAccountIdentities — no-op cases', () => { + it('is a no-op when the account does not exist', async () => { + query.mockResolvedValueOnce({ rows: [] }); + + const result = await syncAccountIdentities(ACCOUNT_ID); + + expect(result).toEqual({ syncedAt: null }); + expect(loadJmapSession).not.toHaveBeenCalled(); + }); + + it('is a no-op when no session URL is configured', async () => { + query.mockResolvedValueOnce({ rows: [{ jmap_session_url: null, jmap_api_token: 'enc:token' }] }); + + const result = await syncAccountIdentities(ACCOUNT_ID); + + expect(result).toEqual({ syncedAt: null }); + expect(loadJmapSession).not.toHaveBeenCalled(); + }); + + it('is a no-op when no token is configured (decrypt returns falsy)', async () => { + query.mockResolvedValueOnce({ rows: [{ jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: null }] }); + decrypt.mockReturnValue(null); + + const result = await syncAccountIdentities(ACCOUNT_ID); + + expect(result).toEqual({ syncedAt: null }); + expect(loadJmapSession).not.toHaveBeenCalled(); + }); +}); + +describe('syncAccountIdentities — connection policy', () => { + const accountRow = { jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:token' }; + + it('threads the connection policy\'s allowPrivateHosts through to loadJmapSession and fetchIdentities', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + getConnectionPolicy.mockResolvedValue({ allowPrivateHosts: true, allowInsecureTls: false, allowNonstandardPorts: false }); + loadJmapSession.mockResolvedValue({ sessionUrl: accountRow.jmap_session_url }); + fetchIdentities.mockResolvedValue([]); + const { client } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(loadJmapSession).toHaveBeenCalledWith(accountRow.jmap_session_url, 'enc:token', { allowPrivate: true }); + expect(fetchIdentities).toHaveBeenCalledWith({ sessionUrl: accountRow.jmap_session_url }, 'enc:token', { allowPrivate: true }); + }); +}); + +describe('syncAccountIdentities — identity reconciliation', () => { + const accountRow = { jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:token' }; + + it('adds new identities, updates changed ones by provider_id, and removes disappeared ones', async () => { + query + .mockResolvedValueOnce({ rows: [accountRow] }) // account load + .mockResolvedValueOnce({ rows: [] }); // final UPDATE ...sync_at + loadJmapSession.mockResolvedValue({ sessionUrl: accountRow.jmap_session_url }); + fetchIdentities.mockResolvedValue([ + { id: 'id-1', name: 'Sales', email: 'sales@example.com', replyTo: null }, + { id: 'id-2', name: 'Support', email: 'support@example.com', replyTo: [{ name: 'Help', email: 'help@example.com' }] }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + const result = await syncAccountIdentities(ACCOUNT_ID); + + expect(result.syncedAt).toBeInstanceOf(Date); + const upserts = upsertsFor(calls, 'identity'); + expect(upserts).toHaveLength(2); + expect(upserts[0][1]).toEqual([ACCOUNT_ID, 'identity', 'id-1', 'sales@example.com', 'Sales', '[]']); + expect(upserts[1][1]).toEqual([ACCOUNT_ID, 'identity', 'id-2', 'support@example.com', 'Support', JSON.stringify([{ name: 'Help', email: 'help@example.com' }])]); + // The delete keeps exactly the provider_ids still present. + expect(deleteFor(calls, 'identity')[1]).toEqual([ACCOUNT_ID, 'identity', ['id-1', 'id-2']]); + + const syncUpdate = query.mock.calls.find(c => /jmap_identity_sync_at/.test(c[0])); + expect(syncUpdate[1][1]).toBe(ACCOUNT_ID); + }); + + it('stores a wildcard (*@domain) identity address unchanged', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([ + { id: 'id-wild', name: 'Catch-all', email: '*@example.com', replyTo: null }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'identity')[0][1]).toEqual([ACCOUNT_ID, 'identity', 'id-wild', '*@example.com', 'Catch-all', '[]']); + }); + + it('skips an identity whose display name contains header control characters', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([ + { id: 'id-bad', name: 'Evil\r\nBcc: attacker@evil.example', email: 'ok@example.com', replyTo: null }, + { id: 'id-good', name: 'Fine', email: 'fine@example.com', replyTo: null }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + const upserts = upsertsFor(calls, 'identity'); + expect(upserts).toHaveLength(1); + expect(upserts[0][1][2]).toBe('id-good'); + }); + + it('skips an identity with an unusable email address', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([ + { id: 'id-bad', name: 'Broken', email: 'not-an-email', replyTo: null }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'identity')).toHaveLength(0); + // Nothing survives -> the delete-stale clause runs with an empty keep-list, wiping all identity rows. + expect(deleteFor(calls, 'identity')[1]).toEqual([ACCOUNT_ID, 'identity', []]); + }); + + it('drops a reply-to entry with an unusable address but keeps the identity', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([ + { id: 'id-1', name: 'Sales', email: 'sales@example.com', replyTo: [{ name: 'Bad', email: 'not-an-email' }, { email: 'good@example.com' }] }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(JSON.parse(upsertsFor(calls, 'identity')[0][1][5])).toEqual([{ email: 'good@example.com' }]); + }); +}); + +describe('syncAccountIdentities — Masked Email reconciliation (optional capability)', () => { + const accountRow = { jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:token' }; + + beforeEach(() => { + fetchIdentities.mockResolvedValue([]); + }); + + it('does not call fetchMaskedEmails at all when the capability is absent', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(false); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(fetchMaskedEmails).not.toHaveBeenCalled(); + // Still reconciles the masked kind to empty — see the "capability disappeared" test below. + expect(deleteFor(calls, 'masked')[1]).toEqual([ACCOUNT_ID, 'masked', []]); + }); + + it('syncs enabled masked-email addresses when the capability is present', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'mask-1', email: 'random1@fastmail.example', state: 'enabled', description: 'Private address', forDomain: 'example.com' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + const upserts = upsertsFor(calls, 'masked'); + expect(upserts).toHaveLength(1); + expect(upserts[0][1]).toEqual([ACCOUNT_ID, 'masked', 'mask-1', 'random1@fastmail.example', 'Private address', '[]']); + }); + + it('falls back to forDomain for the name when description is blank', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'mask-1', email: 'random1@fastmail.example', state: 'enabled', description: '', forDomain: 'example.com' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'masked')[0][1][4]).toBe('example.com'); + }); + + it.each(['pending', 'disabled', 'deleted'])('excludes a %s mask (only enabled masks are usable)', async (state) => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'mask-1', email: 'random1@fastmail.example', state, description: 'Private address' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'masked')).toHaveLength(0); + }); + + it('updates an existing mask and removes one that disappeared, by provider_id', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'mask-1', email: 'random1@fastmail.example', state: 'enabled', description: 'Renamed label' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'masked')[0][1][4]).toBe('Renamed label'); + expect(deleteFor(calls, 'masked')[1]).toEqual([ACCOUNT_ID, 'masked', ['mask-1']]); + }); + + it('wipes all masked rows (but leaves identity rows untouched) when the capability disappears', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([{ id: 'id-1', name: 'Sales', email: 'sales@example.com', replyTo: null }]); + sessionHasMaskedEmail.mockReturnValue(false); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(fetchMaskedEmails).not.toHaveBeenCalled(); + expect(deleteFor(calls, 'masked')[1]).toEqual([ACCOUNT_ID, 'masked', []]); + // Identity reconciliation is unaffected by the masked-capability check. + expect(upsertsFor(calls, 'identity')).toHaveLength(1); + }); + + it('rejects a masked email with an unusable address', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'mask-1', email: 'not-an-email', state: 'enabled', description: 'Bad' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'masked')).toHaveLength(0); + }); + + it('rejects a masked email whose description contains header control characters', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'mask-1', email: 'random1@fastmail.example', state: 'enabled', description: 'Evil\r\nBcc: attacker@evil.example' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'masked')).toHaveLength(0); + }); + + it('lets an identity and a masked address share the same provider_id without conflict (unique per kind)', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([{ id: 'shared-id', name: 'Sales', email: 'sales@example.com', replyTo: null }]); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockResolvedValue([ + { id: 'shared-id', email: 'random1@fastmail.example', state: 'enabled', description: 'Private address' }, + ]); + const { client, calls } = fakeClient(); + withTransaction.mockImplementation(async fn => fn(client)); + + await syncAccountIdentities(ACCOUNT_ID); + + expect(upsertsFor(calls, 'identity')).toHaveLength(1); + expect(upsertsFor(calls, 'masked')).toHaveLength(1); + }); +}); + +describe('syncAccountIdentities — error recording', () => { + const accountRow = { jmap_session_url: 'https://mail.example.com/jmap', jmap_api_token: 'enc:token' }; + + it('records a JMAP_CONFIG error message and rethrows', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockRejectedValue(Object.assign(new Error('bad token'), { code: 'JMAP_CONFIG', status: 422 })); + + await expect(syncAccountIdentities(ACCOUNT_ID)).rejects.toMatchObject({ code: 'JMAP_CONFIG' }); + + const errorUpdate = query.mock.calls.find(c => /jmap_identity_sync_error/.test(c[0])); + expect(errorUpdate[1]).toEqual(['bad token', ACCOUNT_ID]); + }); + + it('records a generic safe message for an unexpected error (never leaks internals)', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockRejectedValue(new Error('ECONNRESET: some internal socket detail')); + + await expect(syncAccountIdentities(ACCOUNT_ID)).rejects.toThrow(); + + const errorUpdate = query.mock.calls.find(c => /jmap_identity_sync_error/.test(c[0])); + expect(errorUpdate[1]).toEqual(['JMAP synchronization failed', ACCOUNT_ID]); + }); + + it('does not touch jmap_identity_sync_at on failure (only sync_error)', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'JMAP_SYNC' })); + + await expect(syncAccountIdentities(ACCOUNT_ID)).rejects.toBeTruthy(); + + const calls = query.mock.calls.filter(c => c[0].includes('UPDATE email_accounts')); + expect(calls).toHaveLength(1); + expect(calls[0][0]).not.toContain('jmap_identity_sync_at'); + }); + + it('records the same error whether it came from fetchIdentities or a masked-email failure', async () => { + query.mockResolvedValueOnce({ rows: [accountRow] }).mockResolvedValueOnce({ rows: [] }); + loadJmapSession.mockResolvedValue({}); + fetchIdentities.mockResolvedValue([]); + sessionHasMaskedEmail.mockReturnValue(true); + fetchMaskedEmails.mockRejectedValue(Object.assign(new Error('masked email fetch failed'), { code: 'JMAP_SYNC' })); + + await expect(syncAccountIdentities(ACCOUNT_ID)).rejects.toMatchObject({ code: 'JMAP_SYNC' }); + + const errorUpdate = query.mock.calls.find(c => /jmap_identity_sync_error/.test(c[0])); + expect(errorUpdate[1]).toEqual(['masked email fetch failed', ACCOUNT_ID]); + }); +}); diff --git a/backend/src/services/imapManager.js b/backend/src/services/imapManager.js index fb7b544..a3f9341 100644 --- a/backend/src/services/imapManager.js +++ b/backend/src/services/imapManager.js @@ -493,6 +493,19 @@ const PROVIDERS = { skipFolderPatterns: [], skipFolderNames: [], }, + fastmail: { + // No soak data on Fastmail's IMAP yet (JMAP identity sync, added alongside this + // profile, is a separate connection entirely) — start from the generic profile's + // conservative defaults rather than guessing at more permissive numbers. + batchSize: 100, batchDelay: 1500, errorDelay: 15000, batchesPerConn: 15, + connectStaggerMs: 500, + fetchBody: false, + pushesFlags: true, + snippetIndex: true, + speculativeFetch: true, + skipFolderPatterns: [], + skipFolderNames: [], + }, }; // Builds the GTD portion of the move-detector relocate guard, shared by the @@ -535,7 +548,7 @@ export async function insertCopiedSibling(accountId, uid, fromFolder, toFolder, thread_references, thread_id, is_bulk, read_changed_at, star_changed_at, spam_score_sa, spam_score_ml, spam_verdict, spam_analyzed_at, spam_details, spam_user_override, - category, list_unsubscribe, list_unsubscribe_post, unsubscribed_at + category, list_unsubscribe, list_unsubscribe_post, unsubscribed_at, delivery_addresses ) SELECT account_id, $4, $5, message_id, subject, @@ -545,7 +558,7 @@ export async function insertCopiedSibling(accountId, uid, fromFolder, toFolder, thread_references, thread_id, is_bulk, read_changed_at, star_changed_at, spam_score_sa, spam_score_ml, spam_verdict, spam_analyzed_at, spam_details, spam_user_override, - category, list_unsubscribe, list_unsubscribe_post, unsubscribed_at + category, list_unsubscribe, list_unsubscribe_post, unsubscribed_at, delivery_addresses FROM messages WHERE account_id = $1 AND folder = $2 AND uid = $3 ON CONFLICT (account_id, uid, folder) DO NOTHING @@ -715,6 +728,7 @@ export function providerProfile(account) { if (host.includes('.icloud.com') || host.includes('.apple.com') || host.includes('.me.com')) return PROVIDERS.apple; if (host.includes('.outlook.com') || host.includes('office365.com') || host.includes('.hotmail.com') || host.includes('.live.com') || (account.oauth_provider === 'microsoft')) return PROVIDERS.microsoft; if (host.includes('purelymail.com')) return PROVIDERS.purelymail; + if (host.includes('fastmail.com') || host.includes('messagingengine.com')) return PROVIDERS.fastmail; return PROVIDERS.generic; } @@ -2389,8 +2403,8 @@ export class ImapManager { date, snippet, is_read, is_starred, has_attachments, flags, body_html, body_text, attachments, thread_references, thread_id, is_bulk, category, - list_unsubscribe, list_unsubscribe_post - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26) + list_unsubscribe, list_unsubscribe_post, delivery_addresses + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27) ON CONFLICT (account_id, uid, folder) DO UPDATE SET subject = CASE WHEN EXCLUDED.subject IS NOT NULL @@ -2436,7 +2450,8 @@ export class ImapManager { is_bulk = COALESCE(messages.is_bulk, EXCLUDED.is_bulk), category = COALESCE(messages.category, EXCLUDED.category), list_unsubscribe = COALESCE(messages.list_unsubscribe, EXCLUDED.list_unsubscribe), - list_unsubscribe_post = COALESCE(messages.list_unsubscribe_post, EXCLUDED.list_unsubscribe_post) + list_unsubscribe_post = COALESCE(messages.list_unsubscribe_post, EXCLUDED.list_unsubscribe_post), + delivery_addresses = COALESCE(messages.delivery_addresses, EXCLUDED.delivery_addresses) RETURNING id, (xmax = 0) as is_new `, [ account.id, parsed.uid, folder, @@ -2451,6 +2466,7 @@ export class ImapManager { refs, threadId, parsed.isBulk ?? null, msgCategory, sanitizeStr(decodeMimeWords(parsed.parsedHeaders?.['list-unsubscribe'] ?? null)), sanitizeStr(decodeMimeWords(parsed.parsedHeaders?.['list-unsubscribe-post'] ?? null)), + JSON.stringify(parsed.deliveryAddresses || []), ]); if (result.rows[0]?.is_new) { insertedCount++; @@ -3033,8 +3049,8 @@ export class ImapManager { date, snippet, is_read, is_starred, has_attachments, flags, body_html, body_text, attachments, thread_references, thread_id, is_bulk, category, - list_unsubscribe, list_unsubscribe_post - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26) + list_unsubscribe, list_unsubscribe_post, delivery_addresses + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27) ON CONFLICT (account_id, uid, folder) DO UPDATE SET subject = CASE WHEN EXCLUDED.subject IS NOT NULL @@ -3080,7 +3096,8 @@ export class ImapManager { is_bulk = COALESCE(messages.is_bulk, EXCLUDED.is_bulk), category = COALESCE(messages.category, EXCLUDED.category), list_unsubscribe = COALESCE(messages.list_unsubscribe, EXCLUDED.list_unsubscribe), - list_unsubscribe_post = COALESCE(messages.list_unsubscribe_post, EXCLUDED.list_unsubscribe_post) + list_unsubscribe_post = COALESCE(messages.list_unsubscribe_post, EXCLUDED.list_unsubscribe_post), + delivery_addresses = COALESCE(messages.delivery_addresses, EXCLUDED.delivery_addresses) `, [ account.id, parsed.uid, folder, bfMsgId, sanitizeStr(parsed.subject), @@ -3094,6 +3111,7 @@ export class ImapManager { bfRefs, bfThreadId, parsed.isBulk ?? null, bfCategory, sanitizeStr(decodeMimeWords(parsed.parsedHeaders?.['list-unsubscribe'] ?? null)), sanitizeStr(decodeMimeWords(parsed.parsedHeaders?.['list-unsubscribe-post'] ?? null)), + JSON.stringify(parsed.deliveryAddresses || []), ]); backfilledRows++; if (bfThreadId && bfThreadId !== bfMsgId) { diff --git a/backend/src/services/imapManager.test.js b/backend/src/services/imapManager.test.js index 7ae8abd..28399dc 100644 --- a/backend/src/services/imapManager.test.js +++ b/backend/src/services/imapManager.test.js @@ -85,6 +85,15 @@ describe('providerProfile — host detection', () => { it.each([ ['imap.fastmail.com'], + ['smtp.fastmail.com'], + ['imap.messagingengine.com'], // Fastmail's underlying infra host (imap.fastmail.com CNAMEs here) + ])('detects fastmail for %s (own profile, not the generic fallback)', host => { + const p = providerProfile(account(host)); + expect(p).toBe(providerProfile({ imap_host: 'imap.fastmail.com' })); + expect(p).not.toBe(providerProfile(account('unknown-provider.example.com'))); + }); + + it.each([ ['imap.protonmail.com'], ])('falls back to generic for unknown host %s', host => { const p = providerProfile(account(host)); @@ -282,6 +291,8 @@ describe('insertCopiedSibling', () => { // Idempotent against the next destination-folder sync. expect(ins[0]).toContain('ON CONFLICT (account_id, uid, folder) DO NOTHING'); expect(ins[1]).toEqual(['acct-1', 'INBOX', 100, 5001, 'Todo']); + // delivery_addresses is copied verbatim from the source row, same as list_unsubscribe. + expect(ins[0]).toContain('delivery_addresses'); }); it('increments destination unread only when the copied message is unread', async () => { diff --git a/backend/src/services/jmapClient.js b/backend/src/services/jmapClient.js new file mode 100644 index 0000000..f7d71da --- /dev/null +++ b/backend/src/services/jmapClient.js @@ -0,0 +1,255 @@ +// Generic, host-configurable JMAP client (RFC 8620/8621). The session URL comes from the +// account row, not a constant — Fastmail is just a settings preset that prefills it (see +// AdminPanel.jsx PRESETS.fastmail). Requires only the standard core + submission +// capabilities. Fastmail's Masked Email vendor extension is detected and used ONLY when +// the session advertises it (sessionHasMaskedEmail) — never required, so a plain Stalwart +// server works exactly the same as Fastmail. +// +// Read-only: Identity/get and (when advertised) MaskedEmail/get are the only method calls +// this module makes. There is no Identity/set or MaskedEmail/set — MailFlow sends over +// SMTP, so nothing needs to be created on the server. +// +// Because the session URL is user-configured, every request goes through safeFetch (the +// same SSRF-guarded fetch IMAP/SMTP host validation and carddavClient.js use) instead of +// plain fetch, and the host is checked with validateHost before the first request — +// see assertUrlAllowed below. +import { validateHost } from './hostValidation.js'; +import { safeFetch } from './safeFetch.js'; + +export const JMAP_CORE = 'urn:ietf:params:jmap:core'; +export const JMAP_SUBMISSION = 'urn:ietf:params:jmap:submission'; +export const JMAP_MASKED_EMAIL = 'https://www.fastmail.com/dev/maskedemail'; + +// JMAP failures follow the app-wide error idiom (see safeFetch.js, routes/draft.js): a +// plain Error carrying a machine-readable `code`, plus a `status` where a route maps it +// to an HTTP response. JMAP_CONFIG is a caller/configuration fault (bad token, wrong URL, +// missing capability) surfaced as 422; JMAP_SYNC is a transient remote failure. +export function jmapConfigError(message) { + return Object.assign(new Error(message), { code: 'JMAP_CONFIG', status: 422 }); +} + +export function jmapSyncError(message) { + return Object.assign(new Error(message), { code: 'JMAP_SYNC' }); +} + +// The token travels as a Bearer header on every JMAP request, so a tampered or +// redirected apiUrl must never be able to carry it to another host. Generalizes the old +// Fastmail-only pin (hardcoded api.fastmail.com origin) to: same-origin as the +// user-configured session URL — whatever host and scheme that is. Origin equality already +// implies the same scheme, so an apiUrl downgraded to http when the session was loaded +// over https is rejected here too. Re-checked on every request (not just at session-load +// time) so a session object handed to fetchIdentities from anywhere other than +// loadJmapSession can't smuggle the Bearer token to another host. +function assertSameOriginApiUrl(sessionUrl, apiUrl) { + let url; + try { + url = new URL(apiUrl); + } catch { + throw jmapConfigError('The JMAP server returned an unusable API URL'); + } + if (url.origin !== new URL(sessionUrl).origin) { + throw jmapConfigError('The JMAP server returned an API URL on a different host'); + } + return url.href; +} + +// SSRF guard for the user-configured session URL: HTTPS is required so the Bearer token +// never travels in the clear, unless the host is genuinely private/local AND the caller's +// policy allows private hosts (matches carddavAccount.js's save-time rule for its own +// user-configured server URL). The host itself is then checked with validateHost — the +// same private/reserved-IP check IMAP/SMTP hosts get — under the real allowPrivate policy. +async function assertUrlAllowed(url, allowPrivate) { + if (url.protocol === 'http:') { + if (!allowPrivate) throw jmapConfigError('The JMAP session URL must use https'); + const publicErr = await validateHost(url.hostname, { allowPrivate: false }); + if (!publicErr) { // resolves to a public address + throw jmapConfigError('HTTPS is required for a public host; plaintext HTTP is only allowed for a private/local address'); + } + } else if (url.protocol !== 'https:') { + throw jmapConfigError('The JMAP session URL must use https'); + } + const hostErr = await validateHost(url.hostname, { allowPrivate }); + if (hostErr) throw jmapConfigError(`JMAP session URL: ${hostErr}`); +} + +function safeFetchError(error) { + if (error.name === 'TimeoutError') { + return jmapSyncError('The JMAP server did not respond in time'); + } + return jmapSyncError('Could not reach the JMAP server'); +} + +function assertSuccessfulResponse(response) { + if (response.status === 401 || response.status === 403) { + throw jmapConfigError('The JMAP server rejected the API token or its permissions'); + } + if (!response.ok) throw jmapSyncError('JMAP synchronization failed'); +} + +async function responseJson(response) { + try { + return await response.json(); + } catch { + throw jmapSyncError('The JMAP server returned an invalid response'); + } +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function invalidResponse() { + throw jmapSyncError('The JMAP server returned an invalid response'); +} + +// Identity/MaskedEmail results drive sendable-address reconciliation, so validate the +// shape of the response before trusting it: reject a malformed or attacker-shaped result +// rather than silently producing bad sendable addresses or masking a partial provider failure. +function validateMethodResult(methodName, result, requestArguments) { + if (!isObject(result)) invalidResponse(); + if (result.accountId !== requestArguments.accountId) invalidResponse(); + if ((methodName === 'Identity/get' || methodName === 'MaskedEmail/get') && !Array.isArray(result.list)) { + invalidResponse(); + } +} + +function requirePrimaryAccount(session, capability, label) { + const accountId = session.primaryAccounts?.[capability]; + if (!accountId || !session.accounts?.[accountId]?.accountCapabilities?.[capability]) { + throw jmapConfigError(`The JMAP server did not provide a usable primary ${label} account`); + } + return accountId; +} + +// True only when the session both advertises the Masked Email capability AND names a +// primary account for it that actually has it in its accountCapabilities — mirrors the +// submission check in loadJmapSession, but this one is optional: callers detect it, they +// never require it. +export function sessionHasMaskedEmail(session) { + const accountId = session?.primaryAccounts?.[JMAP_MASKED_EMAIL]; + return Boolean( + session?.capabilities?.[JMAP_MASKED_EMAIL] + && accountId + && session.accounts?.[accountId]?.accountCapabilities?.[JMAP_MASKED_EMAIL], + ); +} + +// Discover and validate a JMAP session at the given (user-configured) session URL. +// Capabilities beyond core + submission are ignored, not required — a server can +// advertise Masked Email, Sieve, whatever else; this layer never looks at it. The +// returned session carries the discovery URL alongside the parsed JMAP session object so +// later calls (fetchIdentities) can re-pin the API URL's origin against it. +// +// allowPrivate mirrors the admin connection policy (see connectionPolicy.js) that already +// gates IMAP/SMTP host access; fetchFn defaults to the SSRF-guarded safeFetch and is only +// ever overridden by tests. +export async function loadJmapSession(sessionUrl, token, { fetchFn = safeFetch, allowPrivate = false } = {}) { + const url = new URL(sessionUrl); + await assertUrlAllowed(url, allowPrivate); + + let response; + try { + response = await fetchFn(url.href, { + redirect: 'error', + signal: AbortSignal.timeout(30000), + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }, { allowPrivate }); + } catch (error) { + throw safeFetchError(error); + } + + assertSuccessfulResponse(response); + const session = await responseJson(response); + assertSameOriginApiUrl(url.href, session.apiUrl); + + if (!session.capabilities?.[JMAP_CORE]) { + throw jmapConfigError('The JMAP server did not advertise the core capability'); + } + if (!session.capabilities?.[JMAP_SUBMISSION]) { + throw jmapConfigError('The JMAP API token is missing Email submission permission'); + } + requirePrimaryAccount(session, JMAP_SUBMISSION, 'Email submission'); + + return { ...session, sessionUrl: url.href }; +} + +// extraUsing declares a vendor capability (e.g. Masked Email) for this call only — never +// added by default, so a plain core+submission request never advertises anything Fastmail- +// specific. +async function jmapRequest(session, token, methodCalls, { fetchFn = safeFetch, allowPrivate = false, extraUsing = [] } = {}) { + const apiUrl = assertSameOriginApiUrl(session.sessionUrl, session.apiUrl); + let response; + try { + response = await fetchFn(apiUrl, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(30000), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + using: [JMAP_CORE, JMAP_SUBMISSION, ...extraUsing], + methodCalls, + }), + }, { allowPrivate }); + } catch (error) { + throw safeFetchError(error); + } + + assertSuccessfulResponse(response); + const payload = await responseJson(response); + if (!Array.isArray(payload.methodResponses)) { + throw jmapSyncError('The JMAP server returned an invalid response'); + } + + const expectedByTag = new Map(methodCalls.map(call => [call[2], call])); + const seenTags = new Set(); + for (const item of payload.methodResponses) { + if (!Array.isArray(item) || item.length !== 3) invalidResponse(); + const requestedCall = expectedByTag.get(item[2]); + if (!requestedCall || seenTags.has(item[2])) invalidResponse(); + seenTags.add(item[2]); + if (item[0] === 'error') { + throw jmapSyncError('The JMAP server rejected a synchronization method'); + } + if (item[0] !== requestedCall[0]) invalidResponse(); + validateMethodResult(item[0], item[1], requestedCall[1]); + } + if (seenTags.size !== expectedByTag.size) invalidResponse(); + return payload.methodResponses; +} + +function responseByTag(responses, tag) { + const response = responses.find(item => item[2] === tag); + if (!response) throw jmapSyncError('The JMAP server omitted a synchronization result'); + return response[1]; +} + +// Fetch every sending identity for the account's primary submission account. +export async function fetchIdentities(session, token, opts) { + const submissionAccountId = session.primaryAccounts[JMAP_SUBMISSION]; + const responses = await jmapRequest(session, token, [ + ['Identity/get', { accountId: submissionAccountId, ids: null }, 'identities'], + ], opts); + return responseByTag(responses, 'identities').list; +} + +// Fetch every Masked Email address for the account's primary Masked Email account. Only +// callable when sessionHasMaskedEmail(session) — the vendor capability is added to the +// `using` array for this call alone, so a server that never advertised it never sees it +// requested. +export async function fetchMaskedEmails(session, token, opts) { + if (!sessionHasMaskedEmail(session)) { + throw jmapConfigError('The JMAP session does not advertise Masked Email'); + } + const maskedEmailAccountId = session.primaryAccounts[JMAP_MASKED_EMAIL]; + const responses = await jmapRequest(session, token, [ + ['MaskedEmail/get', { accountId: maskedEmailAccountId, ids: null }, 'masked-emails'], + ], { ...opts, extraUsing: [JMAP_MASKED_EMAIL] }); + return responseByTag(responses, 'masked-emails').list; +} diff --git a/backend/src/services/jmapClient.test.js b/backend/src/services/jmapClient.test.js new file mode 100644 index 0000000..7f26a32 --- /dev/null +++ b/backend/src/services/jmapClient.test.js @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./hostValidation.js', () => ({ validateHost: vi.fn() })); + +import { validateHost } from './hostValidation.js'; +import { + JMAP_CORE, + JMAP_SUBMISSION, + JMAP_MASKED_EMAIL, + fetchIdentities, + fetchMaskedEmails, + loadJmapSession, + sessionHasMaskedEmail, +} from './jmapClient.js'; + +const TOKEN = 'token-value'; +const SESSION_URL = 'https://mail.example.com/.well-known/jmap'; +const IDENTITY_EMAIL = 'alias@example.com'; + +const rawSession = { + apiUrl: 'https://mail.example.com/jmap/api/', + capabilities: { + [JMAP_CORE]: {}, + [JMAP_SUBMISSION]: {}, + }, + accounts: { + 'acc-1': { + name: 'Example account', + isPersonal: true, + isReadOnly: false, + accountCapabilities: { + [JMAP_SUBMISSION]: {}, + }, + }, + }, + primaryAccounts: { + [JMAP_SUBMISSION]: 'acc-1', + }, + username: 'owner@example.com', +}; + +const identityList = [{ + id: 'identity-1', + name: 'Private sender', + email: IDENTITY_EMAIL, + replyTo: null, + bcc: null, + textSignature: '', + htmlSignature: '', +}]; + +// A session that advertises the Fastmail-only Masked Email vendor capability, on the same +// primary account as submission (the common Fastmail case). +const sessionWithMaskedEmail = { + ...rawSession, + capabilities: { ...rawSession.capabilities, [JMAP_MASKED_EMAIL]: {} }, + accounts: { + 'acc-1': { + ...rawSession.accounts['acc-1'], + accountCapabilities: { ...rawSession.accounts['acc-1'].accountCapabilities, [JMAP_MASKED_EMAIL]: {} }, + }, + }, + primaryAccounts: { ...rawSession.primaryAccounts, [JMAP_MASKED_EMAIL]: 'acc-1' }, +}; + +const maskList = [{ + id: 'mask-1', + email: 'random123@fastmail.example', + state: 'enabled', + forDomain: 'example.com', + description: 'Private address', +}]; + +function response(payload, { status = 200, ok = status >= 200 && status < 300 } = {}) { + return { + ok, + status, + statusText: ok ? 'OK' : 'Error', + json: vi.fn().mockResolvedValue(payload), + }; +} + +beforeEach(() => { + // validateHost is real (DNS-resolving) in production; mock it to approve by default so + // every test here stays network-free, and let SSRF-specific tests override it. + validateHost.mockReset().mockResolvedValue(null); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('loadJmapSession', () => { + it('discovers a validated session at the configured (host-configurable) session URL', async () => { + const fetchFn = vi.fn().mockResolvedValue(response(rawSession)); + + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn }); + + expect(session).toMatchObject(rawSession); + expect(fetchFn).toHaveBeenCalledOnce(); + const [url, options, sfOptions] = fetchFn.mock.calls[0]; + expect(url).toBe(SESSION_URL); + expect(options).toMatchObject({ + redirect: 'error', + headers: { + Authorization: `Bearer ${TOKEN}`, + Accept: 'application/json', + }, + }); + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(sfOptions).toEqual({ allowPrivate: false }); + }); + + it('accepts a custom self-hosted (Stalwart-style) session URL, not just Fastmail', async () => { + const stalwartUrl = 'https://mail.mycompany.example/.well-known/jmap'; + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + apiUrl: 'https://mail.mycompany.example/jmap/api/', + })); + + await expect(loadJmapSession(stalwartUrl, TOKEN, { fetchFn })).resolves.toBeTruthy(); + }); + + it('rejects a non-https session URL before ever making a request', async () => { + const fetchFn = vi.fn(); + + await expect(loadJmapSession('http://mail.example.com/jmap/session', TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_CONFIG', status: 422 }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('rejects a session missing the core capability', async () => { + const capabilities = { ...rawSession.capabilities }; + delete capabilities[JMAP_CORE]; + const fetchFn = vi.fn().mockResolvedValue(response({ ...rawSession, capabilities })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_CONFIG', status: 422 }); + }); + + it('rejects a session missing the submission capability', async () => { + const capabilities = { ...rawSession.capabilities }; + delete capabilities[JMAP_SUBMISSION]; + const fetchFn = vi.fn().mockResolvedValue(response({ ...rawSession, capabilities })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toThrow('Email submission'); + }); + + it('ignores an advertised Masked Email (or any other vendor) capability — never required', async () => { + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + capabilities: { + ...rawSession.capabilities, + 'https://www.fastmail.com/dev/maskedemail': {}, + }, + })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })).resolves.toBeTruthy(); + }); + + it('rejects a session without a primary Email submission account', async () => { + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + primaryAccounts: {}, + })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toThrow('Email submission'); + }); + + it('rejects a primary account without submission access', async () => { + const accountCapabilities = {}; + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + accounts: { 'acc-1': { ...rawSession.accounts['acc-1'], accountCapabilities } }, + })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toThrow('Email submission'); + }); + + it('rejects a discovered API URL on a different host than the session URL (no hardcoded Fastmail origin)', async () => { + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + apiUrl: 'https://evil.example/jmap', + })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toThrow('different host'); + expect(fetchFn).toHaveBeenCalledOnce(); + }); + + it('rejects a non-https API URL even when the host matches (origin includes scheme)', async () => { + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + apiUrl: 'http://mail.example.com/jmap/api/', + })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toThrow('different host'); + }); + + it.each([ + ['a 401', 401], + ['a 403', 403], + ])('maps %s response to JMAP_CONFIG (bad token)', async (_label, status) => { + const fetchFn = vi.fn().mockResolvedValue(response({}, { status, ok: false })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_CONFIG', status: 422 }); + }); + + it('maps a fetch timeout to JMAP_SYNC (transient)', async () => { + const fetchFn = vi.fn().mockRejectedValue(Object.assign(new Error('timed out'), { name: 'TimeoutError' })); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_SYNC' }); + }); + + it('maps an unreachable server to JMAP_SYNC (transient)', async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_SYNC' }); + }); + + it('maps an invalid JSON body to JMAP_SYNC', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, status: 200, json: vi.fn().mockRejectedValue(new Error('bad json')), + }); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_SYNC' }); + }); +}); + +describe('loadJmapSession — SSRF host policy', () => { + it('rejects a session URL whose host resolves to a private/reserved address when allowPrivate=false', async () => { + validateHost.mockResolvedValue('Host resolves to a private or reserved IP address'); + const fetchFn = vi.fn(); + + await expect(loadJmapSession(SESSION_URL, TOKEN, { fetchFn })) + .rejects.toMatchObject({ code: 'JMAP_CONFIG', status: 422 }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('rejects a plaintext http session URL to a public host, even when allowPrivate=true', async () => { + // The allowPrivate:false probe (is this host genuinely public?) reports no error, i.e. + // the host resolves publicly — plaintext must not carry the Bearer token there. + validateHost.mockResolvedValue(null); + const fetchFn = vi.fn(); + + await expect(loadJmapSession('http://mail.example.com/jmap/session', TOKEN, { fetchFn, allowPrivate: true })) + .rejects.toMatchObject({ code: 'JMAP_CONFIG', status: 422 }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('allows a plaintext http session URL to a genuinely private host when allowPrivate=true', async () => { + // The allowPrivate:false probe reports private (rejected under a strict check) — so the + // real allowPrivate:true check further down is what actually admits it. + validateHost.mockImplementation(async (_host, { allowPrivate }) => + (allowPrivate ? null : 'Host resolves to a private or reserved IP address')); + const fetchFn = vi.fn().mockResolvedValue(response({ + ...rawSession, + apiUrl: 'http://internal.example/jmap/api/', + })); + + const session = await loadJmapSession('http://internal.example/jmap/session', TOKEN, { fetchFn, allowPrivate: true }); + + expect(session).toMatchObject({ ...rawSession, apiUrl: 'http://internal.example/jmap/api/' }); + const [, , sfOptions] = fetchFn.mock.calls[0]; + expect(sfOptions).toEqual({ allowPrivate: true }); + }); + + it('passes allowPrivate through to validateHost for an https session URL', async () => { + const fetchFn = vi.fn().mockResolvedValue(response(rawSession)); + + await loadJmapSession(SESSION_URL, TOKEN, { fetchFn, allowPrivate: true }); + + expect(validateHost).toHaveBeenCalledWith('mail.example.com', { allowPrivate: true }); + }); +}); + +describe('fetchIdentities', () => { + it('fetches the identity list for the primary submission account', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [ + ['Identity/get', { accountId: 'acc-1', state: 'identity-state', list: identityList, notFound: [] }, 'identities'], + ], + sessionState: 'session-state', + })); + + const result = await fetchIdentities(session, TOKEN, { fetchFn }); + + const [url, options, sfOptions] = fetchFn.mock.calls[0]; + const body = JSON.parse(options.body); + expect(url).toBe(session.apiUrl); + expect(options.headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(options.redirect).toBe('error'); + expect(body.using).toEqual([JMAP_CORE, JMAP_SUBMISSION]); + expect(body.methodCalls).toEqual([['Identity/get', { accountId: 'acc-1', ids: null }, 'identities']]); + expect(result).toEqual(identityList); + expect(sfOptions).toEqual({ allowPrivate: false }); + }); + + it('threads allowPrivate through to the API request', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [ + ['Identity/get', { accountId: 'acc-1', state: 'identity-state', list: identityList, notFound: [] }, 'identities'], + ], + sessionState: 'session-state', + })); + + await fetchIdentities(session, TOKEN, { fetchFn, allowPrivate: true }); + + const [, , sfOptions] = fetchFn.mock.calls[0]; + expect(sfOptions).toEqual({ allowPrivate: true }); + }); + + it('refuses to send the token when the session apiUrl no longer matches the session URL origin', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const tampered = { ...session, apiUrl: 'https://evil.example/jmap' }; + const fetchFn = vi.fn(); + + await expect(fetchIdentities(tampered, TOKEN, { fetchFn })).rejects.toThrow('different host'); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('rejects method-level errors without returning partial data', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['error', { type: 'serverFail', description: `Failed for ${IDENTITY_EMAIL}` }, 'identities']], + sessionState: 'session-state', + })); + + const result = fetchIdentities(session, TOKEN, { fetchFn }); + + await expect(result).rejects.toMatchObject({ code: 'JMAP_SYNC' }); + }); + + it('rejects a response method that does not match its requested tag', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['PushSubscription/get', { accountId: 'acc-1', list: [], notFound: [] }, 'identities']], + sessionState: 'session-state', + })); + + await expect(fetchIdentities(session, TOKEN, { fetchFn })).rejects.toThrow('invalid response'); + }); + + it('rejects a malformed Identity/get list result', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['Identity/get', { accountId: 'acc-1', state: 's', list: null, notFound: [] }, 'identities']], + sessionState: 'session-state', + })); + + await expect(fetchIdentities(session, TOKEN, { fetchFn })).rejects.toThrow('invalid response'); + }); + + it('does not expose the token or identity addresses through errors', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['error', { type: TOKEN, description: IDENTITY_EMAIL }, 'identities']], + sessionState: 'session-state', + })); + + let error; + try { + await fetchIdentities(session, TOKEN, { fetchFn }); + } catch (caught) { + error = caught; + } + + expect(error.code).toBe('JMAP_SYNC'); + expect(error.message).not.toContain(TOKEN); + expect(error.message).not.toContain(IDENTITY_EMAIL); + }); +}); + +describe('sessionHasMaskedEmail', () => { + it('is false for a session that never mentions Masked Email', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + + expect(sessionHasMaskedEmail(session)).toBe(false); + }); + + it('is true when the capability, primary account, and account capability all agree', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(sessionWithMaskedEmail)) }); + + expect(sessionHasMaskedEmail(session)).toBe(true); + }); + + it('is false when the top-level capability is advertised but no primary account is named for it', () => { + const session = { ...sessionWithMaskedEmail, primaryAccounts: rawSession.primaryAccounts }; + + expect(sessionHasMaskedEmail(session)).toBe(false); + }); + + it('is false when the primary account itself lacks the accountCapability', () => { + const session = { + ...sessionWithMaskedEmail, + accounts: { 'acc-1': { ...sessionWithMaskedEmail.accounts['acc-1'], accountCapabilities: { [JMAP_SUBMISSION]: {} } } }, + }; + + expect(sessionHasMaskedEmail(session)).toBe(false); + }); +}); + +describe('fetchMaskedEmails', () => { + it('refuses to call when the session does not advertise Masked Email', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(rawSession)) }); + const fetchFn = vi.fn(); + + await expect(fetchMaskedEmails(session, TOKEN, { fetchFn })).rejects.toMatchObject({ code: 'JMAP_CONFIG' }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('fetches the masked-email list, including the vendor capability in `using` only for this call', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(sessionWithMaskedEmail)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['MaskedEmail/get', { accountId: 'acc-1', state: 'mask-state', list: maskList, notFound: [] }, 'masked-emails']], + sessionState: 'session-state', + })); + + const result = await fetchMaskedEmails(session, TOKEN, { fetchFn }); + + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.using).toEqual([JMAP_CORE, JMAP_SUBMISSION, JMAP_MASKED_EMAIL]); + expect(body.methodCalls).toEqual([['MaskedEmail/get', { accountId: 'acc-1', ids: null }, 'masked-emails']]); + expect(result).toEqual(maskList); + }); + + it('does not advertise the vendor capability on an ordinary fetchIdentities call', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(sessionWithMaskedEmail)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['Identity/get', { accountId: 'acc-1', state: 's', list: identityList, notFound: [] }, 'identities']], + sessionState: 'session-state', + })); + + await fetchIdentities(session, TOKEN, { fetchFn }); + + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.using).toEqual([JMAP_CORE, JMAP_SUBMISSION]); + }); + + it('rejects a malformed MaskedEmail/get list result', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(sessionWithMaskedEmail)) }); + const fetchFn = vi.fn().mockResolvedValue(response({ + methodResponses: [['MaskedEmail/get', { accountId: 'acc-1', state: 's', list: null, notFound: [] }, 'masked-emails']], + sessionState: 'session-state', + })); + + await expect(fetchMaskedEmails(session, TOKEN, { fetchFn })).rejects.toThrow('invalid response'); + }); + + it('refuses to send the token when the tampered apiUrl no longer matches the session origin', async () => { + const session = await loadJmapSession(SESSION_URL, TOKEN, { fetchFn: vi.fn().mockResolvedValue(response(sessionWithMaskedEmail)) }); + const tampered = { ...session, apiUrl: 'https://evil.example/jmap' }; + const fetchFn = vi.fn(); + + await expect(fetchMaskedEmails(tampered, TOKEN, { fetchFn })).rejects.toThrow('different host'); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/services/messageParser.headers.test.js b/backend/src/services/messageParser.headers.test.js index b349a58..46c0bea 100644 --- a/backend/src/services/messageParser.headers.test.js +++ b/backend/src/services/messageParser.headers.test.js @@ -5,6 +5,7 @@ import { buildHeadersFromMessage, enrichParsedMetadata, parseMailboxList, + parseDeliveryAddresses, snippetFromBody, parseMessage, } from './messageParser.js'; @@ -139,6 +140,50 @@ describe('parseMailboxList', () => { }); }); +describe('parseDeliveryAddresses', () => { + it('parses a single Delivered-To header', () => { + expect(parseDeliveryAddresses({ 'delivered-to': 'alice+work@example.com' })) + .toEqual(['alice+work@example.com']); + }); + + it('parses multiple values folded onto separate lines', () => { + expect(parseDeliveryAddresses({ 'delivered-to': 'a@example.com\nb@example.com' })) + .toEqual(['a@example.com', 'b@example.com']); + }); + + it('parses X-Original-To', () => { + expect(parseDeliveryAddresses({ 'x-original-to': 'catchall@example.com' })) + .toEqual(['catchall@example.com']); + }); + + it('dedupes the same address across headers, case-insensitively', () => { + expect(parseDeliveryAddresses({ + 'delivered-to': 'Alice@Example.com', + 'x-original-to': 'alice@example.com', + })).toEqual(['alice@example.com']); + }); + + it('extracts the email from angle-bracket forms', () => { + expect(parseDeliveryAddresses({ 'delivered-to': 'Alice Smith ' })) + .toEqual(['alice@example.com']); + }); + + it('returns [] when no delivery headers are present', () => { + expect(parseDeliveryAddresses({})).toEqual([]); + expect(parseDeliveryAddresses(undefined)).toEqual([]); + }); + + it('ignores junk lines that do not parse as an address', () => { + expect(parseDeliveryAddresses({ 'delivered-to': 'not-an-email\nbob@example.com' })) + .toEqual(['bob@example.com']); + }); + + it('caps the number of captured addresses', () => { + const flood = Array.from({ length: 80 }, (_, i) => `user${i}@example.com`).join(', '); + expect(parseDeliveryAddresses({ 'delivered-to': flood })).toHaveLength(50); + }); +}); + describe('buildHeadersFromMessage', () => { it('builds headers from stored message fields', () => { const raw = buildHeadersFromMessage({ diff --git a/backend/src/services/messageParser.js b/backend/src/services/messageParser.js index 99d9480..cbef441 100644 --- a/backend/src/services/messageParser.js +++ b/backend/src/services/messageParser.js @@ -368,6 +368,28 @@ export function parseMailboxList(headerValue) { return results.filter(r => r.email); } +// Headers an MTA/mailbox uses to record the address a message was actually delivered +// to (BCC, catch-all, forwarded alias) — may be absent from To/Cc entirely. +const DELIVERY_HEADERS = ['delivered-to', 'x-delivered-to', 'x-original-to', 'envelope-to']; +// Sender-controlled input persisted per message; capped like subject/snippet. +const MAX_DELIVERY_ADDRESSES = 50; + +export function parseDeliveryAddresses(parsedHeaders) { + const emails = new Set(); + for (const header of DELIVERY_HEADERS) { + const value = parsedHeaders?.[header]; + if (!value) continue; + for (const line of value.split(/\r?\n/)) { + for (const { email } of parseMailboxList(line)) { + const normalized = email.trim().toLowerCase(); + if (normalized) emails.add(normalized); + if (emails.size >= MAX_DELIVERY_ADDRESSES) return [...emails]; + } + } + } + return [...emails]; +} + // Fill gaps when IMAP ENVELOPE is incomplete — common for multipart/related Sent copies. export function enrichParsedMetadata(parsed, { accountEmail, @@ -537,6 +559,7 @@ export async function parseMessage(msg) { inReplyTo: envelope.inReplyTo || null, references, parsedHeaders, + deliveryAddresses: parseDeliveryAddresses(parsedHeaders), date: msg.internalDate || envelope.date || new Date(), snippet, isRead, diff --git a/backend/src/services/messageService.js b/backend/src/services/messageService.js index b65c863..ee9f376 100644 --- a/backend/src/services/messageService.js +++ b/backend/src/services/messageService.js @@ -95,7 +95,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit m.to_addresses, m.cc_addresses, m.reply_to, m.in_reply_to, m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, - m.list_unsubscribe, m.list_unsubscribe_post, + m.list_unsubscribe, m.list_unsubscribe_post, m.delivery_addresses, a.name AS account_name, a.email_address AS account_email, a.color AS account_color, @@ -141,7 +141,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit to_addresses, cc_addresses, reply_to, in_reply_to, date, snippet, is_starred, is_read, has_attachments, account_id, account_name, account_email, account_color, - category, list_unsubscribe, list_unsubscribe_post, + category, list_unsubscribe, list_unsubscribe_post, delivery_addresses, message_count, unread_count, thread_has_contact_photo AS has_contact_photo FROM ranked @@ -172,7 +172,7 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit m.to_addresses, m.cc_addresses, m.reply_to, m.in_reply_to, m.date, m.snippet, m.is_read, m.is_starred, m.has_attachments, m.account_id, m.category, - m.list_unsubscribe, m.list_unsubscribe_post, + m.list_unsubscribe, m.list_unsubscribe_post, m.delivery_addresses, a.name as account_name, a.email_address as account_email, a.color as account_color, (co.id IS NOT NULL) AS has_contact_photo FROM messages m diff --git a/backend/src/services/messageService.test.js b/backend/src/services/messageService.test.js index 34700a5..e97be94 100644 --- a/backend/src/services/messageService.test.js +++ b/backend/src/services/messageService.test.js @@ -150,3 +150,28 @@ describe('listMessages — threaded mode', () => { expect(cteSql).toContain("AND folder = 'INBOX'"); }); }); + +describe('listMessages — message shape', () => { + it('selects delivery_addresses in the flat query', async () => { + query + .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) + .mockResolvedValueOnce({ rows: [{ total_count: 1, unread_count: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + await listMessages({ userId: 'user-1', accountId: 'acc-1' }); + + expect(query.mock.calls[2][0]).toContain('delivery_addresses'); + }); + + it('selects delivery_addresses in the threaded query', async () => { + query + .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] }) + .mockResolvedValueOnce({ rows: [{ total_count: 1, unread_count: 0 }] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ total: 0 }] }); + + await listMessages({ userId: 'user-1', accountId: 'acc-1', threaded: 'true' }); + + expect(query.mock.calls[2][0]).toContain('delivery_addresses'); + }); +}); diff --git a/backend/src/services/replySender.js b/backend/src/services/replySender.js new file mode 100644 index 0000000..1936214 --- /dev/null +++ b/backend/src/services/replySender.js @@ -0,0 +1,107 @@ +import { query } from './db.js'; +import { syncAccountIdentities } from './identitySync.js'; +import { normalizeEmailAddress, wildcardCovers } from './senderAddress.js'; + +// A synced identity set older than this is worth one re-sync attempt before giving up on a +// reply-time match — long enough that every reply doesn't trigger a JMAP round trip, short +// enough that a newly-added Fastmail identity shows up without the user opening Settings. +const STALE_SYNC_MS = 15 * 60 * 1000; + +function addressList(value) { + let parsed = value; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + return []; + } + } + if (!Array.isArray(parsed)) return []; + return parsed.map(entry => (typeof entry === 'string' ? entry : entry?.email)).filter(Boolean); +} + +function normalizeCandidates(list) { + return list.map(email => normalizeEmailAddress(email)).filter(Boolean); +} + +async function loadMessageAndAccount(messageId, userId) { + const result = await query( + `SELECT m.account_id, m.delivery_addresses, m.to_addresses, m.cc_addresses, + a.email_address AS account_email, a.jmap_api_token, a.jmap_identity_sync_at + FROM messages m + JOIN email_accounts a ON a.id = m.account_id + WHERE m.id = $1 AND a.user_id = $2 AND m.is_deleted = false`, + [messageId, userId], + ); + if (!result.rows.length) { + throw Object.assign(new Error('Message not found'), { status: 404 }); + } + return result.rows[0]; +} + +async function loadAliasEmails(accountId) { + const result = await query('SELECT email FROM account_aliases WHERE account_id = $1', [accountId]); + return result.rows.map(row => normalizeEmailAddress(row.email)).filter(Boolean); +} + +// kind-agnostic on purpose: a synced identity and a synced Masked Email address resolve +// a reply-time sender exactly the same way. +async function loadSendableRows(accountId) { + const result = await query( + 'SELECT address, name FROM sendable_addresses WHERE account_id = $1', + [accountId], + ); + return result.rows; +} + +// Candidates are checked in order (delivery-to, then To, then Cc — envelope truth first); +// for each candidate an exact address match wins over a wildcard match, matching the old +// matchAuthorizedSender precedence. +function matchSendable(rows, candidates) { + for (const candidate of candidates) { + const exact = rows.find(row => row.address === candidate); + if (exact) return { fromEmail: candidate, name: typeof exact.name === 'string' ? exact.name : '' }; + const wildcard = rows.find(row => wildcardCovers(row.address, candidate)); + if (wildcard) return { fromEmail: candidate, name: typeof wildcard.name === 'string' ? wildcard.name : '' }; + } + return null; +} + +// Resolve the transient reply From: an address the message was delivered/addressed to that +// the account is authorized (via synced JMAP identities) to send as, but that isn't already +// one of the addresses MailFlow trusts today (account primary / a saved alias — those are +// already auto-selected before this resolver runs). Never returns the full sendable set, +// only the single best match. +export async function resolveReplySender({ messageId, userId }) { + const message = await loadMessageAndAccount(messageId, userId); + + const excluded = new Set([ + normalizeEmailAddress(message.account_email), + ...(await loadAliasEmails(message.account_id)), + ].filter(Boolean)); + + const candidates = normalizeCandidates([ + ...addressList(message.delivery_addresses), + ...addressList(message.to_addresses), + ...addressList(message.cc_addresses), + ]).filter(candidate => !excluded.has(candidate)); + + if (!candidates.length) return { sender: null }; + + const rows = await loadSendableRows(message.account_id); + let sender = matchSendable(rows, candidates); + if (sender) return { sender }; + + const isStale = !message.jmap_identity_sync_at + || (Date.now() - new Date(message.jmap_identity_sync_at).getTime()) > STALE_SYNC_MS; + if (message.jmap_api_token && isStale) { + try { + await syncAccountIdentities(message.account_id); + } catch { + return { sender: null }; + } + sender = matchSendable(await loadSendableRows(message.account_id), candidates); + } + + return { sender }; +} diff --git a/backend/src/services/replySender.test.js b/backend/src/services/replySender.test.js new file mode 100644 index 0000000..55a7905 --- /dev/null +++ b/backend/src/services/replySender.test.js @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./db.js', () => ({ query: vi.fn() })); +vi.mock('./identitySync.js', () => ({ syncAccountIdentities: vi.fn() })); + +const { query } = await import('./db.js'); +const { syncAccountIdentities } = await import('./identitySync.js'); +const { resolveReplySender } = await import('./replySender.js'); + +const USER_ID = 'user-1'; +const MESSAGE_ID = 'msg-1'; +const ACCOUNT_ID = 'acct-1'; + +function messageRow(overrides = {}) { + return { + account_id: ACCOUNT_ID, + delivery_addresses: [], + to_addresses: [], + cc_addresses: [], + account_email: 'me@example.com', + jmap_api_token: null, + jmap_identity_sync_at: new Date().toISOString(), + ...overrides, + }; +} + +// Queries fire in a fixed order: message+account join, alias emails, sendable_addresses +// (and, on a stale-sync miss, sendable_addresses again after the resync). +function stub({ message, aliases = [], sendable = [], sendableAfterSync }) { + query.mockReset(); + const calls = []; + query.mockImplementation(async (sql) => { + calls.push(sql); + if (sql.includes('FROM messages m')) return { rows: message ? [message] : [] }; + if (sql.startsWith('SELECT email FROM account_aliases')) return { rows: aliases.map(email => ({ email })) }; + if (sql.includes('FROM sendable_addresses')) { + const isSecondCall = calls.filter(s => s.includes('FROM sendable_addresses')).length > 1; + return { rows: (isSecondCall && sendableAfterSync !== undefined) ? sendableAfterSync : sendable }; + } + return { rows: [] }; + }); +} + +beforeEach(() => { + syncAccountIdentities.mockReset(); +}); + +describe('resolveReplySender — ownership', () => { + it('throws a 404 when the message is not owned by the user', async () => { + stub({ message: null }); + + await expect(resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID })) + .rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('resolveReplySender — matching', () => { + it('matches a delivery address', async () => { + stub({ + message: messageRow({ delivery_addresses: ['sales@example.com'] }), + sendable: [{ address: 'sales@example.com', name: 'Sales' }], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: { fromEmail: 'sales@example.com', name: 'Sales' } }); + }); + + it('matches a wildcard identity covering a delivered-to candidate', async () => { + stub({ + message: messageRow({ delivery_addresses: ['random@example.com'] }), + sendable: [{ address: '*@example.com', name: 'Catch-all' }], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: { fromEmail: 'random@example.com', name: 'Catch-all' } }); + }); + + // Seam proof: a Masked Email address (kind='masked') must resolve exactly like a + // synced identity (kind='identity') — the sendable_addresses lookup must never filter on + // `kind`. + it('matches a Masked Email-sourced row the same as an identity row', async () => { + stub({ + message: messageRow({ delivery_addresses: ['random1@fastmail.example'] }), + sendable: [{ address: 'random1@fastmail.example', name: 'Private address' }], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: { fromEmail: 'random1@fastmail.example', name: 'Private address' } }); + const sendableQuery = query.mock.calls.find(c => c[0].includes('FROM sendable_addresses')); + expect(sendableQuery[0]).not.toMatch(/kind\s*=/); + }); + + it('prefers delivery address over a different To match', async () => { + stub({ + message: messageRow({ + delivery_addresses: ['sales@example.com'], + to_addresses: [{ email: 'support@example.com' }], + }), + sendable: [ + { address: 'sales@example.com', name: 'Sales' }, + { address: 'support@example.com', name: 'Support' }, + ], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result.sender.fromEmail).toBe('sales@example.com'); + }); + + it('falls back to Cc when there is no delivery or To match', async () => { + stub({ + message: messageRow({ cc_addresses: [{ email: 'support@example.com' }] }), + sendable: [{ address: 'support@example.com', name: 'Support' }], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result.sender.fromEmail).toBe('support@example.com'); + }); + + it('skips a candidate equal to the account primary address', async () => { + stub({ + message: messageRow({ delivery_addresses: ['me@example.com'] }), + sendable: [{ address: 'me@example.com', name: 'Me' }], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: null }); + }); + + it('skips a candidate that is already a saved alias', async () => { + stub({ + message: messageRow({ delivery_addresses: ['alias@example.com'] }), + aliases: ['alias@example.com'], + sendable: [{ address: 'alias@example.com', name: 'Alias' }], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: null }); + }); + + it('returns a null sender on a clean miss (no candidates)', async () => { + stub({ message: messageRow() }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: null }); + }); + + it('never returns more than the single matched sender (no address enumeration)', async () => { + stub({ + message: messageRow({ delivery_addresses: ['sales@example.com'] }), + sendable: [ + { address: 'sales@example.com', name: 'Sales' }, + { address: 'support@example.com', name: 'Support' }, + ], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(Object.keys(result)).toEqual(['sender']); + expect(Object.keys(result.sender).sort()).toEqual(['fromEmail', 'name']); + }); +}); + +describe('resolveReplySender — sync-on-miss', () => { + it('re-syncs once on a miss when the token is configured and the sync is stale, then re-matches', async () => { + const stale = new Date(Date.now() - 20 * 60 * 1000).toISOString(); + stub({ + message: messageRow({ delivery_addresses: ['new@example.com'], jmap_api_token: 'enc:token', jmap_identity_sync_at: stale }), + sendable: [], + sendableAfterSync: [{ address: 'new@example.com', name: 'New' }], + }); + syncAccountIdentities.mockResolvedValue({ syncedAt: new Date() }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(syncAccountIdentities).toHaveBeenCalledOnce(); + expect(syncAccountIdentities).toHaveBeenCalledWith(ACCOUNT_ID); + expect(result).toEqual({ sender: { fromEmail: 'new@example.com', name: 'New' } }); + }); + + it('does not re-sync when the last sync is recent (not stale)', async () => { + stub({ + message: messageRow({ delivery_addresses: ['new@example.com'], jmap_api_token: 'enc:token' }), + sendable: [], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(syncAccountIdentities).not.toHaveBeenCalled(); + expect(result).toEqual({ sender: null }); + }); + + it('does not re-sync when no token is configured, even if stale/never synced', async () => { + stub({ + message: messageRow({ delivery_addresses: ['new@example.com'], jmap_api_token: null, jmap_identity_sync_at: null }), + sendable: [], + }); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(syncAccountIdentities).not.toHaveBeenCalled(); + expect(result).toEqual({ sender: null }); + }); + + it('swallows a sync failure into a null sender', async () => { + const stale = new Date(Date.now() - 20 * 60 * 1000).toISOString(); + stub({ + message: messageRow({ delivery_addresses: ['new@example.com'], jmap_api_token: 'enc:token', jmap_identity_sync_at: stale }), + sendable: [], + }); + syncAccountIdentities.mockRejectedValue(Object.assign(new Error('bad token'), { code: 'JMAP_CONFIG' })); + + const result = await resolveReplySender({ messageId: MESSAGE_ID, userId: USER_ID }); + + expect(result).toEqual({ sender: null }); + }); +}); diff --git a/backend/src/services/senderAddress.js b/backend/src/services/senderAddress.js new file mode 100644 index 0000000..cb29c05 --- /dev/null +++ b/backend/src/services/senderAddress.js @@ -0,0 +1,45 @@ +function isValidDomain(domain) { + if (!domain || domain.length > 253 || domain.startsWith('.') || domain.endsWith('.')) return false; + return domain.split('.').every(label => ( + label.length > 0 + && label.length <= 63 + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label) + )); +} + +export function normalizeEmailAddress(value) { + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + // eslint-disable-next-line no-control-regex -- sender addresses containing control characters are invalid + if (!normalized || /[\s<>\x00-\x1f\x7f]/.test(normalized)) return null; + const parts = normalized.split('@'); + if (parts.length !== 2 || !parts[0] || !isValidDomain(parts[1])) return null; + return normalized; +} + +export function normalizeWildcardAddress(value) { + if (typeof value !== 'string') return null; + const match = /^\*@([^@]+)$/.exec(value.trim().toLowerCase()); + return match && isValidDomain(match[1]) ? `*@${match[1]}` : null; +} + +export function normalizeSenderAddress(value) { + return normalizeWildcardAddress(value) || normalizeEmailAddress(value); +} + +// Identity display names become message headers; reject control characters so they +// cannot inject extra header lines. Shared by identity sync and send authorization. +export function hasHeaderControlCharacters(value) { + // eslint-disable-next-line no-control-regex -- header-unsafe control characters + return /[\x00-\x1f\x7f]/.test(value); +} + +export function wildcardCovers(pattern, candidate) { + const normalizedPattern = normalizeWildcardAddress(pattern); + const normalizedCandidate = normalizeEmailAddress(candidate); + return Boolean( + normalizedPattern + && normalizedCandidate + && normalizedCandidate.slice(normalizedCandidate.lastIndexOf('@') + 1) === normalizedPattern.slice(2) + ); +} diff --git a/backend/src/services/senderAuthorization.js b/backend/src/services/senderAuthorization.js new file mode 100644 index 0000000..0f422ee --- /dev/null +++ b/backend/src/services/senderAuthorization.js @@ -0,0 +1,57 @@ +import { query } from './db.js'; +import { hasHeaderControlCharacters, normalizeEmailAddress, wildcardCovers } from './senderAddress.js'; + +// A stale/forbidden sender is a client fault surfaced as 422; the code lets send.js return +// the SENDER_UNAVAILABLE response shape without leaking why (row missing vs. never synced +// vs. revoked upstream all look identical to the caller). +function unavailable() { + throw Object.assign( + new Error('The selected sending address is no longer available. Refresh addresses or choose another sender.'), + { status: 422, code: 'SENDER_UNAVAILABLE' }, + ); +} + +// sendable_addresses.reply_to is JSONB `[{name?, email}]`; nodemailer's replyTo option +// wants `[{name, address}] | string[]`. Drop any entry that fails to re-normalize (defense +// in depth — identitySync already validates on write) and return null for an empty list +// so the caller omits the header entirely instead of sending an empty Reply-To. +function mapReplyTo(value) { + if (!Array.isArray(value) || !value.length) return null; + const mapped = value.map(entry => { + const email = normalizeEmailAddress(entry?.email); + if (!email) return null; + return (typeof entry?.name === 'string' && entry.name && !hasHeaderControlCharacters(entry.name)) + ? { name: entry.name, address: email } + : email; + }).filter(Boolean); + return mapped.length ? mapped : null; +} + +// Authorize an explicit From address against the account's private sendable_addresses set +// (identities synced read-only over JMAP). Exact address match wins; otherwise a `*@domain` +// wildcard row covering the candidate. Throws SENDER_UNAVAILABLE (422) on any miss. +export async function authorizeSendableAddress({ accountId, fromEmail }) { + const normalized = normalizeEmailAddress(fromEmail); + if (!normalized) unavailable(); + + // kind-agnostic on purpose: a synced identity and a synced Masked Email address + // authorize a From exactly the same way — `kind` only disambiguates how a row was + // sourced (and its provider_id namespace), never whether it's usable. + const result = await query( + 'SELECT address, name, reply_to FROM sendable_addresses WHERE account_id = $1', + [accountId], + ); + const rows = result.rows; + const matched = rows.find(row => row.address === normalized) + || rows.find(row => wildcardCovers(row.address, normalized)); + if (!matched) unavailable(); + // identitySync already rejects control-character names at write time; this is defense + // in depth against a row that reached the table some other way. + if (typeof matched.name === 'string' && hasHeaderControlCharacters(matched.name)) unavailable(); + + return { + fromName: typeof matched.name === 'string' ? matched.name : '', + fromEmail: normalized, + replyTo: mapReplyTo(matched.reply_to), + }; +} diff --git a/backend/src/services/senderAuthorization.test.js b/backend/src/services/senderAuthorization.test.js new file mode 100644 index 0000000..60f136a --- /dev/null +++ b/backend/src/services/senderAuthorization.test.js @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./db.js', () => ({ query: vi.fn() })); + +const { query } = await import('./db.js'); +const { authorizeSendableAddress } = await import('./senderAuthorization.js'); + +const ACCOUNT_ID = 'acct-1'; + +beforeEach(() => { + query.mockReset(); +}); + +describe('authorizeSendableAddress', () => { + it('authorizes an exact address match', async () => { + query.mockResolvedValueOnce({ rows: [{ address: 'sales@example.com', name: 'Sales', reply_to: [] }] }); + + const result = await authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'Sales@Example.com' }); + + expect(result).toEqual({ fromName: 'Sales', fromEmail: 'sales@example.com', replyTo: null }); + }); + + it('authorizes a wildcard-covered address', async () => { + query.mockResolvedValueOnce({ rows: [{ address: '*@example.com', name: 'Catch-all', reply_to: [] }] }); + + const result = await authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'anything@example.com' }); + + expect(result).toEqual({ fromName: 'Catch-all', fromEmail: 'anything@example.com', replyTo: null }); + }); + + it('prefers an exact match over a wildcard row that also covers it', async () => { + query.mockResolvedValueOnce({ + rows: [ + { address: '*@example.com', name: 'Catch-all', reply_to: [] }, + { address: 'sales@example.com', name: 'Sales', reply_to: [] }, + ], + }); + + const result = await authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'sales@example.com' }); + + expect(result.fromName).toBe('Sales'); + }); + + it('rejects a fromEmail not present in the sendable set', async () => { + query.mockResolvedValueOnce({ rows: [{ address: 'sales@example.com', name: 'Sales', reply_to: [] }] }); + + await expect(authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'unknown@example.com' })) + .rejects.toMatchObject({ status: 422, code: 'SENDER_UNAVAILABLE' }); + }); + + it('rejects when the account has no synced identities at all', async () => { + query.mockResolvedValueOnce({ rows: [] }); + + await expect(authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'sales@example.com' })) + .rejects.toMatchObject({ code: 'SENDER_UNAVAILABLE' }); + }); + + it('rejects a malformed fromEmail before querying', async () => { + await expect(authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'not-an-email' })) + .rejects.toMatchObject({ code: 'SENDER_UNAVAILABLE' }); + expect(query).not.toHaveBeenCalled(); + }); + + it('rejects a matched row whose stored name contains header control characters', async () => { + query.mockResolvedValueOnce({ rows: [{ address: 'sales@example.com', name: 'Evil\r\nBcc: x@evil.example', reply_to: [] }] }); + + await expect(authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'sales@example.com' })) + .rejects.toMatchObject({ code: 'SENDER_UNAVAILABLE' }); + }); + + it('maps reply_to to nodemailer shape, dropping an entry with an unusable address', async () => { + query.mockResolvedValueOnce({ + rows: [{ + address: 'sales@example.com', + name: 'Sales', + reply_to: [{ name: 'Help Desk', email: 'help@example.com' }, { email: 'bad-address' }], + }], + }); + + const result = await authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'sales@example.com' }); + + expect(result.replyTo).toEqual([{ name: 'Help Desk', address: 'help@example.com' }]); + }); + + it('omits replyTo entirely (null) when the identity has an empty reply_to', async () => { + query.mockResolvedValueOnce({ rows: [{ address: 'sales@example.com', name: 'Sales', reply_to: [] }] }); + + const result = await authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'sales@example.com' }); + + expect(result.replyTo).toBeNull(); + }); + + // Seam proof: a Masked Email address (kind='masked') must authorize a send exactly + // like a synced identity (kind='identity') — the lookup must never filter on `kind`. + it('authorizes a Masked Email-sourced row the same as an identity row', async () => { + query.mockResolvedValueOnce({ rows: [{ address: 'random1@fastmail.example', name: 'Private address', reply_to: [] }] }); + + const result = await authorizeSendableAddress({ accountId: ACCOUNT_ID, fromEmail: 'random1@fastmail.example' }); + + expect(result).toEqual({ fromName: 'Private address', fromEmail: 'random1@fastmail.example', replyTo: null }); + expect(query.mock.calls[0][0]).not.toMatch(/kind\s*=/); + }); +}); diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 448d71d..fb291e2 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -41,10 +41,17 @@ const COLORS = [ // ─── IMAP presets ───────────────────────────────────────────────────────────── const PRESETS = { - gmail: { label: 'Gmail', imap_host: 'imap.gmail.com', imap_port: 993, smtp_host: 'smtp.gmail.com', smtp_port: 587 }, - yahoo: { label: 'Yahoo', imap_host: 'imap.mail.yahoo.com', imap_port: 993, smtp_host: 'smtp.mail.yahoo.com', smtp_port: 587 }, - icloud: { label: 'iCloud', imap_host: 'imap.mail.me.com', imap_port: 993, smtp_host: 'smtp.mail.me.com', smtp_port: 587 }, - custom: { label: 'Custom' }, + gmail: { label: 'Gmail', imap_host: 'imap.gmail.com', imap_port: 993, smtp_host: 'smtp.gmail.com', smtp_port: 587 }, + yahoo: { label: 'Yahoo', imap_host: 'imap.mail.yahoo.com', imap_port: 993, smtp_host: 'smtp.mail.yahoo.com', smtp_port: 587 }, + icloud: { label: 'iCloud', imap_host: 'imap.mail.me.com', imap_port: 993, smtp_host: 'smtp.mail.me.com', smtp_port: 587 }, + fastmail: { + label: 'Fastmail', imap_host: 'imap.fastmail.com', imap_port: 993, + smtp_host: 'smtp.fastmail.com', smtp_port: 465, smtp_tls: 'SSL', + // Prefilled, not hardcoded server-side — JMAP identity sync reads this from the + // account row, same as any other host-configurable JMAP server. + jmap_session_url: 'https://api.fastmail.com/jmap/session', + }, + custom: { label: 'Custom' }, }; // ─── Account Form (Add or Edit) ─────────────────────────────────────────────── @@ -69,6 +76,10 @@ function AccountForm({ initial, onSave, onCancel }) { const [showPass, setShowPass] = useState(false); const [selectedPreset, setSelectedPreset] = useState(null); const [mailPolicy, setMailPolicy] = useState({ allowPrivateHosts: false, allowInsecureTls: false, allowNonstandardPorts: false }); + const [refreshingIdentities, setRefreshingIdentities] = useState(false); + // Overrides the (possibly stale) synced-at/error shown from `initial` once a manual + // refresh has actually run in this session — null until then. + const [refreshResult, setRefreshResult] = useState(null); useEffect(() => { api.admin.getSettings() @@ -88,6 +99,25 @@ function AccountForm({ initial, onSave, onCancel }) { setSelectedPreset(key); }; + // JMAP identity sync is host-configurable, so it's offered for any account, not just + // Fastmail — hidden only for the presets that don't support it (Gmail/Yahoo/iCloud have + // no user-facing JMAP endpoint here). Always shown once an account already exists. + const jmapSectionVisible = isEdit || !['gmail', 'yahoo', 'icloud'].includes(selectedPreset); + + const handleRefreshIdentities = async () => { + if (!initial?.id) return; + setRefreshingIdentities(true); + try { + setRefreshResult(await api.refreshAccountIdentities(initial.id)); + } catch (err) { + setRefreshResult({ synced_at: null, error: err.message }); + } finally { + setRefreshingIdentities(false); + } + }; + const effectiveSyncedAt = refreshResult ? refreshResult.synced_at : initial?.jmap_identity_sync_at; + const effectiveSyncError = refreshResult ? refreshResult.error : initial?.jmap_identity_sync_error; + const handleSubmit = async () => { if (!form.email_address || !form.auth_user || !form.imap_host) { setError(t('admin.accounts.errorRequired')); @@ -114,7 +144,7 @@ function AccountForm({ initial, onSave, onCancel }) {
{Object.entries(PRESETS).map(([key]) => { const active = selectedPreset === key; - const presetLabel = key === 'gmail' ? t('admin.accounts.presetGmail') : key === 'yahoo' ? t('admin.accounts.presetYahoo') : key === 'icloud' ? t('admin.accounts.presetIcloud') : t('admin.accounts.presetCustom'); + const presetLabel = key === 'gmail' ? t('admin.accounts.presetGmail') : key === 'yahoo' ? t('admin.accounts.presetYahoo') : key === 'icloud' ? t('admin.accounts.presetIcloud') : key === 'fastmail' ? t('admin.accounts.presetFastmail') : t('admin.accounts.presetCustom'); return ( +
+ ) : ( + set('jmap_api_token', e.target.value)} + placeholder={t('admin.accounts.jmapApiTokenPh')} style={inputStyle} + onFocus={e => e.target.style.borderColor = 'var(--accent)'} + onBlur={e => e.target.style.borderColor = 'var(--border)'} /> + )} + +
+ {t('admin.accounts.jmapApiTokenHelp')} +
+ {isEdit && initial?.jmap_identity_sync_configured && ( +
+ +
+ {effectiveSyncError || t('admin.accounts.jmapLastSynced', { + when: effectiveSyncedAt ? new Date(effectiveSyncedAt).toLocaleString() : t('common.never'), + })} +
+
+ )} + + )} + {error && (
{ if (composeData?.aliasId && composeData?.accountId) { - return `alias:${composeData.aliasId}:${composeData.accountId}`; + return aliasFromValue(composeData.aliasId, composeData.accountId); + } + if (composeData?.sendAs?.accountId && composeData?.sendAs?.fromEmail) { + return sendAsFromValue(composeData.sendAs.accountId, composeData.sendAs.fromEmail); } const lastUsedId = localStorage.getItem('mailflow_last_from_account'); const acctId = composeData?.accountId @@ -243,20 +247,11 @@ export default function ComposeModal() { || (lastUsedId && accounts.find(a => a.id === lastUsedId) ? lastUsedId : null) || accounts[0]?.id || ''; - return acctId ? `account:${acctId}` : ''; + return acctId ? accountFromValue(acctId) : ''; }; const [fromValue, setFromValue] = useState(initialFromValue); - const resolveFrom = (val) => { - if (!val) return { accountId: '', aliasId: null }; - if (val.startsWith('alias:')) { - const parts = val.split(':'); - return { aliasId: parts[1], accountId: parts[2] }; - } - return { accountId: val.replace('account:', ''), aliasId: null }; - }; - - const fromResolved = resolveFrom(fromValue); + const fromResolved = resolveFromValue(fromValue); const fromAccount = accounts.find(a => a.id === fromResolved.accountId); const fromAlias = fromResolved.aliasId ? fromAccount?.aliases?.find(al => al.id === fromResolved.aliasId) @@ -740,7 +735,7 @@ export default function ComposeModal() { const handleSend = async ({ skipSubjectWarn = false, skipAttachWarn = false } = {}) => { if (sending) return; // guard against a rapid double-submit (e.g. double Ctrl/Cmd+Enter) - const { accountId, aliasId } = resolveFrom(fromValue); + const { accountId, aliasId, fromEmail } = resolveFromValue(fromValue); const toFinal = [...toChips, ...(toInput.trim() ? [toInput.trim()] : [])]; if (!toFinal.length || !accountId) return; @@ -775,6 +770,7 @@ export default function ComposeModal() { const sendResult = await api.post('/mail/send', { accountId, ...(aliasId ? { aliasId } : {}), + ...(fromEmail ? { fromEmail } : {}), to: toFinal, cc: [...ccChips, ...(ccInput.trim() ? [ccInput.trim()] : [])], bcc: [...bccChips, ...(bccInput.trim() ? [bccInput.trim()] : [])], @@ -835,6 +831,15 @@ export default function ComposeModal() { setTimeout(refreshThread, 10000); } } catch (err) { + if (err.code === 'SENDER_UNAVAILABLE') { + // The transient sender is no longer authorized (e.g. the identity was removed from + // the provider between reply and send) — quiet toast, not an inline form error, and + // fall back to the account's default From rather than leaving an unsendable value selected. + addNotification({ type: 'error', title: t('compose.errors.senderUnavailable') }); + setFromValue(accountFromValue(accountId)); + setSending(false); + return; + } setError(err.message); setSending(false); } @@ -856,7 +861,10 @@ export default function ComposeModal() { }; const doSaveDraft = async ({ closeAfter = false } = {}) => { - const { accountId, aliasId } = resolveFrom(fromValue); + // Drafts keep today's shape (accountId/aliasId only) — a sendas selection isn't + // persisted here; saving a draft with a transient sender falls back to the account + // default, same as before this feature existed. + const { accountId, aliasId } = resolveFromValue(fromValue); if (!accountId) return; setSavingDraft(true); try { @@ -1139,20 +1147,28 @@ export default function ComposeModal() { {accounts.map(a => { const aliases = a.aliases || []; const displayName = a.sender_name || a.name; - if (!aliases.length) { + // Pin the transient reply-time sender at the top of this account's group, + // for this draft only — never saved as an alias, never listed anywhere else. + const sendAsHere = composeData?.sendAs?.accountId === a.id ? composeData.sendAs : null; + if (!aliases.length && !sendAsHere) { return ( - ); } return ( - + )} + {aliases.map(alias => ( - ))} @@ -1787,20 +1803,28 @@ export default function ComposeModal() { {accounts.map(a => { const aliases = a.aliases || []; const displayName = a.sender_name || a.name; - if (!aliases.length) { + // Pin the transient reply-time sender at the top of this account's group, + // for this draft only — never saved as an alias, never listed anywhere else. + const sendAsHere = composeData?.sendAs?.accountId === a.id ? composeData.sendAs : null; + if (!aliases.length && !sendAsHere) { return ( - ); } return ( - + )} + {aliases.map(alias => ( - ))} diff --git a/frontend/src/components/MessagePane.jsx b/frontend/src/components/MessagePane.jsx index 969b7d4..b6a3167 100644 --- a/frontend/src/components/MessagePane.jsx +++ b/frontend/src/components/MessagePane.jsx @@ -12,6 +12,7 @@ import DOMPurify from 'dompurify'; import { BUILTIN_SUMMARIZE } from '../aiActions.js'; import { getResults, saveResult, removeResult } from '../aiResults.js'; import { renderMarkdown } from '../utils/renderMarkdown.js'; +import { pickReplyAlias } from '../utils/replyAlias.js'; const USE_DIV_RENDER = import.meta.env.VITE_EMAIL_DIV_RENDER === 'true'; const MESSAGE_OPENING_EVENT = 'mailflow:message-opening'; @@ -864,7 +865,7 @@ export default function MessagePane() { }; }, [isMobile, setSelectedMessage, resetPaneSwipeStyles]); - const handleReply = (replyAll = false) => { + const handleReply = async (replyAll = false) => { if (!message) return; const date = message.date ? new Date(message.date).toLocaleString() : ''; const safeName = (message.from_name || '').replace(/[\r\n]+/g, ' '); @@ -889,25 +890,13 @@ export default function MessagePane() { const myAccount = accounts.find(a => a.id === message.account_id); const myEmail = myAccount?.email_address || ''; - const replyAliasId = (() => { - const aliases = myAccount?.aliases || []; - if (!aliases.length) return null; - try { - const toArr = Array.isArray(message.to_addresses) - ? message.to_addresses - : JSON.parse(message.to_addresses || '[]'); - const ccArr = Array.isArray(message.cc_addresses) - ? message.cc_addresses - : JSON.parse(message.cc_addresses || '[]'); - const allEmails = [...toArr, ...ccArr].map(t => t.email?.toLowerCase()).filter(Boolean); - const fromEmail = (message.from_email || '').toLowerCase(); - const match = aliases.find(al => { - const aliasEmail = al.email.toLowerCase(); - return allEmails.includes(aliasEmail) || fromEmail === aliasEmail; - }); - return match ? match.id : null; - } catch { return null; } - })(); + const replyAliasId = pickReplyAlias({ + aliases: myAccount?.aliases || [], + deliveryAddresses: message.delivery_addresses, + toAddresses: message.to_addresses, + ccAddresses: message.cc_addresses, + fromEmail: message.from_email, + }); const myAddresses = new Set([ myEmail.toLowerCase(), @@ -933,6 +922,22 @@ export default function MessagePane() { const rawSubject = (message.subject || '').trim(); const reSubject = rawSubject.startsWith('Re:') ? rawSubject : rawSubject ? `Re: ${rawSubject}` : 'Re:'; + // No trusted (primary/alias) match — ask whether a synced JMAP identity authorizes a + // transient From for this reply. Bounded so a slow/unreachable JMAP server can never + // hang the reply action; any failure just falls back to today's behavior (primary). + let sendAs = null; + if (!replyAliasId && myAccount?.jmap_identity_sync_configured) { + try { + const result = await Promise.race([ + api.getReplySender(message.id), + new Promise((_, reject) => setTimeout(() => reject(new Error('reply-sender timeout')), 2500)), + ]); + if (result?.sender?.fromEmail) { + sendAs = { accountId: message.account_id, fromEmail: result.sender.fromEmail, name: result.sender.name }; + } + } catch { /* quiet fallback — never block reply on a network error */ } + } + setShowReplyMenu(false); openCompose({ to: sender, @@ -945,6 +950,7 @@ export default function MessagePane() { references: referencesChain, accountId: message.account_id, aliasId: replyAliasId, + ...(sendAs ? { sendAs } : {}), isReply: true, isReplyAll: replyAll, originalFrom: sender, diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 6c1bd0a..0b2e94c 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -96,6 +96,7 @@ "replyAll": "Allen antworten", "forward": "Weiterleiten", "from": "Von", + "fromDeliveredTo": "zugestellt an", "to": "An", "cc": "Cc", "bcc": "Bcc", @@ -141,6 +142,9 @@ "noCopy": "Gesendet, aber nicht im Ordner Gesendet gespeichert", "action": "Anzeigen" }, + "errors": { + "senderUnavailable": "Diese Absenderadresse ist nicht mehr verfügbar. Wähle einen anderen Absender und versuche es erneut." + }, "toolbar": { "removeTable": "Tabelle entfernen", "textColor": "Textfarbe", @@ -651,6 +655,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "Benutzerdefiniert", "errorRequired": "E-Mail, Benutzername und IMAP-Host sind erforderlich", "errorPasswordRequired": "Für neue Konten ist ein Passwort erforderlich", @@ -663,7 +668,18 @@ "categorizationSection": "E-Mail-Kategorisierung", "categorizationEnabled": "Posteingang-Kategorisierung aktivieren", "categorizationEnabledDesc": "Eingehende E-Mails in Kategorie-Tabs einteilen. Standardmäßig deaktiviert.", - "enabledGlobally": "Global aktiviert" + "enabledGlobally": "Global aktiviert", + "jmapSection": "Identitätssynchronisierung (JMAP)", + "jmapSessionUrl": "JMAP-Sitzungs-URL", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "API-Token", + "jmapApiTokenPh": "JMAP-API-Token einfügen", + "jmapApiTokenHelp": "Wird verwendet, um zu ermitteln, von welchen Adressen aus du senden darfst (z. B. Fastmail-Aliase und eigene Domains). Nur lesend — MailFlow ändert nie etwas auf dem Server.", + "jmapConfigured": "Konfiguriert", + "jmapClear": "Entfernen", + "jmapRefresh": "Jetzt aktualisieren", + "jmapRefreshing": "Wird aktualisiert…", + "jmapLastSynced": "Zuletzt synchronisiert: {{when}}" }, "folderMappings": { "title": "Ordnerzuordnungen", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index fa8cb39..43a522c 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -96,6 +96,7 @@ "replyAll": "Reply All", "forward": "Forward", "from": "From", + "fromDeliveredTo": "delivered to", "to": "To", "cc": "Cc", "bcc": "Bcc", @@ -141,6 +142,9 @@ "noCopy": "Sent, but not saved to your Sent folder", "action": "View" }, + "errors": { + "senderUnavailable": "This sending address is no longer available. Choose a different sender and try again." + }, "toolbar": { "removeTable": "Remove table", "textColor": "Text color", @@ -718,6 +722,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "Custom", "errorRequired": "Email, username, and IMAP host are required", "errorPasswordRequired": "Password is required for new accounts", @@ -730,7 +735,18 @@ "categorizationSection": "Email Categorization", "categorizationEnabled": "Enable inbox categorization", "categorizationEnabledDesc": "Sort incoming emails into category tabs. Disabled by default.", - "enabledGlobally": "Enabled globally" + "enabledGlobally": "Enabled globally", + "jmapSection": "Identity sync (JMAP)", + "jmapSessionUrl": "JMAP session URL", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "API token", + "jmapApiTokenPh": "Paste your JMAP API token", + "jmapApiTokenHelp": "Used to read which addresses you're allowed to send as (e.g. Fastmail aliases and custom domains). Read-only — MailFlow never changes anything on the server.", + "jmapConfigured": "Configured", + "jmapClear": "Clear", + "jmapRefresh": "Refresh now", + "jmapRefreshing": "Refreshing…", + "jmapLastSynced": "Last synced: {{when}}" }, "folderMappings": { "title": "Folder mappings", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 6e6fc89..5276928 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -96,6 +96,7 @@ "replyAll": "Responder a todos", "forward": "Reenviar", "from": "De", + "fromDeliveredTo": "entregado a", "to": "Para", "cc": "Cc", "bcc": "Cco", @@ -141,6 +142,9 @@ "noCopy": "Enviado, pero no guardado en Enviados", "action": "Ver" }, + "errors": { + "senderUnavailable": "Esta dirección de envío ya no está disponible. Elige otro remitente e inténtalo de nuevo." + }, "toolbar": { "removeTable": "Eliminar tabla", "textColor": "Color del texto", @@ -716,6 +720,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "Personalizado", "errorRequired": "El correo, el usuario y el servidor IMAP son obligatorios", "errorPasswordRequired": "La contraseña es obligatoria para cuentas nuevas", @@ -730,7 +735,18 @@ "categorizationSection": "Categorización de correo", "categorizationEnabled": "Activar categorización del buzón", "categorizationEnabledDesc": "Clasifica los correos entrantes en pestañas de categorías. Desactivado por defecto.", - "enabledGlobally": "Activado globalmente" + "enabledGlobally": "Activado globalmente", + "jmapSection": "Sincronización de identidad (JMAP)", + "jmapSessionUrl": "URL de sesión JMAP", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "Token de API", + "jmapApiTokenPh": "Pega tu token de API JMAP", + "jmapApiTokenHelp": "Se usa para saber desde qué direcciones puedes enviar (p. ej. alias de Fastmail y dominios personalizados). Solo lectura: MailFlow nunca modifica nada en el servidor.", + "jmapConfigured": "Configurado", + "jmapClear": "Borrar", + "jmapRefresh": "Actualizar ahora", + "jmapRefreshing": "Actualizando…", + "jmapLastSynced": "Última sincronización: {{when}}" }, "folderMappings": { "title": "Asignación de carpetas", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index b103645..e9e6d30 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -96,6 +96,7 @@ "replyAll": "Répondre à tous", "forward": "Transférer", "from": "De", + "fromDeliveredTo": "remis à", "to": "À", "cc": "Cc", "bcc": "Cci", @@ -141,6 +142,9 @@ "noCopy": "Envoyé, mais non enregistré dans Envoyés", "action": "Voir" }, + "errors": { + "senderUnavailable": "Cette adresse d'expédition n'est plus disponible. Choisissez un autre expéditeur et réessayez." + }, "toolbar": { "removeTable": "Supprimer le tableau", "textColor": "Couleur du texte", @@ -716,6 +720,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "Personnalisé", "errorRequired": "L'e-mail, le nom d'utilisateur et le serveur IMAP sont requis", "errorPasswordRequired": "Le mot de passe est requis pour les nouveaux comptes", @@ -730,7 +735,18 @@ "categorizationSection": "Catégorisation des e-mails", "categorizationEnabled": "Activer la catégorisation", "categorizationEnabledDesc": "Classe les e-mails entrants dans des onglets de catégories. Désactivé par défaut.", - "enabledGlobally": "Activé globalement" + "enabledGlobally": "Activé globalement", + "jmapSection": "Synchronisation d'identité (JMAP)", + "jmapSessionUrl": "URL de session JMAP", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "Jeton API", + "jmapApiTokenPh": "Collez votre jeton API JMAP", + "jmapApiTokenHelp": "Sert à déterminer les adresses depuis lesquelles vous pouvez envoyer (ex. alias Fastmail et domaines personnalisés). Lecture seule : MailFlow ne modifie jamais rien sur le serveur.", + "jmapConfigured": "Configuré", + "jmapClear": "Effacer", + "jmapRefresh": "Actualiser maintenant", + "jmapRefreshing": "Actualisation…", + "jmapLastSynced": "Dernière synchronisation : {{when}}" }, "folderMappings": { "title": "Mappage des dossiers", diff --git a/frontend/src/locales/i18n.test.js b/frontend/src/locales/i18n.test.js index 888632e..79e3334 100644 --- a/frontend/src/locales/i18n.test.js +++ b/frontend/src/locales/i18n.test.js @@ -122,6 +122,8 @@ const SAME_VALUE_ALLOWED = { 'admin.accounts.presetGmail': 'any', // Gmail 'admin.accounts.presetIcloud': 'any', // iCloud 'admin.accounts.presetYahoo': 'any', // Yahoo Mail + 'admin.accounts.presetFastmail': 'any', // Fastmail — brand name, same everywhere + 'admin.accounts.jmapSessionUrlPh': 'any', // https://mail.example.com/.well-known/jmap 'admin.accounts.smtpHostPh': 'any', // smtp.gmail.com 'admin.ai.baseUrlPh': 'any', // http://localhost:11434/v1 'admin.appearance.customCssPlaceholder': 'any', // CSS code snippet, same in all locales diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 2997ed8..a01787b 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -96,6 +96,7 @@ "replyAll": "Rispondi a tutti", "forward": "Inoltra", "from": "Da", + "fromDeliveredTo": "consegnato a", "to": "A", "cc": "Cc", "bcc": "Bcc", @@ -141,6 +142,9 @@ "noCopy": "Inviato, ma non salvato in Inviati", "action": "Visualizza" }, + "errors": { + "senderUnavailable": "Questo indirizzo di invio non è più disponibile. Scegli un altro mittente e riprova." + }, "toolbar": { "removeTable": "Rimuovi tabella", "textColor": "Colore testo", @@ -716,6 +720,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "Personalizzato", "errorRequired": "Email, nome utente e host IMAP sono obbligatori", "errorPasswordRequired": "La password è richiesta per i nuovi account", @@ -730,7 +735,18 @@ "categorizationSection": "Categorizzazione email", "categorizationEnabled": "Abilita categorizzazione", "categorizationEnabledDesc": "Ordina le email in arrivo in schede di categoria. Disabilitato per impostazione predefinita.", - "enabledGlobally": "Abilitato globalmente" + "enabledGlobally": "Abilitato globalmente", + "jmapSection": "Sincronizzazione identità (JMAP)", + "jmapSessionUrl": "URL sessione JMAP", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "Token API", + "jmapApiTokenPh": "Incolla il tuo token API JMAP", + "jmapApiTokenHelp": "Serve a sapere da quali indirizzi puoi inviare (es. alias Fastmail e domini personalizzati). Sola lettura: MailFlow non modifica mai nulla sul server.", + "jmapConfigured": "Configurato", + "jmapClear": "Cancella", + "jmapRefresh": "Aggiorna ora", + "jmapRefreshing": "Aggiornamento…", + "jmapLastSynced": "Ultima sincronizzazione: {{when}}" }, "folderMappings": { "title": "Mappatura cartelle", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 2ba1117..b71d733 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -96,6 +96,7 @@ "replyAll": "Ответить всем", "forward": "Переслать", "from": "От", + "fromDeliveredTo": "доставлено на", "to": "Кому", "cc": "Копия", "bcc": "Скрытая копия", @@ -141,6 +142,9 @@ "noCopy": "Отправлено, но не сохранено в папке Отправленные", "action": "Просмотреть" }, + "errors": { + "senderUnavailable": "Этот адрес отправителя больше недоступен. Выберите другого отправителя и повторите попытку." + }, "toolbar": { "removeTable": "Удалить таблицу", "textColor": "Цвет текста", @@ -716,6 +720,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "Другое", "errorRequired": "Требуются адрес электронной почты, имя пользователя и IMAP-сервер", "errorPasswordRequired": "Для новых аккаунтов требуется пароль", @@ -730,7 +735,18 @@ "categorizationSection": "Категоризация почты", "categorizationEnabled": "Включить категоризацию входящих", "categorizationEnabledDesc": "Сортирует входящие письма по вкладкам категорий. По умолчанию отключено.", - "enabledGlobally": "Включено глобально" + "enabledGlobally": "Включено глобально", + "jmapSection": "Синхронизация идентификаторов (JMAP)", + "jmapSessionUrl": "URL сессии JMAP", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "API-токен", + "jmapApiTokenPh": "Вставьте свой JMAP API-токен", + "jmapApiTokenHelp": "Используется, чтобы узнать, с каких адресов вам разрешено отправлять письма (например, псевдонимы Fastmail и собственные домены). Только чтение — MailFlow никогда не изменяет ничего на сервере.", + "jmapConfigured": "Настроено", + "jmapClear": "Очистить", + "jmapRefresh": "Обновить сейчас", + "jmapRefreshing": "Обновление…", + "jmapLastSynced": "Последняя синхронизация: {{when}}" }, "folderMappings": { "title": "Сопоставление папок", diff --git a/frontend/src/locales/zhCN.json b/frontend/src/locales/zhCN.json index 04e064a..151405a 100644 --- a/frontend/src/locales/zhCN.json +++ b/frontend/src/locales/zhCN.json @@ -96,6 +96,7 @@ "replyAll": "回复全部", "forward": "转发", "from": "发件人", + "fromDeliveredTo": "投递至", "to": "收件人", "cc": "抄送", "bcc": "密送", @@ -141,6 +142,9 @@ "noCopy": "已发送,但未保存到已发送文件夹", "action": "查看" }, + "errors": { + "senderUnavailable": "此发件地址已不可用。请选择其他发件人后重试。" + }, "toolbar": { "removeTable": "删除表格", "textColor": "文字颜色", @@ -718,6 +722,7 @@ "presetGmail": "Gmail", "presetYahoo": "Yahoo Mail", "presetIcloud": "iCloud", + "presetFastmail": "Fastmail", "presetCustom": "自定义", "errorRequired": "邮箱地址、用户名和 IMAP 服务器为必填项", "errorPasswordRequired": "添加新账户时必须填写密码", @@ -730,7 +735,18 @@ "categorizationSection": "邮件分类", "categorizationEnabled": "启用收件箱分类", "categorizationEnabledDesc": "将收到的邮件按类别选项卡分类。默认禁用。", - "enabledGlobally": "已全局启用" + "enabledGlobally": "已全局启用", + "jmapSection": "身份同步(JMAP)", + "jmapSessionUrl": "JMAP 会话 URL", + "jmapSessionUrlPh": "https://mail.example.com/.well-known/jmap", + "jmapApiToken": "API 令牌", + "jmapApiTokenPh": "粘贴您的 JMAP API 令牌", + "jmapApiTokenHelp": "用于读取您可以使用哪些地址发送邮件(例如 Fastmail 别名和自定义域名)。仅只读——MailFlow 绝不会更改服务器上的任何内容。", + "jmapConfigured": "已配置", + "jmapClear": "清除", + "jmapRefresh": "立即刷新", + "jmapRefreshing": "正在刷新…", + "jmapLastSynced": "上次同步:{{when}}" }, "folderMappings": { "title": "文件夹映射", diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c6d0e55..fc00901 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -28,7 +28,11 @@ async function request(method, path, body, extraHeaders) { window.dispatchEvent(new CustomEvent('mailflow:session_expired')); } const err = await res.json().catch(() => ({ error: 'Request failed' })); - throw new Error(err.error || 'Request failed'); + const thrown = new Error(err.error || 'Request failed'); + // Machine-readable error codes (e.g. SENDER_UNAVAILABLE) let callers branch on the + // failure reason without string-matching the message. + if (err.code) thrown.code = err.code; + throw thrown; } return res.json(); } @@ -140,6 +144,7 @@ export const api = { deleteAccount: (id) => request('DELETE', `/accounts/${id}`), reconnectAccount: (id) => request('POST', `/accounts/${id}/reconnect`), reindexAccount: (id) => request('POST', `/accounts/${id}/reindex`), + refreshAccountIdentities: (id) => request('POST', `/accounts/${id}/identities/refresh`), getFolders: (accountId) => request('GET', `/accounts/${accountId}/folders`), getAliases: (accountId) => request('GET', `/accounts/${accountId}/aliases`), addAlias: (accountId, data) => request('POST', `/accounts/${accountId}/aliases`, data), @@ -178,6 +183,9 @@ export const api = { getMessageHeaders: (id) => request('GET', `/mail/messages/${id}/headers`), snoozeMessage: (id, until) => request('POST', `/mail/messages/${id}/snooze`, { until }), + // Transient reply-From resolution (JMAP identity sync) — never returns an address list, + // only the single best match (or null). + getReplySender: (id) => request('GET', `/mail/messages/${id}/reply-sender`), // Integrations getIntegrations: () => request('GET', '/integrations'), diff --git a/frontend/src/utils/fromValue.js b/frontend/src/utils/fromValue.js new file mode 100644 index 0000000..933ec2c --- /dev/null +++ b/frontend/src/utils/fromValue.js @@ -0,0 +1,33 @@ +// Encode/decode the ComposeModal From