Skip to content

Commit c854f92

Browse files
os-zhuangclaude
andauthored
feat(email): drain queued sys_email rows so app inserts actually deliver (#2002)
Apps that can only `api.write` (e.g. sandboxed action bodies, which expose no `api.email`) cannot reach the email service — the only thing they can do is INSERT a `sys_email` row. Until now such rows sat at `status:'queued'` forever: declared-but-never-delivered. The transport (and the queued→sent transition) only ran inside `EmailService.send()`, which a raw insert never invokes. Treat a `sys_email` row inserted as `status:'queued'` with no `message_id` as an OUTBOX entry: an afterInsert hook delivers it through the live transport and finalizes the same row in place (sent + message_id + sent_at, or failed + error). Delivery is deferred past the insert so transport I/O never runs inside the insert's transaction, and the row is re-read + re-checked under system context before sending (idempotent against concurrent drains). Rows that the service's own `send()` inserts are tagged managed (EmailService.isServiceManaged) and skipped by the hook, so they are delivered exactly once by send() — never double-sent. - email-service.ts: managedRowIds guard; extract deliverNormalized() shared by send() and the new deliverPersistedRow(); rowToNormalized() helper. - email-plugin.ts: register the sys_email afterInsert drain hook. - tests: managed-guard active at insert moment; deliverPersistedRow delivers without inserting a 2nd row + fails cleanly on bodyless rows; rowToNormalized. Verified empirically in HotCRM: a raw INSERT of a queued sys_email now transitions queued→sent with a transport message_id within ~600ms; before, the identical insert stayed queued forever. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 36b738f commit c854f92

3 files changed

Lines changed: 243 additions & 8 deletions

File tree

packages/plugins/plugin-email/src/email-plugin.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,65 @@ export class EmailServicePlugin implements Plugin {
271271
this.service.setTemplateLoader(templateLoader);
272272
ctx.logger.info('EmailServicePlugin: sys_email persistence + template loader enabled');
273273

274+
// ── sys_email OUTBOX DRAIN (afterInsert) ─────────────────────────
275+
// Apps that can only `api.write` (e.g. sandboxed action bodies, which
276+
// expose no `api.email`) cannot reach the email service directly — the
277+
// only thing they CAN do is INSERT a sys_email row. Treat such a row,
278+
// inserted as `status:'queued'` with no `message_id`, as an outbox
279+
// entry: deliver it through the live transport, then finalize the SAME
280+
// row in place (`sent`/`failed`). Without this, those rows sat at
281+
// `queued` forever (declared-but-never-delivered).
282+
//
283+
// Rows that the service's own `send()` inserts are marked managed (see
284+
// EmailService.isServiceManaged) and skipped here, so they are
285+
// delivered exactly once by `send()` — never double-sent by the hook.
286+
if (persistence && typeof (engine as any).registerHook === 'function') {
287+
const svc = this.service;
288+
const DRAIN_PKG = 'com.objectstack.service.email.drain';
289+
if (typeof (engine as any).unregisterHooksByPackage === 'function') {
290+
(engine as any).unregisterHooksByPackage(DRAIN_PKG);
291+
}
292+
(engine as any).registerHook(
293+
'afterInsert',
294+
async (hookCtx: any) => {
295+
try {
296+
if (hookCtx?.object !== 'sys_email') return;
297+
const row = hookCtx?.result;
298+
if (!row || typeof row !== 'object') return;
299+
if (row.status !== 'queued' || row.message_id) return;
300+
const rowId = row.id != null ? String(row.id) : '';
301+
if (!rowId || svc.isServiceManaged(rowId)) return;
302+
// Defer past the current insert op: transport.send is network
303+
// I/O and must not run inside the insert's transaction, and the
304+
// row must be committed before we update it. Re-read under
305+
// system context to get the full row + re-check it is still an
306+
// undelivered queued entry (idempotent against concurrent drains).
307+
setTimeout(() => {
308+
void (async () => {
309+
try {
310+
const rows = await (engine as any).find('sys_email', {
311+
where: { id: rowId },
312+
limit: 1,
313+
context: SYSTEM_CTX,
314+
});
315+
const fresh = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0];
316+
const target = fresh ?? row;
317+
if (target.status !== 'queued' || target.message_id) return;
318+
await svc.deliverPersistedRow(target);
319+
} catch (err: any) {
320+
ctx.logger.warn(`EmailServicePlugin: outbox drain failed for ${rowId}: ${err?.message ?? err}`);
321+
}
322+
})();
323+
}, 0);
324+
} catch (err: any) {
325+
ctx.logger.warn(`EmailServicePlugin: outbox drain hook error: ${err?.message ?? err}`);
326+
}
327+
},
328+
{ packageId: DRAIN_PKG },
329+
);
330+
ctx.logger.info('EmailServicePlugin: sys_email outbox drain hook installed');
331+
}
332+
274333
// Bind 'email.send.async' queue subscriber for durable, retry-on-failure delivery.
275334
// Producers: `queue.publish('email.send.async', sendInput, { maxAttempts: 5, backoff: {...} })`
276335
// The queue handles retry / DLQ via sys_job_queue.

packages/plugins/plugin-email/src/email-service.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
LogTransport,
77
formatAddress,
88
normalizeMessage,
9+
rowToNormalized,
910
type EmailPersistence,
1011
} from './email-service.js';
1112

