Refresh of the open-core distribution from the private tree. Included since the previous snapshot: - Mail module (ADR-158/159/160/169): composer, labels, scheduling, attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083 - Crawler-readable SSR for /blog and public spaces (ADR-190): blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages - Registration policy + referral/promo settings (ADR-183/188) - Message translation + language detection (ADR-185) - Reddit connector for the agent-tool surface Excised from the public distribution (unchanged policy): infrastructure topology and host config, internal ops scripts, DB cleanup snapshots, business documents, throwaway debug scripts, and two private product lines (SC-SIM simulator, personal one-off tools). Real host addresses are replaced with placeholders; credential-shaped literals are redacted. Frontend build verified green on this tree.
1281 lines
51 KiB
JavaScript
1281 lines
51 KiB
JavaScript
/**
|
||
* Mail module routes — ADR-160 (the bridge slice).
|
||
*
|
||
* Connects the `imap` connector (catalogue/imap.js) + `mail_messages` store
|
||
* (mig 075) to the Mail UI. The frontend hooks read these over `/api/v3/mail/*`,
|
||
* replacing MOCK_MESSAGES.
|
||
*
|
||
* GET /spaces/:spaceId/mail/folders → folder list + unread counts
|
||
* GET /spaces/:spaceId/mail/messages → list a folder (newest first)
|
||
* GET /spaces/:spaceId/mail/messages/:msgId → one message (marks it read)
|
||
* POST /spaces/:spaceId/mail/sync → pull newest N from IMAP, upsert
|
||
* POST /spaces/:spaceId/mail/send → send via the connector's SMTP creds
|
||
*
|
||
* `spaceId` is a path param (sibling of /spaces/:spaceId/connectors); the mailbox
|
||
* is chosen with `connector_id` (account switcher) and access is checked per
|
||
* request. Credentials never leave the server — they are vault-decrypted here,
|
||
* used for the IMAP/SMTP call, and dropped. Mounted via:
|
||
* app.use('/api/v3', authenticate, mailRoutesV3);
|
||
*/
|
||
|
||
import express from 'express';
|
||
|
||
import { dbGet, dbAll, dbRun } from '../../database/connection.js';
|
||
import { checkUserSpaceAccess } from '../../services/space/access.js';
|
||
import { imapFetch } from '../../services/mail/imapFetch.js';
|
||
import { extractReplyToken } from '../../services/mail/replyToken.js';
|
||
import { normalizeRecipients, RecipientError } from '../../services/mail/recipients.js';
|
||
import { normalizeAttachments, AttachmentError } from '../../services/mail/attachments.js';
|
||
import {
|
||
matchingLabelIds,
|
||
matchesRules,
|
||
normalizeRules,
|
||
collectTableRefs,
|
||
resolveTableRefs,
|
||
resolveLabels,
|
||
RuleError,
|
||
} from '../../services/mail/labelRules.js';
|
||
import { loadImapConnector } from '../../services/mail/connector.js';
|
||
import { bodyToPreview } from '../../services/mail/preview.js';
|
||
import { deliverMail, MailSendError } from '../../services/mail/deliver.js';
|
||
import { apiLogger as log } from '../../utils/logger.js';
|
||
import { success, created, error, badRequest, notFound, forbidden } from '../../utils/response.js';
|
||
|
||
const router = express.Router();
|
||
|
||
// Canonical folder list mirrored from src/features/mail/types.ts (MailFolderId).
|
||
const FOLDERS = [
|
||
{ id: 'inbox', label: 'Inbox' },
|
||
{ id: 'sent', label: 'Sent' },
|
||
{ id: 'drafts', label: 'Drafts' },
|
||
{ id: 'archive', label: 'Archive' },
|
||
{ id: 'trash', label: 'Trash' },
|
||
];
|
||
const FOLDER_IDS = new Set(FOLDERS.map((f) => f.id));
|
||
|
||
// ─── helpers ────────────────────────────────────────────────────────
|
||
|
||
async function ensureSpaceAccess(req, res, spaceId) {
|
||
if (!Number.isFinite(spaceId)) {
|
||
badRequest(res, 'Invalid spaceId');
|
||
return null;
|
||
}
|
||
const space = await dbGet('SELECT * FROM spaces WHERE id = ?', [spaceId]);
|
||
if (!space) {
|
||
notFound(res, 'Space');
|
||
return null;
|
||
}
|
||
let accessControl = null;
|
||
try {
|
||
accessControl =
|
||
typeof space.access_control === 'string'
|
||
? JSON.parse(space.access_control)
|
||
: space.access_control;
|
||
} catch {
|
||
accessControl = null;
|
||
}
|
||
const allowed = await checkUserSpaceAccess(req.user.id, req.user.role, space, accessControl);
|
||
if (!allowed) {
|
||
forbidden(res, 'No access to space');
|
||
return null;
|
||
}
|
||
return space;
|
||
}
|
||
|
||
// loadImapConnector moved to services/mail/connector.js so the scheduled-send
|
||
// worker resolves + decrypts creds through the same path (imported above).
|
||
|
||
// DB row → frontend MailMessage shape (src/features/mail/types.ts).
|
||
// `labels` is passed in by the caller (batched to avoid an N+1 per row); a row
|
||
// with no labels renders no chips. Shape per label: {id, name, icon, color}.
|
||
function toMailMessage(r, labels = []) {
|
||
return {
|
||
id: String(r.id),
|
||
// ADR-173 §B.4 — label chips (icon+color) rendered inline on rows/headers.
|
||
labels,
|
||
folder: r.folder,
|
||
fromName: r.from_name || r.from_address || '',
|
||
fromAddress: r.from_address || '',
|
||
to: Array.isArray(r.to_addresses)
|
||
? r.to_addresses
|
||
: r.to_addresses
|
||
? JSON.parse(r.to_addresses)
|
||
: [],
|
||
subject: r.subject || '',
|
||
preview: r.preview || '',
|
||
body: r.body_html || r.body_text || '',
|
||
date: r.date ? new Date(r.date).toISOString() : null,
|
||
read: !!r.is_read,
|
||
starred: !!r.starred,
|
||
// ADR-158 §P5 — attachment metadata (filename/contentType/size); [] when none.
|
||
attachments: Array.isArray(r.attachments)
|
||
? r.attachments
|
||
: r.attachments
|
||
? JSON.parse(r.attachments)
|
||
: [],
|
||
ticketId: r.ticket_id ?? null,
|
||
// ADR-158 §P2 — generic CRM linkage (deal/lead/ticket/…) + reply token.
|
||
linkedTableId: r.linked_table_id ?? null,
|
||
linkedRowId: r.linked_row_id ?? null,
|
||
replyToken: r.reply_token ?? null,
|
||
};
|
||
}
|
||
|
||
// ADR-158 §P7 — compact "chip-resolve" shape for embedding a mail message as a
|
||
// reference chip inside a chat conversation (mirrors the ticket-chip idiom:
|
||
// send_widget_message → row_reference attachment). Unlike toMailMessage this is
|
||
// display-only metadata (no body/labels) — everything a MailMessageChip needs to
|
||
// render collapsed (sender · subject · date · attachment count) and to open the
|
||
// message in the reader by `messageId`. Consumed by:
|
||
// - GET …/messages/:msgId/ref → build the attachment / re-resolve on render
|
||
// - the `mail_message_ref` chat attachment payload (baked at attach time)
|
||
function toMailRef(r) {
|
||
const attachments = Array.isArray(r.attachments)
|
||
? r.attachments
|
||
: r.attachments
|
||
? JSON.parse(r.attachments)
|
||
: [];
|
||
return {
|
||
messageId: String(r.id),
|
||
spaceId: r.space_id,
|
||
connectorId: r.connector_id ?? null,
|
||
folder: r.folder,
|
||
subject: r.subject || '',
|
||
fromName: r.from_name || r.from_address || '',
|
||
fromAddress: r.from_address || '',
|
||
to: Array.isArray(r.to_addresses)
|
||
? r.to_addresses
|
||
: r.to_addresses
|
||
? JSON.parse(r.to_addresses)
|
||
: [],
|
||
date: r.date ? new Date(r.date).toISOString() : null,
|
||
read: !!r.is_read,
|
||
attachmentCount: Array.isArray(attachments) ? attachments.length : 0,
|
||
preview: r.preview || '',
|
||
};
|
||
}
|
||
|
||
// Parse a jsonb column that may arrive already-parsed (pg) or as a string.
|
||
function parseJsonb(v, fallback = []) {
|
||
if (v == null) return fallback;
|
||
if (typeof v !== 'string') return v;
|
||
try {
|
||
return JSON.parse(v);
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
// mail_outbox row → frontend shape for the scheduled-send panel (ADR-158 §P6).
|
||
// Bytes are never shipped back — only an attachment count for the list.
|
||
function toScheduled(r) {
|
||
const attachments = parseJsonb(r.attachments, []);
|
||
return {
|
||
id: Number(r.id),
|
||
connectorId: r.connector_id ?? null,
|
||
to: parseJsonb(r.to_addresses, []),
|
||
cc: parseJsonb(r.cc_addresses, []),
|
||
bcc: parseJsonb(r.bcc_addresses, []),
|
||
subject: r.subject || '',
|
||
preview: bodyToPreview(r.body, r.is_html),
|
||
// Full composed body (HTML/text as stored) so the "Отложенные" editor can
|
||
// reopen a queued send fully pre-filled and editable (ADR-158 §P6). The list
|
||
// is short (a handful of queued sends), so shipping the body here is cheap.
|
||
body: r.body || '',
|
||
isHtml: !!r.is_html,
|
||
attachmentCount: Array.isArray(attachments) ? attachments.length : 0,
|
||
linkedTableId: r.linked_table_id ?? null,
|
||
linkedRowId: r.linked_row_id ?? null,
|
||
scheduledAt: r.scheduled_at ? new Date(r.scheduled_at).toISOString() : null,
|
||
status: r.status,
|
||
errorMessage: r.error_message ?? null,
|
||
sentMessageId: r.sent_message_id != null ? Number(r.sent_message_id) : null,
|
||
createdAt: r.created_at ? new Date(r.created_at).toISOString() : null,
|
||
};
|
||
}
|
||
|
||
// ─── label helpers (ADR-173) ────────────────────────────────────────
|
||
|
||
// A user-defined label as the API returns it (rail/settings/chips).
|
||
function toLabel(r) {
|
||
return {
|
||
id: Number(r.id),
|
||
name: r.name,
|
||
icon: r.icon ?? null,
|
||
color: r.color ?? null,
|
||
showInToolbar: !!r.show_in_toolbar,
|
||
rules: typeof r.rules === 'string' ? JSON.parse(r.rules) : r.rules || {},
|
||
orderIndex: Number(r.order_index) || 0,
|
||
enabled: !!r.enabled,
|
||
...(r.message_count != null ? { messageCount: Number(r.message_count) } : {}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Batch-load the labels attached to a set of messages, keyed by message id.
|
||
* One indexed join for the whole page — never per-row (ADR-173 §Risks: N+1).
|
||
* @returns {Promise<Map<string, Array<{id,name,icon,color}>>>}
|
||
*/
|
||
async function fetchLabelsMap(messageIds, spaceId) {
|
||
const map = new Map();
|
||
if (!messageIds.length) return map;
|
||
const rows = await dbAll(
|
||
`SELECT mml.message_id, l.id, l.name, l.icon, l.color
|
||
FROM mail_message_labels mml
|
||
JOIN mail_labels l ON l.id = mml.label_id
|
||
WHERE l.space_id = ? AND mml.message_id = ANY(?::bigint[])
|
||
ORDER BY l.order_index, l.id`,
|
||
[spaceId, messageIds]
|
||
);
|
||
for (const r of rows) {
|
||
const key = String(r.message_id);
|
||
if (!map.has(key)) map.set(key, []);
|
||
map.get(key).push({ id: Number(r.id), name: r.name, icon: r.icon ?? null, color: r.color ?? null });
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// Confirm a label belongs to this space (guards tag/untag/apply cross-space).
|
||
async function loadSpaceLabel(spaceId, labelId) {
|
||
if (!Number.isFinite(labelId)) return null;
|
||
return dbGet('SELECT * FROM mail_labels WHERE id = ? AND space_id = ?', [labelId, spaceId]);
|
||
}
|
||
|
||
// ─── GET /spaces/:spaceId/mail/folders ──────────────────────────────
|
||
|
||
router.get('/spaces/:spaceId/mail/folders', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const conn = await loadImapConnector(spaceId, Number(req.query.connector_id) || null);
|
||
// No mailbox connected yet → return the static folder list with zero counts so
|
||
// the UI renders the nav before a connector exists.
|
||
const unread = {};
|
||
if (conn) {
|
||
const counts = await dbAll(
|
||
`SELECT folder, COUNT(*) FILTER (WHERE NOT is_read) AS unread
|
||
FROM mail_messages
|
||
WHERE space_id = ? AND connector_id = ?
|
||
GROUP BY folder`,
|
||
[spaceId, conn.id]
|
||
);
|
||
for (const c of counts) unread[c.folder] = Number(c.unread) || 0;
|
||
}
|
||
|
||
return success(res, {
|
||
folders: FOLDERS.map((f) => ({ ...f, unread: unread[f.id] || 0 })),
|
||
});
|
||
});
|
||
|
||
// ─── GET /spaces/:spaceId/mail/messages ─────────────────────────────
|
||
|
||
router.get('/spaces/:spaceId/mail/messages', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
|
||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||
|
||
const conn = await loadImapConnector(spaceId, Number(req.query.connector_id) || null);
|
||
if (!conn) return success(res, { messages: [] });
|
||
|
||
// ADR-173 §B.4 — `?label=<id>` is a *virtual folder*: filter by label across
|
||
// ALL real folders (message stays put). Takes precedence over `folder`.
|
||
let rows;
|
||
if (req.query.label != null && req.query.label !== '') {
|
||
const labelId = Number(req.query.label);
|
||
if (!Number.isFinite(labelId)) return badRequest(res, 'Invalid label id');
|
||
rows = await dbAll(
|
||
`SELECT m.* FROM mail_messages m
|
||
JOIN mail_message_labels mml ON mml.message_id = m.id
|
||
WHERE m.space_id = ? AND m.connector_id = ? AND mml.label_id = ?
|
||
ORDER BY m.date DESC NULLS LAST, m.id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[spaceId, conn.id, labelId, limit, offset]
|
||
);
|
||
} else {
|
||
const folder = String(req.query.folder || 'inbox');
|
||
if (!FOLDER_IDS.has(folder)) return badRequest(res, `Unknown folder: ${folder}`);
|
||
rows = await dbAll(
|
||
`SELECT * FROM mail_messages
|
||
WHERE space_id = ? AND connector_id = ? AND folder = ?
|
||
ORDER BY date DESC NULLS LAST, id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[spaceId, conn.id, folder, limit, offset]
|
||
);
|
||
}
|
||
|
||
const labelsMap = await fetchLabelsMap(rows.map((r) => r.id), spaceId);
|
||
return success(res, { messages: rows.map((r) => toMailMessage(r, labelsMap.get(String(r.id)) || [])) });
|
||
});
|
||
|
||
// ─── GET /spaces/:spaceId/mail/messages/:msgId ──────────────────────
|
||
|
||
router.get('/spaces/:spaceId/mail/messages/:msgId', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const id = Number(req.params.msgId);
|
||
if (!Number.isFinite(id)) return badRequest(res, 'Invalid message id');
|
||
|
||
// Opening a message marks it read (matches the UI `read` flag).
|
||
const row = await dbGet(
|
||
`UPDATE mail_messages SET is_read = true, updated_at = now()
|
||
WHERE id = ? AND space_id = ?
|
||
RETURNING *`,
|
||
[id, spaceId]
|
||
);
|
||
if (!row) return notFound(res, 'Message');
|
||
const labelsMap = await fetchLabelsMap([row.id], spaceId);
|
||
return success(res, { message: toMailMessage(row, labelsMap.get(String(row.id)) || []) });
|
||
});
|
||
|
||
// ─── GET /spaces/:spaceId/mail/messages/:msgId/ref ──────────────────
|
||
// ADR-158 §P7 — resolve a mail message to its compact chip shape (sender ·
|
||
// subject · date · attachment count) for embedding as a reference chip in a
|
||
// chat conversation. Space-scoped + authorized (ensureSpaceAccess), so a chip
|
||
// can only be built/re-resolved for a message the caller may actually read.
|
||
// Unlike GET …/:msgId this is a pure read — it does NOT mark the message read
|
||
// (building/rendering a chip must not silently flip the unread flag). 404 →
|
||
// the frontend renders a tombstone chip ("письмо удалено").
|
||
router.get('/spaces/:spaceId/mail/messages/:msgId/ref', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const id = Number(req.params.msgId);
|
||
if (!Number.isFinite(id)) return badRequest(res, 'Invalid message id');
|
||
|
||
const row = await dbGet(
|
||
`SELECT * FROM mail_messages WHERE id = ? AND space_id = ?`,
|
||
[id, spaceId]
|
||
);
|
||
if (!row) return notFound(res, 'Message');
|
||
return success(res, { ref: toMailRef(row) });
|
||
});
|
||
|
||
// ─── PATCH /spaces/:spaceId/mail/messages/:msgId ────────────────────
|
||
// ADR-158 §P1 — mutate a message's flags/placement in one call:
|
||
// { is_read?, starred?, folder? }
|
||
// Mark (un)read, star/unstar, and move folders — archive = folder:'archive',
|
||
// delete = folder:'trash' (a local move; an IMAP-side move/expunge is a later
|
||
// enhancement, same class as the Sent APPEND deferral). All fields optional;
|
||
// only the provided ones are written, so the UI can drive each button
|
||
// independently against one endpoint.
|
||
|
||
router.patch('/spaces/:spaceId/mail/messages/:msgId', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const id = Number(req.params.msgId);
|
||
if (!Number.isFinite(id)) return badRequest(res, 'Invalid message id');
|
||
|
||
const body = req.body || {};
|
||
const sets = [];
|
||
const params = [];
|
||
|
||
if ('is_read' in body) {
|
||
if (typeof body.is_read !== 'boolean') return badRequest(res, '`is_read` must be a boolean');
|
||
sets.push('is_read = ?');
|
||
params.push(body.is_read);
|
||
}
|
||
if ('starred' in body) {
|
||
if (typeof body.starred !== 'boolean') return badRequest(res, '`starred` must be a boolean');
|
||
sets.push('starred = ?');
|
||
params.push(body.starred);
|
||
}
|
||
if ('folder' in body) {
|
||
const folder = String(body.folder);
|
||
if (!FOLDER_IDS.has(folder)) return badRequest(res, `Unknown folder: ${folder}`);
|
||
sets.push('folder = ?');
|
||
params.push(folder);
|
||
}
|
||
if (sets.length === 0) {
|
||
return badRequest(res, 'Provide at least one of: is_read, starred, folder');
|
||
}
|
||
|
||
const row = await dbGet(
|
||
`UPDATE mail_messages SET ${sets.join(', ')}, updated_at = now()
|
||
WHERE id = ? AND space_id = ?
|
||
RETURNING *`,
|
||
[...params, id, spaceId]
|
||
);
|
||
if (!row) return notFound(res, 'Message');
|
||
return success(res, { message: toMailMessage(row) });
|
||
});
|
||
|
||
// ADR-158 §P4 — resolve `in_table` rule conditions to concrete value lists so
|
||
// the PURE evaluator (labelRules.js) can treat them as `in_list`. This is the
|
||
// ONLY side-effecting half of the rule engine.
|
||
//
|
||
// Security: the DISTINCT query is space-scoped via universal_tables→projects, so
|
||
// a label can only pull values from tables in its OWN space (`source.tableId` is
|
||
// validated as a positive int at save time but NOT trusted for cross-space
|
||
// reach — a foreign id simply resolves to zero rows). The column key travels as
|
||
// a BIND param (`data->>?`), never string-concatenated (SQLi-safe, mirrors
|
||
// AutomationTriggerService's `data->>? = ?`).
|
||
//
|
||
// Cost: one query per UNIQUE (table,column,filter) ref, run ONCE before the
|
||
// message loop — a sync stays O(messages)+O(refs), not O(messages × refs).
|
||
// Fail-closed: any error → empty list → the condition never matches, never 500s.
|
||
const IN_TABLE_MAX_VALUES = 5000; // ceiling on the value list one ref can pull
|
||
|
||
async function resolveTableRefValues(spaceId, refs) {
|
||
const valuesByKey = new Map();
|
||
for (const { key, source } of refs) {
|
||
try {
|
||
const params = [source.column, source.tableId, spaceId];
|
||
let filterSql = '';
|
||
if (source.filter) {
|
||
const like = source.filter.op === 'contains';
|
||
filterSql = like ? ' AND tr.data->>? ILIKE ?' : ' AND tr.data->>? = ?';
|
||
params.push(source.filter.column, like ? `%${source.filter.value}%` : source.filter.value);
|
||
}
|
||
const rows = await dbAll(
|
||
`SELECT DISTINCT tr.data->>? AS val
|
||
FROM table_rows tr
|
||
JOIN universal_tables ut ON tr.table_id = ut.id
|
||
JOIN projects p ON ut.project_id = p.id
|
||
WHERE tr.table_id = ? AND p.space_id = ?${filterSql}
|
||
LIMIT ${IN_TABLE_MAX_VALUES}`,
|
||
params
|
||
);
|
||
valuesByKey.set(
|
||
key,
|
||
rows
|
||
.map((r) => r.val)
|
||
.filter((v) => v != null && String(v).trim() !== '')
|
||
.map(String)
|
||
);
|
||
} catch (err) {
|
||
log.warn({ err, spaceId, ref: key }, 'mail in_table resolve failed; condition yields no match');
|
||
valuesByKey.set(key, []); // fail closed
|
||
}
|
||
}
|
||
return valuesByKey;
|
||
}
|
||
|
||
// ─── POST /spaces/:spaceId/mail/sync ────────────────────────────────
|
||
|
||
router.post('/spaces/:spaceId/mail/sync', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const folder = String(req.body?.folder || 'inbox');
|
||
if (!FOLDER_IDS.has(folder)) return badRequest(res, `Unknown folder: ${folder}`);
|
||
const limit = Math.min(Math.max(Number(req.body?.limit) || 50, 1), 200);
|
||
|
||
const conn = await loadImapConnector(spaceId, Number(req.body?.connector_id) || null);
|
||
if (!conn) return badRequest(res, 'No imap connector for this space');
|
||
|
||
let fetched;
|
||
try {
|
||
fetched = await imapFetch({ creds: conn.creds, folder, limit });
|
||
} catch (err) {
|
||
log.error({ err, spaceId, connectorId: conn.id, folder }, 'mail sync: imap fetch failed');
|
||
return error(res, 'IMAP_FETCH_FAILED', err?.message || 'IMAP fetch failed', 502);
|
||
}
|
||
|
||
const inbound = folder !== 'sent' && folder !== 'drafts';
|
||
|
||
// ADR-173 §B.3.1 — auto-labeling runs only on inbound folders and only against
|
||
// enabled labels that actually carry a rule. Loaded ONCE before the loop (not
|
||
// per message) so a sync is O(messages), not O(messages × labels).
|
||
let ruleLabels = inbound
|
||
? await dbAll(
|
||
`SELECT id, rules FROM mail_labels WHERE space_id = ? AND enabled = true`,
|
||
[spaceId]
|
||
)
|
||
: [];
|
||
|
||
// ADR-158 §P4 — pre-resolve any `in_table` conditions to live value lists
|
||
// ONCE, before the per-message loop (below). No in_table refs ⇒ zero extra
|
||
// queries and byte-identical behavior to the pre-P4 path.
|
||
if (ruleLabels.length) {
|
||
const refs = collectTableRefs(ruleLabels);
|
||
if (refs.length) {
|
||
const valuesByKey = await resolveTableRefValues(spaceId, refs);
|
||
ruleLabels = resolveLabels(ruleLabels, valuesByKey);
|
||
}
|
||
}
|
||
|
||
let synced = 0;
|
||
let linked = 0;
|
||
let labeled = 0;
|
||
for (const m of fetched) {
|
||
// RETURNING (xmax = 0) tells insert from ON CONFLICT update: a fresh tuple
|
||
// has xmax 0, an updated one carries the locking xid. We use it to fire
|
||
// rule-labeling ONLY on a genuine new message — a re-sync must not re-tag
|
||
// (which would resurrect a label the user manually removed). Verified on PG.
|
||
const up = await dbGet(
|
||
`INSERT INTO mail_messages
|
||
(space_id, connector_id, folder, imap_uid, message_id, from_name, from_address,
|
||
to_addresses, subject, preview, body_text, body_html, date, is_read, attachments)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, ?, ?, ?, ?, ?::jsonb)
|
||
ON CONFLICT (connector_id, folder, imap_uid) DO UPDATE SET
|
||
message_id = EXCLUDED.message_id,
|
||
from_name = EXCLUDED.from_name,
|
||
from_address = EXCLUDED.from_address,
|
||
to_addresses = EXCLUDED.to_addresses,
|
||
subject = EXCLUDED.subject,
|
||
preview = EXCLUDED.preview,
|
||
body_text = EXCLUDED.body_text,
|
||
body_html = EXCLUDED.body_html,
|
||
date = EXCLUDED.date,
|
||
-- Sticky read: a sync must never un-read a message. We mark read only
|
||
-- in our DB (we don't push \Seen back to IMAP), so EXCLUDED.is_read is
|
||
-- almost always false on re-sync. OR-merge keeps a local read read, and
|
||
-- still honors a server-side read made from another client. (ADR-158)
|
||
is_read = mail_messages.is_read OR EXCLUDED.is_read,
|
||
attachments = EXCLUDED.attachments,
|
||
updated_at = now()
|
||
RETURNING id, (xmax = 0) AS inserted`,
|
||
[
|
||
spaceId,
|
||
conn.id,
|
||
folder,
|
||
m.imap_uid ?? null,
|
||
m.message_id,
|
||
m.from_name,
|
||
m.from_address,
|
||
JSON.stringify(m.to_addresses || []),
|
||
m.subject,
|
||
m.preview,
|
||
m.body_text,
|
||
m.body_html,
|
||
m.date,
|
||
m.is_read,
|
||
JSON.stringify(m.attachments || []),
|
||
]
|
||
);
|
||
synced += 1;
|
||
|
||
// ADR-158 §P2 — auto-link an inbound reply back to its CRM row via the
|
||
// `[crm:<token>]` marker the outbound minted. Only for inbound folders, and
|
||
// only when the token resolves to an outbound row in *this* space that is
|
||
// itself linked. Idempotent: skips rows already linked.
|
||
if (inbound && up) {
|
||
const token = extractReplyToken(m.subject);
|
||
if (token) {
|
||
const origin = await dbGet(
|
||
`SELECT linked_table_id, linked_row_id FROM mail_messages
|
||
WHERE space_id = ? AND reply_token = ?
|
||
AND linked_table_id IS NOT NULL AND linked_row_id IS NOT NULL`,
|
||
[spaceId, token]
|
||
);
|
||
if (origin) {
|
||
// Copy ONLY the linkage onto the inbound reply. Do NOT copy reply_token:
|
||
// it is the outbound's join key and is guarded by the (space_id,
|
||
// reply_token) partial-unique index — writing the origin's token here
|
||
// would collide with the origin row and throw on the first real reply.
|
||
await dbRun(
|
||
`UPDATE mail_messages
|
||
SET linked_table_id = ?, linked_row_id = ?, updated_at = now()
|
||
WHERE id = ? AND linked_table_id IS NULL`,
|
||
[origin.linked_table_id, origin.linked_row_id, up.id]
|
||
);
|
||
linked += 1;
|
||
}
|
||
}
|
||
|
||
// ADR-173 §B.3.1 — rule-based auto-labeling, only on a genuinely new row.
|
||
// ON CONFLICT DO NOTHING keeps it idempotent and never disturbs a `manual`
|
||
// tag on the same message (that link, if any, is source='manual').
|
||
if (up.inserted && ruleLabels.length) {
|
||
for (const labelId of matchingLabelIds(ruleLabels, m)) {
|
||
await dbRun(
|
||
// RETURNING is explicit so PostgresAdapter.run() does NOT auto-append
|
||
// `RETURNING id` — this join table has a composite PK and no `id`
|
||
// column, so that append throws "column id does not exist".
|
||
`INSERT INTO mail_message_labels (message_id, label_id, source)
|
||
VALUES (?, ?, 'rule')
|
||
ON CONFLICT (message_id, label_id) DO NOTHING
|
||
RETURNING message_id`,
|
||
[up.id, labelId]
|
||
);
|
||
labeled += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return success(res, { synced, folder, linked, labeled });
|
||
});
|
||
|
||
// ─── POST /spaces/:spaceId/mail/send ────────────────────────────────
|
||
|
||
router.post('/spaces/:spaceId/mail/send', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const { subject, body, html } = req.body || {};
|
||
// `to` accepts a single string (back-compat) or a string[]; `cc`/`bcc` are
|
||
// optional lists → Reply-All / CC / BCC. Validation lives in one helper.
|
||
let toList, ccList, bccList;
|
||
try {
|
||
toList = normalizeRecipients(req.body?.to, { field: 'to', required: true });
|
||
ccList = normalizeRecipients(req.body?.cc, { field: 'cc' });
|
||
bccList = normalizeRecipients(req.body?.bcc, { field: 'bcc' });
|
||
} catch (err) {
|
||
if (err instanceof RecipientError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
if (!subject || typeof subject !== 'string') {
|
||
return badRequest(res, '`subject` is required');
|
||
}
|
||
|
||
// Inline attachments carried base64 in the JSON body. Kept small on purpose —
|
||
// this path avoids a separate upload endpoint; large files are a future
|
||
// upload-then-reference enhancement. Guard the decoded total so a big paste
|
||
// can't blow up the SMTP send or the request body.
|
||
let attachments;
|
||
try {
|
||
attachments = normalizeAttachments(req.body?.attachments);
|
||
} catch (err) {
|
||
if (err instanceof AttachmentError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
|
||
// ADR-158 §P2 — CRM-row linkage. Both ids must be present to link; a plain
|
||
// compose stays token-free. The reply token itself is minted at actual send
|
||
// time inside deliverMail() so a scheduled send embeds it in the *real*
|
||
// outgoing subject, so here we only carry the ids.
|
||
const linkedTableId = Number(req.body?.linked_table_id);
|
||
const linkedRowId = Number(req.body?.linked_row_id);
|
||
const linked = Number.isFinite(linkedTableId) && Number.isFinite(linkedRowId);
|
||
|
||
const conn = await loadImapConnector(spaceId, Number(req.body?.connector_id) || null);
|
||
if (!conn) return badRequest(res, 'No imap connector for this space');
|
||
|
||
// ADR-158 §P6 — optional deferred send. A future `scheduledAt` (ISO) queues
|
||
// the fully-composed message in mail_outbox instead of sending now; the
|
||
// minute-poll worker delivers it via the same deliverMail() path when due.
|
||
const rawSchedule = req.body?.scheduledAt ?? req.body?.scheduled_at;
|
||
if (rawSchedule != null && rawSchedule !== '') {
|
||
const when = new Date(rawSchedule);
|
||
if (Number.isNaN(when.getTime())) {
|
||
return badRequest(res, '`scheduledAt` must be a valid ISO timestamp');
|
||
}
|
||
if (when.getTime() <= Date.now()) {
|
||
return badRequest(res, '`scheduledAt` must be in the future');
|
||
}
|
||
// Store attachment bytes as canonical base64 (re-decoded by the worker).
|
||
const storedAttachments = attachments.map((a) => ({
|
||
filename: a.filename,
|
||
contentType: a.contentType,
|
||
content: a.content.toString('base64'),
|
||
}));
|
||
const row = await dbGet(
|
||
`INSERT INTO mail_outbox
|
||
(space_id, connector_id, to_addresses, cc_addresses, bcc_addresses,
|
||
subject, body, is_html, attachments, linked_table_id, linked_row_id,
|
||
scheduled_at, status, created_by)
|
||
VALUES (?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb, ?, ?, ?, 'pending', ?)
|
||
RETURNING *`,
|
||
[
|
||
spaceId,
|
||
conn.id,
|
||
JSON.stringify(toList),
|
||
JSON.stringify(ccList),
|
||
JSON.stringify(bccList),
|
||
subject,
|
||
body || '',
|
||
!!html,
|
||
JSON.stringify(storedAttachments),
|
||
linked ? linkedTableId : null,
|
||
linked ? linkedRowId : null,
|
||
when.toISOString(),
|
||
req.user?.id ?? null,
|
||
]
|
||
);
|
||
return created(res, { scheduled: toScheduled(row) });
|
||
}
|
||
|
||
// Immediate send via the shared path (SMTP + Sent copy + token minting).
|
||
let result;
|
||
try {
|
||
result = await deliverMail({
|
||
spaceId,
|
||
conn,
|
||
to: toList,
|
||
cc: ccList,
|
||
bcc: bccList,
|
||
subject,
|
||
body,
|
||
html: !!html,
|
||
attachments,
|
||
linkedTableId: linked ? linkedTableId : null,
|
||
linkedRowId: linked ? linkedRowId : null,
|
||
});
|
||
} catch (err) {
|
||
if (err instanceof MailSendError) return badRequest(res, err.message);
|
||
log.error({ err, spaceId, connectorId: conn.id }, 'mail send: SMTP failed');
|
||
return error(res, 'SMTP_SEND_FAILED', err?.message || 'SMTP send failed', 502);
|
||
}
|
||
|
||
return success(res, {
|
||
messageId: result.info?.messageId || null,
|
||
replyToken: result.replyToken,
|
||
linkedTableId: linked ? linkedTableId : null,
|
||
linkedRowId: linked ? linkedRowId : null,
|
||
});
|
||
});
|
||
|
||
// ─── POST /spaces/:spaceId/mail/drafts ──────────────────────────────
|
||
// ADR-158 §P6 — save the composer contents as a Drafts message without sending.
|
||
// Persists a local mail_messages row (folder='drafts'); an optional `id` in the
|
||
// body re-saves that existing draft in place (Gmail keeps one draft, not a new
|
||
// row per keystroke-save) instead of spawning duplicates. Attachment metadata
|
||
// only, same as the Sent copy — an IMAP-side APPEND to \Drafts and byte-perfect
|
||
// draft-resend are deferred enhancements (same class as the Sent APPEND).
|
||
router.post('/spaces/:spaceId/mail/drafts', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const { subject, body, html } = req.body || {};
|
||
// A draft can be half-finished, so recipients are NOT required here (unlike
|
||
// /send) — you save a draft precisely because it isn't ready to go out.
|
||
let toList, ccList, bccList;
|
||
try {
|
||
toList = normalizeRecipients(req.body?.to, { field: 'to' });
|
||
ccList = normalizeRecipients(req.body?.cc, { field: 'cc' });
|
||
bccList = normalizeRecipients(req.body?.bcc, { field: 'bcc' });
|
||
} catch (err) {
|
||
if (err instanceof RecipientError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
|
||
let attachments;
|
||
try {
|
||
attachments = normalizeAttachments(req.body?.attachments);
|
||
} catch (err) {
|
||
if (err instanceof AttachmentError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
const attachmentMeta = attachments.map((a) => ({
|
||
filename: a.filename,
|
||
contentType: a.contentType,
|
||
size: a.content.length,
|
||
}));
|
||
|
||
const conn = await loadImapConnector(spaceId, Number(req.body?.connector_id) || null);
|
||
if (!conn) return badRequest(res, 'No imap connector for this space');
|
||
|
||
const linkedTableId = Number(req.body?.linked_table_id);
|
||
const linkedRowId = Number(req.body?.linked_row_id);
|
||
const linked = Number.isFinite(linkedTableId) && Number.isFinite(linkedRowId);
|
||
const preview = bodyToPreview(body, html);
|
||
const bodyText = html ? null : body || '';
|
||
const bodyHtml = html ? body || '' : null;
|
||
|
||
const draftId = Number(req.body?.id);
|
||
let row;
|
||
if (Number.isFinite(draftId)) {
|
||
// Re-save an existing draft in place (only if it's still a draft in this space).
|
||
row = await dbGet(
|
||
`UPDATE mail_messages
|
||
SET to_addresses = ?::jsonb, subject = ?, preview = ?, body_text = ?, body_html = ?,
|
||
attachments = ?::jsonb, linked_table_id = ?, linked_row_id = ?, date = now(), updated_at = now()
|
||
WHERE id = ? AND space_id = ? AND folder = 'drafts'
|
||
RETURNING *`,
|
||
[
|
||
JSON.stringify(toList),
|
||
subject || '',
|
||
preview,
|
||
bodyText,
|
||
bodyHtml,
|
||
JSON.stringify(attachmentMeta),
|
||
linked ? linkedTableId : null,
|
||
linked ? linkedRowId : null,
|
||
draftId,
|
||
spaceId,
|
||
]
|
||
);
|
||
if (!row) return notFound(res, 'Draft');
|
||
} else {
|
||
row = await dbGet(
|
||
`INSERT INTO mail_messages
|
||
(space_id, connector_id, folder, from_name, from_address,
|
||
to_addresses, subject, preview, body_text, body_html, date, is_read,
|
||
attachments, linked_table_id, linked_row_id)
|
||
VALUES (?, ?, 'drafts', ?, ?, ?::jsonb, ?, ?, ?, ?, now(), true, ?::jsonb, ?, ?)
|
||
RETURNING *`,
|
||
[
|
||
spaceId,
|
||
conn.id,
|
||
conn.creds?.username || '',
|
||
conn.creds?.username || '',
|
||
JSON.stringify(toList),
|
||
subject || '',
|
||
preview,
|
||
bodyText,
|
||
bodyHtml,
|
||
JSON.stringify(attachmentMeta),
|
||
linked ? linkedTableId : null,
|
||
linked ? linkedRowId : null,
|
||
]
|
||
);
|
||
}
|
||
|
||
return created(res, { message: toMailMessage(row) });
|
||
});
|
||
|
||
// ─── GET /spaces/:spaceId/mail/scheduled ────────────────────────────
|
||
// ADR-158 §P6 — the scheduled-send queue for the composer panel (analog of the
|
||
// AI-chat scheduled-messages table). Lists this space's outbox, soonest-first.
|
||
// `?status=` optionally narrows (default: everything not yet terminally sent —
|
||
// i.e. pending/sending/failed — since those are what the panel acts on).
|
||
router.get('/spaces/:spaceId/mail/scheduled', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 500);
|
||
const status = typeof req.query.status === 'string' ? req.query.status : '';
|
||
let rows;
|
||
if (status) {
|
||
rows = await dbAll(
|
||
`SELECT * FROM mail_outbox WHERE space_id = ? AND status = ?
|
||
ORDER BY scheduled_at ASC LIMIT ?`,
|
||
[spaceId, status, limit]
|
||
);
|
||
} else {
|
||
rows = await dbAll(
|
||
`SELECT * FROM mail_outbox
|
||
WHERE space_id = ? AND status IN ('pending', 'sending', 'failed')
|
||
ORDER BY scheduled_at ASC LIMIT ?`,
|
||
[spaceId, limit]
|
||
);
|
||
}
|
||
return success(res, { scheduled: rows.map(toScheduled) });
|
||
});
|
||
|
||
// ─── DELETE /spaces/:spaceId/mail/scheduled/:id ─────────────────────
|
||
// Cancel a scheduled send before it fires. Only a still-`pending` row can be
|
||
// canceled — once the worker has claimed it (`sending`) or it has been `sent`,
|
||
// cancellation is a no-op 409 so we never claim to have stopped an email that
|
||
// already went out.
|
||
router.delete('/spaces/:spaceId/mail/scheduled/:id', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const id = Number(req.params.id);
|
||
if (!Number.isFinite(id)) return badRequest(res, 'Invalid scheduled id');
|
||
|
||
const row = await dbGet(
|
||
`UPDATE mail_outbox SET status = 'canceled', updated_at = now()
|
||
WHERE id = ? AND space_id = ? AND status = 'pending'
|
||
RETURNING id`,
|
||
[id, spaceId]
|
||
);
|
||
if (!row) {
|
||
// Distinguish "not here" from "too late to cancel".
|
||
const existing = await dbGet('SELECT status FROM mail_outbox WHERE id = ? AND space_id = ?', [id, spaceId]);
|
||
if (!existing) return notFound(res, 'Scheduled message');
|
||
return error(res, 'NOT_CANCELABLE', `Cannot cancel a message that is ${existing.status}`, 409);
|
||
}
|
||
return success(res, { canceled: true, id });
|
||
});
|
||
|
||
// ─── PATCH /spaces/:spaceId/mail/scheduled/:id ──────────────────────
|
||
// ADR-158 §P6 — edit a still-`pending` queued send in place: recipients, subject,
|
||
// body and/or the fire time. Editing in place (vs. cancel-then-recreate) keeps the
|
||
// row id AND its stored attachment bytes — the composer never has the bytes to
|
||
// re-upload, so a recreate would silently drop them. Only `pending` is editable:
|
||
// once the worker has claimed it (`sending`/`sent`) we refuse (409), same honesty
|
||
// contract as cancel.
|
||
router.patch('/spaces/:spaceId/mail/scheduled/:id', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const id = Number(req.params.id);
|
||
if (!Number.isFinite(id)) return badRequest(res, 'Invalid scheduled id');
|
||
|
||
const { subject, body, html } = req.body || {};
|
||
let toList, ccList, bccList;
|
||
try {
|
||
toList = normalizeRecipients(req.body?.to, { field: 'to', required: true });
|
||
ccList = normalizeRecipients(req.body?.cc, { field: 'cc' });
|
||
bccList = normalizeRecipients(req.body?.bcc, { field: 'bcc' });
|
||
} catch (err) {
|
||
if (err instanceof RecipientError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
if (!subject || typeof subject !== 'string') {
|
||
return badRequest(res, '`subject` is required');
|
||
}
|
||
|
||
const rawSchedule = req.body?.scheduledAt ?? req.body?.scheduled_at;
|
||
if (rawSchedule == null || rawSchedule === '') {
|
||
return badRequest(res, '`scheduledAt` is required');
|
||
}
|
||
const when = new Date(rawSchedule);
|
||
if (Number.isNaN(when.getTime())) {
|
||
return badRequest(res, '`scheduledAt` must be a valid ISO timestamp');
|
||
}
|
||
if (when.getTime() <= Date.now()) {
|
||
return badRequest(res, '`scheduledAt` must be in the future');
|
||
}
|
||
|
||
// New attachments (if any) are APPENDED to the stored bytes — the composer only
|
||
// ever holds files the user just picked, not the originals, so a replace would
|
||
// drop what's already queued. Empty → the stored `attachments` jsonb is untouched.
|
||
let newAttachments;
|
||
try {
|
||
newAttachments = normalizeAttachments(req.body?.attachments);
|
||
} catch (err) {
|
||
if (err instanceof AttachmentError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
const appended = newAttachments.map((a) => ({
|
||
filename: a.filename,
|
||
contentType: a.contentType,
|
||
content: a.content.toString('base64'),
|
||
}));
|
||
|
||
const row = await dbGet(
|
||
`UPDATE mail_outbox
|
||
SET to_addresses = ?::jsonb, cc_addresses = ?::jsonb, bcc_addresses = ?::jsonb,
|
||
subject = ?, body = ?, is_html = ?, scheduled_at = ?,
|
||
attachments = COALESCE(attachments, '[]'::jsonb) || ?::jsonb, updated_at = now()
|
||
WHERE id = ? AND space_id = ? AND status = 'pending'
|
||
RETURNING *`,
|
||
[
|
||
JSON.stringify(toList),
|
||
JSON.stringify(ccList),
|
||
JSON.stringify(bccList),
|
||
subject,
|
||
body || '',
|
||
!!html,
|
||
when.toISOString(),
|
||
JSON.stringify(appended),
|
||
id,
|
||
spaceId,
|
||
]
|
||
);
|
||
if (!row) {
|
||
// Distinguish "not here" from "too late to edit" — mirror the cancel contract.
|
||
const existing = await dbGet('SELECT status FROM mail_outbox WHERE id = ? AND space_id = ?', [id, spaceId]);
|
||
if (!existing) return notFound(res, 'Scheduled message');
|
||
return error(res, 'NOT_EDITABLE', `Cannot edit a message that is ${existing.status}`, 409);
|
||
}
|
||
return success(res, { scheduled: toScheduled(row) });
|
||
});
|
||
|
||
// ─── PUT /spaces/:spaceId/mail/messages/:msgId/link ─────────────────
|
||
// ADR-158 §P2 — attach-to-row: link any message to a CRM row (deal/lead/ticket/…)
|
||
// or clear the link (both ids null). Manual counterpart to the reply-token
|
||
// auto-link; lets an operator file an arbitrary email onto a record.
|
||
|
||
router.put('/spaces/:spaceId/mail/messages/:msgId/link', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const id = Number(req.params.msgId);
|
||
if (!Number.isFinite(id)) return badRequest(res, 'Invalid message id');
|
||
|
||
const rawTable = req.body?.linked_table_id;
|
||
const rawRow = req.body?.linked_row_id;
|
||
const clearing = rawTable == null && rawRow == null;
|
||
const tableId = Number(rawTable);
|
||
const rowId = Number(rawRow);
|
||
if (!clearing && !(Number.isFinite(tableId) && Number.isFinite(rowId))) {
|
||
return badRequest(res, 'linked_table_id and linked_row_id must both be numbers, or both null to unlink');
|
||
}
|
||
|
||
const row = await dbGet(
|
||
`UPDATE mail_messages SET linked_table_id = ?, linked_row_id = ?, updated_at = now()
|
||
WHERE id = ? AND space_id = ?
|
||
RETURNING *`,
|
||
[clearing ? null : tableId, clearing ? null : rowId, id, spaceId]
|
||
);
|
||
if (!row) return notFound(res, 'Message');
|
||
return success(res, { message: toMailMessage(row) });
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// ADR-158 §P4 (ex-ADR-173) — Mail Labels & Rules API (B.4)
|
||
// A label is a tag, not a folder move. Space-scoped + RBAC via
|
||
// ensureSpaceAccess like every route above. `rules` is validated at this
|
||
// boundary by normalizeRules (400 on bad shape) so a label can't be saved
|
||
// with a rule that would silently never fire.
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
// Hard cap for the opt-in backfill so a huge mailbox can't turn one click into
|
||
// an unbounded scan. Surfaced to the caller as `capped` when hit.
|
||
const LABEL_BACKFILL_MAX = 5000;
|
||
|
||
// ─── GET /spaces/:spaceId/mail/labels ───────────────────────────────
|
||
// List a space's labels with per-label message counts (rail badges). One
|
||
// grouped LEFT JOIN — no N+1 over labels.
|
||
router.get('/spaces/:spaceId/mail/labels', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const rows = await dbAll(
|
||
`SELECT l.*, COUNT(mml.message_id) AS message_count
|
||
FROM mail_labels l
|
||
LEFT JOIN mail_message_labels mml ON mml.label_id = l.id
|
||
WHERE l.space_id = ?
|
||
GROUP BY l.id
|
||
ORDER BY l.order_index, l.id`,
|
||
[spaceId]
|
||
);
|
||
return success(res, { labels: rows.map(toLabel) });
|
||
});
|
||
|
||
// ─── POST /spaces/:spaceId/mail/labels ──────────────────────────────
|
||
// Create a label. `{ name (req), icon?, color?, show_in_toolbar?, rules?,
|
||
// order_index?, enabled? }`. Empty/absent `rules` ⇒ manual-only label.
|
||
router.post('/spaces/:spaceId/mail/labels', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const b = req.body || {};
|
||
const name = typeof b.name === 'string' ? b.name.trim() : '';
|
||
if (!name) return badRequest(res, '`name` is required');
|
||
|
||
let rules;
|
||
try {
|
||
rules = normalizeRules(b.rules);
|
||
} catch (err) {
|
||
if (err instanceof RuleError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
|
||
const row = await dbGet(
|
||
`INSERT INTO mail_labels
|
||
(space_id, name, icon, color, show_in_toolbar, rules, order_index, enabled)
|
||
VALUES (?, ?, ?, ?, ?, ?::jsonb, ?, ?)
|
||
RETURNING *`,
|
||
[
|
||
spaceId,
|
||
name,
|
||
b.icon ?? null,
|
||
b.color ?? null,
|
||
!!b.show_in_toolbar,
|
||
JSON.stringify(rules),
|
||
Number.isFinite(Number(b.order_index)) ? Number(b.order_index) : 0,
|
||
b.enabled === undefined ? true : !!b.enabled,
|
||
]
|
||
);
|
||
return created(res, { label: toLabel(row) });
|
||
});
|
||
|
||
// ─── PATCH /spaces/:spaceId/mail/labels/:labelId ────────────────────
|
||
// Update any subset of fields. `rules` re-validated when present.
|
||
router.patch('/spaces/:spaceId/mail/labels/:labelId', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const labelId = Number(req.params.labelId);
|
||
const existing = await loadSpaceLabel(spaceId, labelId);
|
||
if (!existing) return notFound(res, 'Label');
|
||
|
||
const b = req.body || {};
|
||
const sets = [];
|
||
const params = [];
|
||
|
||
if ('name' in b) {
|
||
const n = typeof b.name === 'string' ? b.name.trim() : '';
|
||
if (!n) return badRequest(res, '`name` cannot be empty');
|
||
sets.push('name = ?');
|
||
params.push(n);
|
||
}
|
||
if ('icon' in b) {
|
||
sets.push('icon = ?');
|
||
params.push(b.icon ?? null);
|
||
}
|
||
if ('color' in b) {
|
||
sets.push('color = ?');
|
||
params.push(b.color ?? null);
|
||
}
|
||
if ('show_in_toolbar' in b) {
|
||
if (typeof b.show_in_toolbar !== 'boolean') return badRequest(res, '`show_in_toolbar` must be a boolean');
|
||
sets.push('show_in_toolbar = ?');
|
||
params.push(b.show_in_toolbar);
|
||
}
|
||
if ('enabled' in b) {
|
||
if (typeof b.enabled !== 'boolean') return badRequest(res, '`enabled` must be a boolean');
|
||
sets.push('enabled = ?');
|
||
params.push(b.enabled);
|
||
}
|
||
if ('order_index' in b) {
|
||
const oi = Number(b.order_index);
|
||
if (!Number.isFinite(oi)) return badRequest(res, '`order_index` must be a number');
|
||
sets.push('order_index = ?');
|
||
params.push(oi);
|
||
}
|
||
if ('rules' in b) {
|
||
let rules;
|
||
try {
|
||
rules = normalizeRules(b.rules);
|
||
} catch (err) {
|
||
if (err instanceof RuleError) return badRequest(res, err.message);
|
||
throw err;
|
||
}
|
||
sets.push('rules = ?::jsonb');
|
||
params.push(JSON.stringify(rules));
|
||
}
|
||
if (sets.length === 0) return badRequest(res, 'No updatable fields provided');
|
||
|
||
const row = await dbGet(
|
||
`UPDATE mail_labels SET ${sets.join(', ')}, updated_at = now()
|
||
WHERE id = ? AND space_id = ?
|
||
RETURNING *`,
|
||
[...params, labelId, spaceId]
|
||
);
|
||
return success(res, { label: toLabel(row) });
|
||
});
|
||
|
||
// ─── DELETE /spaces/:spaceId/mail/labels/:labelId ───────────────────
|
||
// Delete a label; its join rows go with it (FK ON DELETE CASCADE).
|
||
router.delete('/spaces/:spaceId/mail/labels/:labelId', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const labelId = Number(req.params.labelId);
|
||
const existing = await loadSpaceLabel(spaceId, labelId);
|
||
if (!existing) return notFound(res, 'Label');
|
||
|
||
await dbRun('DELETE FROM mail_labels WHERE id = ? AND space_id = ?', [labelId, spaceId]);
|
||
return success(res, { deleted: true, id: labelId });
|
||
});
|
||
|
||
// ─── POST /spaces/:spaceId/mail/labels/:labelId/apply ───────────────
|
||
// Backfill (ADR-158 §P4 B.3.2): re-evaluate this label's rules over existing
|
||
// space messages and sync ONLY its `rule`-sourced links — add new matches,
|
||
// drop stale ones — never touching a human's `manual` tag. Bounded by
|
||
// LABEL_BACKFILL_MAX; `capped:true` signals the scan hit the ceiling.
|
||
router.post('/spaces/:spaceId/mail/labels/:labelId/apply', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const labelId = Number(req.params.labelId);
|
||
const label = await loadSpaceLabel(spaceId, labelId);
|
||
if (!label) return notFound(res, 'Label');
|
||
|
||
const rawRules = typeof label.rules === 'string' ? JSON.parse(label.rules) : label.rules;
|
||
|
||
// ADR-158 §P4 — resolve in_table refs ONCE, then match every message against
|
||
// the concrete value list. No refs ⇒ rules pass through untouched.
|
||
const refs = collectTableRefs([{ rules: rawRules }]);
|
||
const rules = refs.length
|
||
? resolveTableRefs(rawRules, await resolveTableRefValues(spaceId, refs))
|
||
: rawRules;
|
||
|
||
const rows = await dbAll(
|
||
`SELECT id, from_name, from_address, to_addresses, subject, body_text, body_html, preview
|
||
FROM mail_messages
|
||
WHERE space_id = ?
|
||
ORDER BY id DESC
|
||
LIMIT ?`,
|
||
[spaceId, LABEL_BACKFILL_MAX]
|
||
);
|
||
|
||
const matchIds = [];
|
||
for (const r of rows) {
|
||
if (matchesRules(rules, r)) matchIds.push(r.id);
|
||
}
|
||
|
||
// Drop stale rule-links (matched before, not now); manual links untouched.
|
||
if (matchIds.length) {
|
||
await dbRun(
|
||
`DELETE FROM mail_message_labels
|
||
WHERE label_id = ? AND source = 'rule' AND message_id <> ALL(?::bigint[])`,
|
||
[labelId, matchIds]
|
||
);
|
||
for (const mid of matchIds) {
|
||
await dbRun(
|
||
// Explicit RETURNING — see the sync-hook note: this composite-PK join
|
||
// table has no `id`, so the adapter's auto `RETURNING id` would throw.
|
||
`INSERT INTO mail_message_labels (message_id, label_id, source)
|
||
VALUES (?, ?, 'rule')
|
||
ON CONFLICT (message_id, label_id) DO NOTHING
|
||
RETURNING message_id`,
|
||
[mid, labelId]
|
||
);
|
||
}
|
||
} else {
|
||
await dbRun(
|
||
`DELETE FROM mail_message_labels WHERE label_id = ? AND source = 'rule'`,
|
||
[labelId]
|
||
);
|
||
}
|
||
|
||
return success(res, {
|
||
applied: matchIds.length,
|
||
scanned: rows.length,
|
||
capped: rows.length >= LABEL_BACKFILL_MAX,
|
||
});
|
||
});
|
||
|
||
// ─── POST /spaces/:spaceId/mail/messages/:msgId/labels ──────────────
|
||
// Manual tag. Body `{ label_id }`. Idempotent upsert; if a `rule` link already
|
||
// exists it is promoted to `manual` so a later rule run can't drop what the
|
||
// user explicitly set.
|
||
router.post('/spaces/:spaceId/mail/messages/:msgId/labels', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const msgId = Number(req.params.msgId);
|
||
if (!Number.isFinite(msgId)) return badRequest(res, 'Invalid message id');
|
||
|
||
const labelId = Number(req.body?.label_id);
|
||
const label = await loadSpaceLabel(spaceId, labelId);
|
||
if (!label) return notFound(res, 'Label');
|
||
|
||
const msg = await dbGet('SELECT id FROM mail_messages WHERE id = ? AND space_id = ?', [msgId, spaceId]);
|
||
if (!msg) return notFound(res, 'Message');
|
||
|
||
await dbRun(
|
||
// Explicit RETURNING — this composite-PK join table has no `id`, so
|
||
// PostgresAdapter.run()'s auto `RETURNING id` would throw (see sync hook).
|
||
`INSERT INTO mail_message_labels (message_id, label_id, source)
|
||
VALUES (?, ?, 'manual')
|
||
ON CONFLICT (message_id, label_id) DO UPDATE SET source = 'manual'
|
||
RETURNING message_id`,
|
||
[msgId, labelId]
|
||
);
|
||
return success(res, { tagged: true, messageId: String(msgId), labelId });
|
||
});
|
||
|
||
// ─── DELETE /spaces/:spaceId/mail/messages/:msgId/labels/:labelId ───
|
||
// Manual untag. The USING join scopes the delete to this space, so a caller
|
||
// can't strip a label off another space's message by id-guessing.
|
||
router.delete('/spaces/:spaceId/mail/messages/:msgId/labels/:labelId', async (req, res) => {
|
||
const spaceId = Number(req.params.spaceId);
|
||
const space = await ensureSpaceAccess(req, res, spaceId);
|
||
if (!space) return;
|
||
|
||
const msgId = Number(req.params.msgId);
|
||
const labelId = Number(req.params.labelId);
|
||
if (!Number.isFinite(msgId) || !Number.isFinite(labelId)) return badRequest(res, 'Invalid id');
|
||
|
||
await dbRun(
|
||
`DELETE FROM mail_message_labels mml
|
||
USING mail_messages m
|
||
WHERE mml.message_id = m.id AND m.space_id = ?
|
||
AND mml.message_id = ? AND mml.label_id = ?`,
|
||
[spaceId, msgId, labelId]
|
||
);
|
||
return success(res, { untagged: true, messageId: String(msgId), labelId });
|
||
});
|
||
|
||
export default router;
|