godcrm/backend/routes/v3/mail.js
GOD CRM Release 065f2edd23
Some checks are pending
CI / Lint / Typecheck / Test / Build (push) Waiting to run
CI / PostgreSQL Integration Tests (push) Waiting to run
GOD CRM — public scrubbed snapshot (2026-08-30)
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.
2026-08-30 15:13:28 +03:00

1281 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;