@@ -139,4 +140,80 @@ describe('EmailService', () => {
139140
expect(res.status).toBe('sent');
140141
expect(warn).toHaveBeenCalledWith(expect.stringContaining('persist failed'), expect.any(Object));
141142
});
143+
144+
// ── Outbox-drain support (sys_email afterInsert) ───────────────────
145+
it('marks its own row managed during send() so the drain hook skips it', async () => {
146+
// The persistence.insert callback fires at exactly the moment the
147+
// afterInsert drain hook would fire — assert the row is flagged managed
148+
// there, and unflagged once send() resolves.
149+
let managedAtInsert: boolean | undefined;
150+
let insertedId: string | undefined;
151+
const transport = { send: vi.fn(async () => ({ messageId: '<m@x>' })) };
152+
let svc!: EmailService;
153+
const persistence: EmailPersistence = {
154+
async insert(row) {
155+
insertedId = String(row.id);
156+
managedAtInsert = svc.isServiceManaged(insertedId);
157+
return { id: row.id };
158+
},
159+
async update() { /* noop */ },
160+
};
161+
svc = new EmailService({ transport, defaultFrom: 'no@reply.com', persistence });
162+
const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x' });
163+
expect(res.status).toBe('sent');
164+
expect(managedAtInsert).toBe(true); // hook would skip it
165+
expect(svc.isServiceManaged(insertedId!)).toBe(false); // cleared after send
166+
});
167+
168+
it('deliverPersistedRow delivers an existing row WITHOUT inserting a new one', async () => {
169+
const transport = { send: vi.fn(async () => ({ messageId: '<drained@x>' })) };
170+
const { p, rows } = makePersistence();
171+
const insertSpy = vi.spyOn(p, 'insert');
172+
const svc = new EmailService({ transport, persistence: p });
173+
// Simulate an app-inserted outbox row.
174+
const row = {
175+
id: 'row-1', status: 'queued', from_address: 'no@reply.com',
176+
to_addresses: 'a@b.com, c@d.com', subject: 'Drain me', body_text: 'hello',
177+
};
178+
rows.set(row.id, { ...row });
179+
const res = await svc.deliverPersistedRow(row);
180+
expect(res).toMatchObject({ id: 'row-1', status: 'sent', messageId: '<drained@x>' });
181+
expect(transport.send).toHaveBeenCalledTimes(1);
182+
expect(transport.send).toHaveBeenCalledWith(expect.objectContaining({
183+
to: ['a@b.com', 'c@d.com'], from: 'no@reply.com', subject: 'Drain me', text: 'hello',
184+
}));
185+
expect(insertSpy).not.toHaveBeenCalled(); // never inserts a 2nd row
186+
expect(rows.get('row-1')).toMatchObject({ status: 'sent', message_id: '<drained@x>' });
187+
});
188+
189+
it('deliverPersistedRow marks the row failed when it lacks a body', async () => {
190+
const transport = { send: vi.fn(async () => ({ messageId: '<x>' })) };
191+
const { p, rows } = makePersistence();
192+
const svc = new EmailService({ transport, persistence: p });
193+
rows.set('row-2', { id: 'row-2', status: 'queued', from_address: 'a@b.com', to_addresses: 'c@d.com', subject: 'No body' });
194+
const res = await svc.deliverPersistedRow({ id: 'row-2', from_address: 'a@b.com', to_addresses: 'c@d.com', subject: 'No body' });
195+
expect(res.status).toBe('failed');
196+
expect(transport.send).not.toHaveBeenCalled();
197+
expect(rows.get('row-2')).toMatchObject({ status: 'failed' });
198+
});
199+
});
200+
201+
describe('rowToNormalized', () => {
202+
it('reconstructs a message from persisted columns', () => {
203+
const msg = rowToNormalized({
204+
to_addresses: 'a@b.com, "B" <b@c.com>', from_address: 'no@reply.com',
205+
subject: 'Hi', body_text: 'text', body_html: '<p>html</p>',
206+
cc_addresses: 'cc@x.com', bcc_addresses: 'bcc@x.com', reply_to: 'r@x.com',
207+
});
208+
expect(msg).toEqual({
209+
to: ['a@b.com', '"B" <b@c.com>'], from: 'no@reply.com', subject: 'Hi',
210+
text: 'text', html: '<p>html</p>', cc: ['cc@x.com'], bcc: ['bcc@x.com'], replyTo: 'r@x.com',
211+
});
212+
});
213+
it('throws on missing to/from/subject/body', () => {
214+
expect(() => rowToNormalized({ from_address: 'a@b.com', subject: 'x', body_text: 'y' })).toThrow(/to_addresses/);
215+
expect(() => rowToNormalized({ to_addresses: 'a@b.com', subject: 'x', body_text: 'y' })).toThrow(/from_address/);
216+
expect(() => rowToNormalized({ to_addresses: 'a@b.com', from_address: 'c@d.com', body_text: 'y' })).toThrow(/subject/);
217+
expect(() => rowToNormalized({ to_addresses: 'a@b.com', from_address: 'c@d.com', subject: 'x' })).toThrow(/body/);
218+
});
142219
});

