Skip to content

Commit 268b9ca

Browse files
hotlongCopilot
andcommitted
feat(email): M11.B1 email MVP — IEmailService + plugin-email + REST /email/send
Ships the platform email primitive end-to-end so CRM (and any tenant) can send transactional mail through a pluggable transport without each app re-implementing validation, persistence, or audit: * spec/contracts: IEmailService + IEmailTransport + EmailAddress / Attachment / SendEmailInput / NormalizedEmailMessage / SendEmailResult. * platform-objects: new SysEmail audit object (envelope, content, status, source linkage, lifecycle) + three indexes. * @objectstack/plugin-email (new package): - normalizeMessage(): validates subject + (text|html) + ≥1 recipient + RFC-style email address, applies defaultFrom. - EmailService: validate → persist queued row → transport.send → update sent|failed with messageId/error; retries 5xx/network with exp-backoff + jitter; never throws on transport failure. - LogTransport ships for dev; real transports (nodemailer/Resend/SES) plug in by implementing IEmailTransport. - EmailServicePlugin registers SysEmail via manifest, then swaps to persistence-enabled EmailService at kernel:ready. - 15 unit tests cover formatAddress, validation envelope, retry semantics, and persistence wiring. * rest: new POST {basePath}/email/send endpoint resolves the service via emailServiceProvider, stamps sentBy from auth context, surfaces VALIDATION_FAILED as 400, missing-provider as 501, transport errors as 500. 3 new tests (60 total). * rest-api-plugin: emailServiceProvider falls back to kernel getService('email') so single-kernel deployments work without kernelManager wiring. Closes B.1 / M10.7. Out of scope: no SMTP transport bundled — wire nodemailer/Resend yourself or wait for @objectstack/plugin-email-* in a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 174ebd4 commit 268b9ca

14 files changed

Lines changed: 1085 additions & 1 deletion

File tree

