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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/migrations/0035_message_delivery_addresses.sql
Original file line number Diff line number Diff line change
@@ -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;
34 changes: 34 additions & 0 deletions backend/migrations/0036_jmap_identity_sync.sql
Original file line number Diff line number Diff line change
@@ -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);
334 changes: 334 additions & 0 deletions backend/src/routes/accounts.jmap.test.js

Large diffs are not rendered by default.

181 changes: 163 additions & 18 deletions backend/src/routes/accounts.js

Large diffs are not rendered by default.

38 changes: 35 additions & 3 deletions backend/src/routes/mail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions backend/src/routes/mail.replySender.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
18 changes: 17 additions & 1 deletion backend/src/routes/send.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Loading