packages/plugins/plugin-email/src/email-service.ts

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,48 @@ export function normalizeMessage(
9595
return msg;
9696
}
9797

98+
/** Split a persisted comma-separated address column back into a list. */
99+
function splitAddresses(v: unknown): string[] {
100+
if (v == null) return [];
101+
return String(v)
102+
.split(',')
103+
.map((s) => s.trim())
104+
.filter(Boolean);
105+
}
106+
107+
/**
108+
* Reconstruct a NormalizedEmailMessage from a persisted `sys_email` row
109+
* (the outbox-drain path). Addresses are already canonicalized at insert
110+
* time, so this re-splits the stored columns rather than re-validating.
111+
* Throws when the row lacks the minimum fields needed to send.
112+
*/
113+
export function rowToNormalized(row: Record<string, any>): NormalizedEmailMessage {
114+
const to = splitAddresses(row.to_addresses);
115+
if (to.length === 0) throw new Error('VALIDATION_FAILED: row has no to_addresses');
116+
const from = String(row.from_address ?? '').trim();
117+
if (!from) throw new Error('VALIDATION_FAILED: row has no from_address');
118+
const subject = String(row.subject ?? '').trim();
119+
if (!subject) throw new Error('VALIDATION_FAILED: row has no subject');
120+
const text = row.body_text;
121+
const html = row.body_html;
122+
if ((text == null || text === '') && (html == null || html === '')) {
123+
throw new Error('VALIDATION_FAILED: row has neither body_text nor body_html');
124+
}
125+
const msg: NormalizedEmailMessage = {
126+
to,
127+
from,
128+
subject,
129+
...(text != null && text !== '' ? { text: String(text) } : {}),
130+
...(html != null && html !== '' ? { html: String(html) } : {}),
131+
};
132+
const cc = splitAddresses(row.cc_addresses);
133+
if (cc.length > 0) msg.cc = cc;
134+
const bcc = splitAddresses(row.bcc_addresses);
135+
if (bcc.length > 0) msg.bcc = bcc;
136+
if (row.reply_to) msg.replyTo = String(row.reply_to);
137+
return msg;
138+
}
139+
98140
/**
99141
* Development transport — never actually sends. Logs to the provided
100142
* logger and returns a synthetic Message-ID. Useful for local dev,
@@ -186,10 +228,24 @@ export interface EmailServiceOptions {
186228
* id when persistence is disabled).
187229
*/
188230
export class EmailService implements IEmailService {
231+
/**
232+
* Row ids the service is itself delivering (via `send()`). The
233+
* EmailServicePlugin's sys_email afterInsert drain hook consults this
234+
* set and skips these rows, so a service-originated `queued` insert is
235+
* delivered exactly once (by `send()`) — never double-sent by the hook.
236+
* App-originated raw inserts are absent here, so the hook handles them.
237+
*/
238+
private readonly managedRowIds = new Set<string>();
239+
189240
constructor(public options: EmailServiceOptions) {
190241
if (!options.transport) throw new Error('EmailService: transport is required');
191242
}
192243

244+
/** True when this row id is currently being delivered by `send()`. */
245+
isServiceManaged(id: string): boolean {
246+
return this.managedRowIds.has(id);
247+
}
248+
193249
/** Wire (or replace) the template loader after construction. */
194250
setTemplateLoader(loader: TemplateLoader): void {
195251
this.options.templateLoader = loader;
@@ -242,17 +298,37 @@ export class EmailService implements IEmailService {
242298
attempt_count: 0,
243299
};
244300

245-
let persistedId: string | undefined;
246-
if (this.options.persistence) {
247-
try {
248-
const res = await this.options.persistence.insert(baseRow);
249-
persistedId = typeof res === 'string' ? res : res?.id ?? id;
250-
} catch (err: any) {
251-
this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message });
301+
// Reserve the row id BEFORE persistence.insert so the drain hook
302+
// (which fires synchronously inside that insert) sees it as managed
303+
// and skips it — `send()` owns this row's delivery.
304+
this.managedRowIds.add(id);
305+
try {
306+
let persistedId: string | undefined;
307+
if (this.options.persistence) {
308+
try {
309+
const res = await this.options.persistence.insert(baseRow);
310+
persistedId = typeof res === 'string' ? res : res?.id ?? id;
311+
if (persistedId !== id) this.managedRowIds.add(persistedId);
312+
} catch (err: any) {
313+
this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message });
314+
}
252315
}
316+
const rowId = persistedId ?? id;
317+
return await this.deliverNormalized(rowId, normalized);
318+
} finally {
319+
this.managedRowIds.delete(id);
253320
}
254-
const rowId = persistedId ?? id;
321+
}
255322