packages/platform-objects/src/audit/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { SysActivity } from './sys-activity.object.js';
1010
export { SysComment } from './sys-comment.object.js';
1111
export { SysAttachment } from './sys-attachment.object.js';
1212
export { SysNotification } from './sys-notification.object.js';
13+
export { SysEmail } from './sys-email.object.js';
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_email — Outbound Email Log
7+
*
8+
* Persistent record of every email the platform has tried to deliver.
9+
* Lets administrators audit campaigns, debug delivery failures, and
10+
* lets users see "what was sent" from a record's activity stream.
11+
*
12+
* The actual SMTP / API delivery is performed by an `IEmailTransport`
13+
* implementation injected into the EmailServicePlugin (e.g. nodemailer,
14+
* SendGrid, Resend). This object only stores the outcome.
15+
*
16+
* Typical writers: `IEmailService.send()`.
17+
* Typical readers: activity timeline, deliverability dashboard.
18+
*
19+
* @namespace sys
20+
*/
21+
export const SysEmail = ObjectSchema.create({
22+
name: 'sys_email',
23+
label: 'Email',
24+
pluralLabel: 'Emails',
25+
icon: 'mail',
26+
isSystem: true,
27+
description: 'Outbound email delivery log',
28+
displayNameField: 'subject',
29+
titleFormat: '{subject}',
30+
compactLayout: ['subject', 'to', 'status', 'sent_at'],
31+
32+
fields: {
33+
id: Field.text({
34+
label: 'Email ID',
35+
required: true,
36+
readonly: true,
37+
group: 'System',
38+
}),
39+
40+
// ── Envelope ─────────────────────────────────────────────────
41+
message_id: Field.text({
42+
label: 'Message-ID',
43+
required: false,
44+
maxLength: 255,
45+
description: 'RFC-5322 Message-ID assigned by the transport',
46+
group: 'Envelope',
47+
}),
48+
49+
from_address: Field.text({
50+
label: 'From',
51+
required: true,
52+
maxLength: 320,
53+
searchable: true,
54+
group: 'Envelope',
55+
}),
56+
57+
to_addresses: Field.text({
58+
label: 'To',
59+
required: true,
60+
maxLength: 4000,
61+
searchable: true,
62+
description: 'Comma-separated recipient addresses',
63+
group: 'Envelope',
64+
}),
65+
66+
cc_addresses: Field.text({
67+
label: 'Cc',
68+
required: false,
69+
maxLength: 4000,
70+
group: 'Envelope',
71+
}),
72+
73+
bcc_addresses: Field.text({
74+
label: 'Bcc',
75+
required: false,
76+
maxLength: 4000,
77+
group: 'Envelope',
78+
}),
79+
80+
reply_to: Field.text({
81+
label: 'Reply-To',
82+
required: false,
83+
maxLength: 320,
84+
group: 'Envelope',
85+
}),
86+
87+
// ── Content ──────────────────────────────────────────────────
88+
subject: Field.text({
89+
label: 'Subject',
90+
required: true,
91+
maxLength: 998,
92+
searchable: true,
93+
group: 'Content',
94+
}),
95+
96+
body_text: Field.textarea({
97+
label: 'Body (text)',
98+
required: false,
99+
searchable: true,
100+
group: 'Content',
101+
}),
102+
103+
body_html: Field.textarea({
104+
label: 'Body (HTML)',
105+
required: false,
106+
group: 'Content',
107+
}),
108+
109+
// ── Delivery state ───────────────────────────────────────────
110+
status: Field.select(
111+
['queued', 'sent', 'failed'],
112+
{
113+
label: 'Status',
114+
required: true,
115+
defaultValue: 'queued',
116+
description: 'Lifecycle state — queued by IEmailService.send before transport call',
117+
group: 'State',
118+
},
119+
),
120+
121+
error: Field.textarea({
122+
label: 'Error',
123+
required: false,
124+
description: 'Transport error message when status=failed',
125+
group: 'State',
126+
}),
127+
128+
attempt_count: Field.number({
129+
label: 'Attempts',
130+
required: false,
131+
defaultValue: 0,
132+
description: 'Number of delivery attempts performed by the service',
133+
group: 'State',
134+
}),
135+
136+
sent_at: Field.datetime({
137+
label: 'Sent At',
138+
required: false,
139+
description: 'Set when status transitions to "sent"',
140+
group: 'State',
141+
}),
142+
143+
// ── Source linkage ───────────────────────────────────────────
144+
related_object: Field.text({
145+
label: 'Related Object',
146+
required: false,
147+
maxLength: 100,
148+
description: 'Object name of the related record (e.g. lead, opportunity)',
149+
group: 'Source',
150+
}),
151+
152+
related_id: Field.text({
153+
label: 'Related Record',
154+
required: false,
155+
maxLength: 100,
156+
description: 'Record id within related_object',
157+
group: 'Source',
158+
}),
159+
160+
sent_by: Field.lookup('sys_user', {
161+
label: 'Sent By',
162+
required: false,
163+
group: 'Source',
164+
}),
165+
166+
// ── Lifecycle ────────────────────────────────────────────────
167+
created_at: Field.datetime({
168+
label: 'Created At',
169+
required: true,
170+
defaultValue: 'NOW()',
171+
readonly: true,
172+
group: 'System',
173+
}),
174+
175+
updated_at: Field.datetime({
176+
label: 'Updated At',
177+
required: false,
178+
group: 'System',
179+
}),
180+
},
181+
182+
indexes: [
183+
{ fields: ['status', 'created_at'] },
184+
{ fields: ['related_object', 'related_id'] },
185+
{ fields: ['sent_by'] },
186+
],
187+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "@objectstack/plugin-email",
3+
"version": "4.0.0",
4+
"license": "Apache-2.0",
5+
"description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run --passWithNoTests"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/platform-objects": "workspace:*",
22+
"@objectstack/spec": "workspace:*"
23+
},
24+
"devDependencies": {
25+
"@types/node": "^25.8.0",
26+
"typescript": "^6.0.3",
27+
"vitest": "^4.1.6"
28+
},
29+
"keywords": [
30+
"objectstack",
31+
"plugin",
32+
"email",
33+
"smtp"
34+
]
35+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import type { IDataEngine } from '@objectstack/spec/contracts';
5+
import type {
6+
IEmailTransport,
7+
EmailAddress,
8+
} from '@objectstack/spec/contracts';
9+
import { SysEmail } from '@objectstack/platform-objects/audit';
10+
import { EmailService, LogTransport, type EmailPersistence } from './email-service.js';
11+
12+
/**
13+
* Plugin configuration.
14+
*/
15+
export interface EmailServicePluginOptions {
16+
/**
17+
* Pluggable delivery transport. When omitted, a `LogTransport` is
18+
* installed which never sends mail — suitable for development. For
19+
* production, wire a concrete transport (nodemailer, Resend SDK, …).
20+
*/
21+
transport?: IEmailTransport;
22+
/** Default `From` address applied when `input.from` is omitted. */
23+
defaultFrom?: EmailAddress;
24+
/** Persist each attempt to sys_email. Default true when ObjectQL engine present. */
25+
persist?: boolean;
26+
/** Retry attempts on transport throw. Default 0. */
27+
retries?: number;
28+
}
29+
30+
/**
31+
* EmailServicePlugin — registers the `email` service.
32+
*
33+
* @example
34+
* ```ts
35+
* import { EmailServicePlugin } from '@objectstack/plugin-email';
36+
* import nodemailer from 'nodemailer';
37+
*
38+
* const smtp = nodemailer.createTransport({ host: 'smtp.example.com', port: 587 });
39+
* const transport = { async send(msg) { const r = await smtp.sendMail(msg); return { messageId: r.messageId }; } };
40+
*
41+
* kernel.use(new EmailServicePlugin({
42+
* transport,
43+
* defaultFrom: { name: 'Acme CRM', address: 'no-reply@acme.com' },
44+
* retries: 2,
45+
* }));
46+
* ```
47+
*/
48+
export class EmailServicePlugin implements Plugin {
49+
name = 'com.objectstack.service.email';
50+
version = '1.0.0';
51+
type = 'standard';
52+
dependencies = ['com.objectstack.engine.objectql'];
53+
54+
private readonly options: EmailServicePluginOptions;
55+
private service?: EmailService;
56+
57+
constructor(options: EmailServicePluginOptions = {}) {
58+
this.options = options;
59+
}
60+
61+
async init(ctx: PluginContext): Promise<void> {
62+
// Register sys_email schema via manifest service.
63+
ctx.getService<{ register(m: any): void }>('manifest').register({
64+
id: 'com.objectstack.service.email',
65+
name: 'Email Service',
66+
version: '1.0.0',
67+
type: 'plugin',
68+
scope: 'system',
69+
defaultDatasource: 'cloud',
70+
namespace: 'sys',
71+
objects: [SysEmail],
72+
});
73+
74+
const transport = this.options.transport ?? new LogTransport(ctx.logger);
75+
if (!this.options.transport) {
76+
ctx.logger.info(
77+
'EmailServicePlugin: no transport configured — using LogTransport (mail will NOT be sent)',
78+
);
79+
}
80+
81+
// Persistence is wired in `start` once the ObjectQL engine is available;
82+
// here we register the service synchronously so dependents can resolve it.
83+
this.service = new EmailService({
84+
transport,
85+
defaultFrom: this.options.defaultFrom,
86+
retries: this.options.retries,
87+
logger: ctx.logger,
88+
});
89+
ctx.registerService('email', this.service);
90+
ctx.logger.info('EmailServicePlugin: email service registered');
91+
}
92+
93+
async start(ctx: PluginContext): Promise<void> {
94+
if (this.options.persist === false) return;
95+
ctx.hook('kernel:ready', async () => {
96+
let engine: IDataEngine | null = null;
97+
try { engine = ctx.getService<IDataEngine>('objectql'); }
98+
catch { try { engine = ctx.getService<IDataEngine>('data'); } catch { /* ignore */ } }
99+
if (!engine || !this.service) return;
100+
const persistence: EmailPersistence = {
101+
async insert(row) {
102+
const created = await (engine as any).insert('sys_email', row, {
103+
context: { isSystem: true, roles: [], permissions: [] },
104+
});
105+
return created?.id ? { id: String(created.id) } : { id: String(row.id) };
106+
},
107+
async update(id, patch) {
108+
await (engine as any).update('sys_email', id, patch, {
109+
context: { isSystem: true, roles: [], permissions: [] },
110+
});
111+
},
112+
};
113+
// Swap the service to persistence-enabled by re-constructing with same options.
114+
const upgraded = new EmailService({
115+
transport: (this.service as any).options.transport,
116+
defaultFrom: (this.service as any).options.defaultFrom,
117+
retries: (this.service as any).options.retries,
118+
logger: ctx.logger,
119+
persistence,
120+
});
121+
// Replace the registered instance via the same service name.
122+
ctx.registerService('email', upgraded);
123+
this.service = upgraded;
124+
ctx.logger.info('EmailServicePlugin: sys_email persistence enabled');
125+
});
126+
}
127+
}

0 commit comments

Comments
 (0)