323+
/**
324+
* Deliver a normalized message through the transport (with retry) and
325+
* finalize the persisted `sys_email` row (`sent` + message_id + sent_at,
326+
* or `failed` + error). Shared by `send()` and `deliverPersistedRow()`.
327+
*/
328+
private async deliverNormalized(
329+
rowId: string,
330+
normalized: NormalizedEmailMessage,
331+
): Promise<SendEmailResult> {
256332
const maxAttempts = (this.options.retries ?? 0) + 1;
257333
let lastError: any;
258334
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -284,6 +360,29 @@ export class EmailService implements IEmailService {
284360
return { id: rowId, status: 'failed', error: errMessage };
285361
}
286362

363+
/**
364+
* Deliver an ALREADY-PERSISTED `sys_email` row (the outbox-drain path).
365+
*
366+
* An app (e.g. a sandboxed action that can only `api.write`, never reach
367+
* the email service) inserts a `sys_email` row as `status:'queued'`; the
368+
* plugin's afterInsert hook calls this to actually transmit it. Unlike
369+
* `send()`, this does NOT insert a new row — it reconstructs the message
370+
* from the row columns and finalizes that same row in place.
371+
*/
372+
async deliverPersistedRow(row: Record<string, any>): Promise<SendEmailResult> {
373+
const rowId = String(row?.id ?? '');
374+
if (!rowId) throw new Error('deliverPersistedRow: row.id is required');
375+
let normalized: NormalizedEmailMessage;
376+
try {
377+
normalized = rowToNormalized(row);
378+
} catch (err: any) {
379+
const errMessage = String(err?.message ?? err ?? 'invalid row').slice(0, 1000);
380+
await this.updateRow(rowId, { status: 'failed', error: errMessage, attempt_count: 0 });
381+
return { id: rowId, status: 'failed', error: errMessage };
382+
}
383+
return this.deliverNormalized(rowId, normalized);
384+
}
385+
287386
private async updateRow(id: string, patch: Record<string, any>): Promise<void> {
288387
if (!this.options.persistence?.update) return;
289388
try {

0 commit comments

Comments
 (0)