@ultimat3/mail 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/package.json +39 -0
  4. package/src/base64.ts +9 -0
  5. package/src/blocks.d.ts +47 -0
  6. package/src/blocks.d.ts.map +1 -0
  7. package/src/blocks.js +23 -0
  8. package/src/blocks.js.map +1 -0
  9. package/src/blocks.ts +56 -0
  10. package/src/catalog.d.ts +6 -0
  11. package/src/catalog.d.ts.map +1 -0
  12. package/src/catalog.js +78 -0
  13. package/src/catalog.js.map +1 -0
  14. package/src/catalog.ts +88 -0
  15. package/src/driver-env.ts +105 -0
  16. package/src/driver-resend.ts +184 -0
  17. package/src/driver-smtp.ts +209 -0
  18. package/src/driver.d.ts +79 -0
  19. package/src/driver.d.ts.map +1 -0
  20. package/src/driver.js +115 -0
  21. package/src/driver.js.map +1 -0
  22. package/src/driver.ts +174 -0
  23. package/src/errors.d.ts +23 -0
  24. package/src/errors.d.ts.map +1 -0
  25. package/src/errors.js +82 -0
  26. package/src/errors.js.map +1 -0
  27. package/src/errors.ts +177 -0
  28. package/src/html.d.ts +10 -0
  29. package/src/html.d.ts.map +1 -0
  30. package/src/html.js +34 -0
  31. package/src/html.js.map +1 -0
  32. package/src/html.ts +37 -0
  33. package/src/idempotency.ts +61 -0
  34. package/src/index.d.ts +17 -0
  35. package/src/index.d.ts.map +1 -0
  36. package/src/index.js +13 -0
  37. package/src/index.js.map +1 -0
  38. package/src/index.ts +110 -0
  39. package/src/job.d.ts +17 -0
  40. package/src/job.d.ts.map +1 -0
  41. package/src/job.js +79 -0
  42. package/src/job.js.map +1 -0
  43. package/src/job.ts +36 -0
  44. package/src/layout.d.ts +41 -0
  45. package/src/layout.d.ts.map +1 -0
  46. package/src/layout.js +160 -0
  47. package/src/layout.js.map +1 -0
  48. package/src/layout.ts +221 -0
  49. package/src/mail.d.ts +67 -0
  50. package/src/mail.d.ts.map +1 -0
  51. package/src/mail.js +119 -0
  52. package/src/mail.js.map +1 -0
  53. package/src/mail.ts +195 -0
  54. package/src/mime.ts +233 -0
  55. package/src/render.d.ts +26 -0
  56. package/src/render.d.ts.map +1 -0
  57. package/src/render.js +149 -0
  58. package/src/render.js.map +1 -0
  59. package/src/render.ts +190 -0
  60. package/src/smtp-client.ts +246 -0
  61. package/src/smtp-protocol.ts +184 -0
  62. package/src/smtp-socket.ts +266 -0
  63. package/src/templates/index.d.ts +9 -0
  64. package/src/templates/index.d.ts.map +1 -0
  65. package/src/templates/index.js +23 -0
  66. package/src/templates/index.js.map +1 -0
  67. package/src/templates/index.ts +39 -0
  68. package/src/templates/invite.d.ts +15 -0
  69. package/src/templates/invite.d.ts.map +1 -0
  70. package/src/templates/invite.js +25 -0
  71. package/src/templates/invite.js.map +1 -0
  72. package/src/templates/invite.ts +29 -0
  73. package/src/templates/mfa-enrolled.d.ts +14 -0
  74. package/src/templates/mfa-enrolled.d.ts.map +1 -0
  75. package/src/templates/mfa-enrolled.js +26 -0
  76. package/src/templates/mfa-enrolled.js.map +1 -0
  77. package/src/templates/mfa-enrolled.ts +37 -0
  78. package/src/templates/reset-password.d.ts +13 -0
  79. package/src/templates/reset-password.d.ts.map +1 -0
  80. package/src/templates/reset-password.js +23 -0
  81. package/src/templates/reset-password.js.map +1 -0
  82. package/src/templates/reset-password.ts +27 -0
  83. package/src/templates/security-alert.d.ts +16 -0
  84. package/src/templates/security-alert.d.ts.map +1 -0
  85. package/src/templates/security-alert.js +29 -0
  86. package/src/templates/security-alert.js.map +1 -0
  87. package/src/templates/security-alert.ts +36 -0
  88. package/src/templates/verify-email.d.ts +13 -0
  89. package/src/templates/verify-email.d.ts.map +1 -0
  90. package/src/templates/verify-email.js +23 -0
  91. package/src/templates/verify-email.js.map +1 -0
  92. package/src/templates/verify-email.ts +27 -0
  93. package/src/templates/welcome.d.ts +13 -0
  94. package/src/templates/welcome.d.ts.map +1 -0
  95. package/src/templates/welcome.js +20 -0
  96. package/src/templates/welcome.js.map +1 -0
  97. package/src/templates/welcome.ts +24 -0
package/src/layout.ts ADDED
@@ -0,0 +1,221 @@
1
+ // Single responsibility: the base email layout and the semantic colour tokens every block
2
+ // resolves through. Colours are declared once here (light + dark) and inlined by the
3
+ // renderer, because email clients drop <style> and templates must never carry a raw hex.
4
+ // Table-based and 600px wide: Outlook still renders with Word's HTML engine.
5
+
6
+ import { escapeHtml, safeUrl, styleAttr } from './html';
7
+
8
+ export type ColorScheme = 'light' | 'dark';
9
+
10
+ export type MailToken =
11
+ | 'pageBg'
12
+ | 'surfaceBg'
13
+ | 'textPrimary'
14
+ | 'textMuted'
15
+ | 'borderSubtle'
16
+ | 'accentBg'
17
+ | 'accentText'
18
+ | 'linkText'
19
+ | 'calloutInfoBg'
20
+ | 'calloutInfoText'
21
+ | 'calloutDangerBg'
22
+ | 'calloutDangerText';
23
+
24
+ export const MAIL_TOKENS: Readonly<Record<MailToken, Readonly<Record<ColorScheme, string>>>> =
25
+ Object.freeze({
26
+ pageBg: { light: '#f4f5f7', dark: '#0b0d10' },
27
+ surfaceBg: { light: '#ffffff', dark: '#14181d' },
28
+ textPrimary: { light: '#16191d', dark: '#e7eaee' },
29
+ textMuted: { light: '#5c6470', dark: '#9aa4b1' },
30
+ borderSubtle: { light: '#e2e6ea', dark: '#262c33' },
31
+ accentBg: { light: '#2f6df6', dark: '#4f86ff' },
32
+ accentText: { light: '#ffffff', dark: '#0b0d10' },
33
+ linkText: { light: '#2f6df6', dark: '#7aa7ff' },
34
+ calloutInfoBg: { light: '#eef3ff', dark: '#141c2c' },
35
+ calloutInfoText: { light: '#1f3f8f', dark: '#b9cdfb' },
36
+ calloutDangerBg: { light: '#fdeceb', dark: '#2a1416' },
37
+ calloutDangerText: { light: '#8c2118', dark: '#f5b3ad' },
38
+ });
39
+
40
+ /** Resolve a token to a hex value. The only function in the package that returns a colour. */
41
+ export function token(name: MailToken, scheme: ColorScheme = 'light'): string {
42
+ return MAIL_TOKENS[name][scheme];
43
+ }
44
+
45
+ export const MAIL_FONT_STACK =
46
+ '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif';
47
+
48
+ export const MAIL_WIDTH_PX = 600;
49
+
50
+ /** A node's dark-mode role: which CSS property takes which token when the client is dark. */
51
+ export type DarkRule = readonly [role: string, declarations: readonly [string, MailToken][]];
52
+
53
+ /**
54
+ * Dark overrides key off short `data-x` role codes rather than class names: base styling
55
+ * stays fully inlined for the clients that strip <style>, and the codes are deliberately
56
+ * not token names, so nothing leaks the design vocabulary into delivered HTML.
57
+ */
58
+ export const DARK_RULES: readonly DarkRule[] = [
59
+ ['page', [['background-color', 'pageBg']]],
60
+ [
61
+ 'card',
62
+ [
63
+ ['background-color', 'surfaceBg'],
64
+ ['border-color', 'borderSubtle'],
65
+ ],
66
+ ],
67
+ ['h', [['color', 'textPrimary']]],
68
+ ['p', [['color', 'textPrimary']]],
69
+ ['mut', [['color', 'textMuted']]],
70
+ [
71
+ 'btn',
72
+ [
73
+ ['background-color', 'accentBg'],
74
+ ['color', 'accentText'],
75
+ ],
76
+ ],
77
+ ['hr', [['border-top-color', 'borderSubtle']]],
78
+ ['a', [['color', 'linkText']]],
79
+ [
80
+ 'ci',
81
+ [
82
+ ['background-color', 'calloutInfoBg'],
83
+ ['color', 'calloutInfoText'],
84
+ ],
85
+ ],
86
+ [
87
+ 'cd',
88
+ [
89
+ ['background-color', 'calloutDangerBg'],
90
+ ['color', 'calloutDangerText'],
91
+ ],
92
+ ],
93
+ ];
94
+
95
+ /** The one `prefers-color-scheme` block. Clients that ignore it keep the inlined light theme. */
96
+ export function darkModeCss(): string {
97
+ const rules = DARK_RULES.map(([role, declarations]) => {
98
+ const body = declarations
99
+ .map(([property, name]) => `${property}:${token(name, 'dark')}!important`)
100
+ .join(';');
101
+ return `[data-x="${role}"]{${body}}`;
102
+ });
103
+ return `@media (prefers-color-scheme:dark){${rules.join('')}}`;
104
+ }
105
+
106
+ export interface UnsubscribeSlot {
107
+ /** Already translated — the layout never touches a catalog. */
108
+ readonly label: string;
109
+ readonly url: string;
110
+ }
111
+
112
+ export interface LayoutInput {
113
+ readonly subject: string;
114
+ /** Hidden preview text. Clients show it next to the subject in the inbox list. */
115
+ readonly preheader: string;
116
+ /** Rendered block HTML. */
117
+ readonly content: string;
118
+ readonly footer: readonly string[];
119
+ readonly unsubscribe?: UnsubscribeSlot | undefined;
120
+ readonly locale: string;
121
+ readonly direction: 'ltr' | 'rtl';
122
+ }
123
+
124
+ export type MailLayout = (input: LayoutInput) => string;
125
+
126
+ export const BASE_LAYOUT = 'base';
127
+
128
+ const BODY_STYLE = styleAttr([
129
+ 'margin:0',
130
+ 'padding:0',
131
+ 'width:100%',
132
+ `background-color:${token('pageBg')}`,
133
+ `font-family:${MAIL_FONT_STACK}`,
134
+ `color:${token('textPrimary')}`,
135
+ '-webkit-font-smoothing:antialiased',
136
+ ]);
137
+
138
+ const PREHEADER_STYLE = styleAttr([
139
+ 'display:none',
140
+ 'font-size:1px',
141
+ 'line-height:1px',
142
+ 'max-height:0',
143
+ 'max-width:0',
144
+ 'opacity:0',
145
+ 'overflow:hidden',
146
+ 'mso-hide:all',
147
+ ]);
148
+
149
+ const PAGE_STYLE = styleAttr([`background-color:${token('pageBg')}`, 'width:100%']);
150
+ const GUTTER_STYLE = styleAttr(['padding:24px 12px']);
151
+ const SHELL_STYLE = styleAttr([`width:${MAIL_WIDTH_PX}px`, 'max-width:100%']);
152
+
153
+ const CARD_STYLE = styleAttr([
154
+ `background-color:${token('surfaceBg')}`,
155
+ `border:1px solid ${token('borderSubtle')}`,
156
+ 'border-radius:12px',
157
+ 'padding:32px',
158
+ ]);
159
+
160
+ const FOOTER_STYLE = styleAttr([
161
+ `color:${token('textMuted')}`,
162
+ 'font-size:12px',
163
+ 'line-height:18px',
164
+ 'padding:16px 8px 32px 8px',
165
+ ]);
166
+
167
+ const UNSUBSCRIBE_STYLE = styleAttr([`color:${token('textMuted')}`, 'text-decoration:underline']);
168
+
169
+ const TABLE_ATTRS = 'role="presentation" cellpadding="0" cellspacing="0" border="0"';
170
+
171
+ /** The framework's only layout. Apps register their own with `registerLayout`. */
172
+ export const baseLayout: MailLayout = (input) => `<!doctype html>
173
+ <html lang="${escapeHtml(input.locale)}" dir="${input.direction}">
174
+ <head>
175
+ <meta charset="utf-8">
176
+ <meta name="viewport" content="width=device-width,initial-scale=1">
177
+ <meta http-equiv="x-ua-compatible" content="ie=edge">
178
+ <meta name="color-scheme" content="light dark">
179
+ <meta name="supported-color-schemes" content="light dark">
180
+ <meta name="format-detection" content="telephone=no,date=no,address=no,email=no">
181
+ <meta name="x-apple-disable-message-reformatting">
182
+ <title>${escapeHtml(input.subject)}</title>
183
+ <style>${darkModeCss()}</style>
184
+ </head>
185
+ <body ${BODY_STYLE} data-x="page">
186
+ <div ${PREHEADER_STYLE}>${escapeHtml(input.preheader)}</div>
187
+ <table ${TABLE_ATTRS} width="100%" data-x="page" ${PAGE_STYLE}>
188
+ <tr><td align="center" ${GUTTER_STYLE}>
189
+ <table ${TABLE_ATTRS} width="${MAIL_WIDTH_PX}" ${SHELL_STYLE}>
190
+ <tr><td data-x="card" ${CARD_STYLE}>${input.content}</td></tr>
191
+ <tr><td data-x="mut" ${FOOTER_STYLE}>${footerHtml(input)}</td></tr>
192
+ </table>
193
+ </td></tr>
194
+ </table>
195
+ </body>
196
+ </html>`;
197
+
198
+ function footerHtml(input: LayoutInput): string {
199
+ const lines = input.footer.map((line) => `<div>${escapeHtml(line)}</div>`);
200
+ const slot = input.unsubscribe;
201
+ if (slot !== undefined) {
202
+ const href = escapeHtml(safeUrl(slot.url));
203
+ const label = escapeHtml(slot.label);
204
+ lines.push(`<div><a data-x="a" href="${href}" ${UNSUBSCRIBE_STYLE}>${label}</a></div>`);
205
+ }
206
+ return lines.join('');
207
+ }
208
+
209
+ const layouts = new Map<string, MailLayout>([[BASE_LAYOUT, baseLayout]]);
210
+
211
+ export function registerLayout(name: string, layout: MailLayout): void {
212
+ layouts.set(name, layout);
213
+ }
214
+
215
+ export function layoutFor(name: string): MailLayout | undefined {
216
+ return layouts.get(name);
217
+ }
218
+
219
+ export function registeredLayouts(): readonly string[] {
220
+ return [...layouts.keys()].sort();
221
+ }
package/src/mail.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { type StandardSchemaV1 } from '@ultimat3/schema';
2
+ import type { MailTemplate } from './blocks';
3
+ import { type MailMessage, type SendResult } from './driver';
4
+ import { type RenderableMail } from './render';
5
+ export interface MailDefinition<I> extends RenderableMail<I> {
6
+ readonly id: string;
7
+ /** An i18n key, never a literal subject line. */
8
+ readonly subject: string;
9
+ readonly template: MailTemplate<I>;
10
+ readonly input: StandardSchemaV1<unknown, I>;
11
+ readonly layout: string;
12
+ }
13
+ export interface MailInit<I> {
14
+ readonly id: string;
15
+ readonly subject: string;
16
+ readonly template: MailTemplate<I>;
17
+ readonly input: StandardSchemaV1<unknown, I>;
18
+ /** Defaults to the framework's `base` layout. */
19
+ readonly layout?: string | undefined;
20
+ }
21
+ export interface SendOptions {
22
+ readonly to: string | readonly string[];
23
+ /** BCP-47. Required — there is no ambient fallback for a message the user reads later. */
24
+ readonly locale: string;
25
+ /** IANA zone for every date in the body. Defaults to the request context, then UTC. */
26
+ readonly tz?: string | undefined;
27
+ readonly replyTo?: string | undefined;
28
+ readonly cc?: readonly string[] | undefined;
29
+ readonly bcc?: readonly string[] | undefined;
30
+ readonly unsubscribeUrl?: string | undefined;
31
+ readonly idempotencyKey?: string | undefined;
32
+ /** Deliver inline instead of through the queue. Tests and CLI one-shots only. */
33
+ readonly sync?: boolean | undefined;
34
+ }
35
+ /**
36
+ * The registry erases the payload type so heterogeneous mails share one map. `template`
37
+ * stays assignable because `never` flows into any `TemplateArgs<I>`; `input` widens to
38
+ * `unknown` on the output side, which is exactly what `parse` re-establishes at send time.
39
+ */
40
+ export interface AnyMailDefinition {
41
+ readonly id: string;
42
+ readonly subject: string;
43
+ readonly layout: string;
44
+ readonly input: StandardSchemaV1<unknown, unknown>;
45
+ readonly template: MailTemplate<never>;
46
+ }
47
+ export declare function defineMail<I>(init: MailInit<I>): MailDefinition<I>;
48
+ export declare function mailFor(id: string): AnyMailDefinition | undefined;
49
+ export declare function registeredMails(): readonly AnyMailDefinition[];
50
+ export declare function registeredMailIds(): readonly string[];
51
+ /** Test/CLI seam: drop every registered mail. */
52
+ export declare function resetMails(): void;
53
+ /**
54
+ * Validate, render, and build the envelope — everything `send` does before it decides
55
+ * between the queue and the transport. Exported so `x mail preview` and tests can render
56
+ * without delivering anything.
57
+ */
58
+ export declare function renderMessage<I>(mail: MailDefinition<I>, data: I, options: SendOptions): MailMessage;
59
+ /**
60
+ * Enqueues by default so a slow SMTP host never blocks a request. Falls back to an inline
61
+ * send when no job driver is configured (a script, a test, `x` one-shots) — the alternative
62
+ * would be silently dropping mail in exactly the setups where it is easiest to miss.
63
+ */
64
+ export declare function send<I>(mail: MailDefinition<I>, data: I, options: SendOptions): Promise<SendResult>;
65
+ /** Send by id — what the CLI, MCP tools and generated code use. `data` is parsed, not trusted. */
66
+ export declare function sendById(id: string, data: unknown, options: SendOptions): Promise<SendResult>;
67
+ //# sourceMappingURL=mail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mail.d.ts","sourceRoot":"","sources":["mail.ts"],"names":[],"mappings":"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"}
package/src/mail.js ADDED
@@ -0,0 +1,119 @@
1
+ // Single responsibility: the `defineMail` primitive, the mail registry, and `send`.
2
+ // `locale` is required by the TYPE, not by a runtime check alone: an email in the wrong
3
+ // language is a real, unnoticed production bug, and the ambient request locale is the one
4
+ // piece of context that a background send cannot recover after the fact.
5
+ import { assert, DEFAULT_TIME_ZONE, tryUseContext } from '@ultimat3/core';
6
+ import { jobDriver } from '@ultimat3/jobs';
7
+ import { parse } from '@ultimat3/schema';
8
+ import { mailDriver } from './driver';
9
+ import { localeMissing, mailDuplicate, templateUnknown } from './errors';
10
+ import { mailIdempotencyKey, sendMailJob } from './job';
11
+ import { BASE_LAYOUT } from './layout';
12
+ import { renderMail } from './render';
13
+ const registry = new Map();
14
+ export function defineMail(init) {
15
+ if (registry.has(init.id))
16
+ throw mailDuplicate(init.id);
17
+ const definition = {
18
+ id: init.id,
19
+ subject: init.subject,
20
+ template: init.template,
21
+ input: init.input,
22
+ layout: init.layout ?? BASE_LAYOUT,
23
+ };
24
+ registry.set(init.id, definition);
25
+ return definition;
26
+ }
27
+ export function mailFor(id) {
28
+ return registry.get(id);
29
+ }
30
+ export function registeredMails() {
31
+ return [...registry.values()].sort((a, b) => a.id.localeCompare(b.id));
32
+ }
33
+ export function registeredMailIds() {
34
+ return [...registry.keys()].sort();
35
+ }
36
+ /** Test/CLI seam: drop every registered mail. */
37
+ export function resetMails() {
38
+ registry.clear();
39
+ }
40
+ /**
41
+ * Validate, render, and build the envelope — everything `send` does before it decides
42
+ * between the queue and the transport. Exported so `x mail preview` and tests can render
43
+ * without delivering anything.
44
+ */
45
+ export function renderMessage(mail, data, options) {
46
+ assertLocale(mail.id, options.locale);
47
+ if (registry.get(mail.id) === undefined)
48
+ throw templateUnknown(mail.id, registeredMailIds());
49
+ const to = typeof options.to === 'string' ? [options.to] : [...options.to];
50
+ assert(to.length > 0, `send(${mail.id}, ...) was called with an empty recipient list`, 'pass { to: user.email } — one address, or a non-empty array of addresses');
51
+ const input = parse(mail.input, data, `${mail.id}.input`);
52
+ const tz = options.tz ?? tryUseContext()?.tz ?? DEFAULT_TIME_ZONE;
53
+ const rendered = renderMail(mail, input, {
54
+ locale: options.locale,
55
+ tz,
56
+ unsubscribeUrl: options.unsubscribeUrl,
57
+ });
58
+ return {
59
+ mailId: mail.id,
60
+ to,
61
+ subject: rendered.subject,
62
+ html: rendered.html,
63
+ text: rendered.text,
64
+ locale: options.locale,
65
+ tz,
66
+ replyTo: options.replyTo,
67
+ cc: options.cc,
68
+ bcc: options.bcc,
69
+ unsubscribeUrl: options.unsubscribeUrl,
70
+ idempotencyKey: options.idempotencyKey,
71
+ };
72
+ }
73
+ /**
74
+ * Enqueues by default so a slow SMTP host never blocks a request. Falls back to an inline
75
+ * send when no job driver is configured (a script, a test, `x` one-shots) — the alternative
76
+ * would be silently dropping mail in exactly the setups where it is easiest to miss.
77
+ */
78
+ export async function send(mail, data, options) {
79
+ const message = renderMessage(mail, data, options);
80
+ const key = mailIdempotencyKey(message);
81
+ const queue = jobDriver();
82
+ if (options.sync === true || queue === undefined) {
83
+ // The key is reported even inline, so a caller can dedupe its own retries either way.
84
+ return { ...(await mailDriver().send(message)), idempotencyKey: key };
85
+ }
86
+ const enqueued = await queue.enqueue({
87
+ name: sendMailJob.name,
88
+ queue: sendMailJob.queue,
89
+ input: message,
90
+ idempotencyKey: key,
91
+ maxAttempts: sendMailJob.retry.attempts,
92
+ onConflict: 'dedupe',
93
+ });
94
+ return {
95
+ id: enqueued.id,
96
+ driver: 'queue',
97
+ accepted: [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])],
98
+ queued: true,
99
+ idempotencyKey: key,
100
+ };
101
+ }
102
+ /** Send by id — what the CLI, MCP tools and generated code use. `data` is parsed, not trusted. */
103
+ export async function sendById(id, data, options) {
104
+ const mail = registry.get(id);
105
+ if (mail === undefined)
106
+ throw templateUnknown(id, registeredMailIds());
107
+ // The one cast in the package: the registry erased `I`, and `send` re-parses `data`
108
+ // through the mail's own schema before it reaches the template.
109
+ return await send(mail, data, options);
110
+ }
111
+ /**
112
+ * The runtime backstop for JS callers and generated code — TypeScript already forbids
113
+ * omitting `locale`, so reaching this throw means the type was cast away somewhere.
114
+ */
115
+ function assertLocale(mailId, locale) {
116
+ if (typeof locale !== 'string' || locale.trim() === '')
117
+ throw localeMissing(mailId);
118
+ }
119
+ //# sourceMappingURL=mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mail.js","sourceRoot":"","sources":["mail.ts"],"names":[],"mappings":"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"}
package/src/mail.ts ADDED
@@ -0,0 +1,195 @@
1
+ // Single responsibility: the `defineMail` primitive, the mail registry, and `send`.
2
+ // `locale` is required by the TYPE, not by a runtime check alone: an email in the wrong
3
+ // language is a real, unnoticed production bug, and the ambient request locale is the one
4
+ // piece of context that a background send cannot recover after the fact.
5
+
6
+ import { assert, DEFAULT_TIME_ZONE, tryUseContext } from '@ultimat3/core';
7
+ import { jobDriver } from '@ultimat3/jobs';
8
+ import { parse, type StandardSchemaV1 } from '@ultimat3/schema';
9
+ import type { MailTemplate } from './blocks';
10
+ import { type MailMessage, mailDriver, type SendResult } from './driver';
11
+ import { localeMissing, mailDuplicate, templateUnknown } from './errors';
12
+ import { mailIdempotencyKey } from './idempotency';
13
+ import { sendMailJob } from './job';
14
+ import { BASE_LAYOUT } from './layout';
15
+ import { type RenderableMail, renderMail } from './render';
16
+
17
+ export interface MailDefinition<I> extends RenderableMail<I> {
18
+ readonly id: string;
19
+ /** An i18n key, never a literal subject line. */
20
+ readonly subject: string;
21
+ readonly template: MailTemplate<I>;
22
+ readonly input: StandardSchemaV1<unknown, I>;
23
+ readonly layout: string;
24
+ }
25
+
26
+ export interface MailInit<I> {
27
+ readonly id: string;
28
+ readonly subject: string;
29
+ readonly template: MailTemplate<I>;
30
+ readonly input: StandardSchemaV1<unknown, I>;
31
+ /** Defaults to the framework's `base` layout. */
32
+ readonly layout?: string | undefined;
33
+ }
34
+
35
+ export interface SendOptions {
36
+ readonly to: string | readonly string[];
37
+ /** BCP-47. Required — there is no ambient fallback for a message the user reads later. */
38
+ readonly locale: string;
39
+ /** IANA zone for every date in the body. Defaults to the request context, then UTC. */
40
+ readonly tz?: string | undefined;
41
+ readonly replyTo?: string | undefined;
42
+ readonly cc?: readonly string[] | undefined;
43
+ readonly bcc?: readonly string[] | undefined;
44
+ readonly unsubscribeUrl?: string | undefined;
45
+ readonly idempotencyKey?: string | undefined;
46
+ /** Deliver inline instead of through the queue. Tests and CLI one-shots only. */
47
+ readonly sync?: boolean | undefined;
48
+ }
49
+
50
+ /**
51
+ * The registry erases the payload type so heterogeneous mails share one map. `template`
52
+ * stays assignable because `never` flows into any `TemplateArgs<I>`; `input` widens to
53
+ * `unknown` on the output side, which is exactly what `parse` re-establishes at send time.
54
+ */
55
+ export interface AnyMailDefinition {
56
+ readonly id: string;
57
+ readonly subject: string;
58
+ readonly layout: string;
59
+ readonly input: StandardSchemaV1<unknown, unknown>;
60
+ readonly template: MailTemplate<never>;
61
+ }
62
+
63
+ const registry = new Map<string, AnyMailDefinition>();
64
+
65
+ export function defineMail<I>(init: MailInit<I>): MailDefinition<I> {
66
+ if (registry.has(init.id)) throw mailDuplicate(init.id);
67
+ const definition: MailDefinition<I> = {
68
+ id: init.id,
69
+ subject: init.subject,
70
+ template: init.template,
71
+ input: init.input,
72
+ layout: init.layout ?? BASE_LAYOUT,
73
+ };
74
+ registry.set(init.id, definition);
75
+ return definition;
76
+ }
77
+
78
+ export function mailFor(id: string): AnyMailDefinition | undefined {
79
+ return registry.get(id);
80
+ }
81
+
82
+ export function registeredMails(): readonly AnyMailDefinition[] {
83
+ return [...registry.values()].sort((a, b) => a.id.localeCompare(b.id));
84
+ }
85
+
86
+ export function registeredMailIds(): readonly string[] {
87
+ return [...registry.keys()].sort();
88
+ }
89
+
90
+ /** Test/CLI seam: drop every registered mail. */
91
+ export function resetMails(): void {
92
+ registry.clear();
93
+ }
94
+
95
+ /**
96
+ * Validate, render, and build the envelope — everything `send` does before it decides
97
+ * between the queue and the transport. Exported so `x mail preview` and tests can render
98
+ * without delivering anything.
99
+ */
100
+ export function renderMessage<I>(
101
+ mail: MailDefinition<I>,
102
+ data: I,
103
+ options: SendOptions,
104
+ ): MailMessage {
105
+ assertLocale(mail.id, options.locale);
106
+ if (registry.get(mail.id) === undefined) throw templateUnknown(mail.id, registeredMailIds());
107
+
108
+ const to = typeof options.to === 'string' ? [options.to] : [...options.to];
109
+ assert(
110
+ to.length > 0,
111
+ `send(${mail.id}, ...) was called with an empty recipient list`,
112
+ 'pass { to: user.email } — one address, or a non-empty array of addresses',
113
+ );
114
+
115
+ const input = parse(mail.input, data, `${mail.id}.input`);
116
+ const tz = options.tz ?? tryUseContext()?.tz ?? DEFAULT_TIME_ZONE;
117
+ const rendered = renderMail(mail, input, {
118
+ locale: options.locale,
119
+ tz,
120
+ unsubscribeUrl: options.unsubscribeUrl,
121
+ });
122
+
123
+ return {
124
+ mailId: mail.id,
125
+ to,
126
+ subject: rendered.subject,
127
+ html: rendered.html,
128
+ text: rendered.text,
129
+ locale: options.locale,
130
+ tz,
131
+ replyTo: options.replyTo,
132
+ cc: options.cc,
133
+ bcc: options.bcc,
134
+ unsubscribeUrl: options.unsubscribeUrl,
135
+ idempotencyKey: options.idempotencyKey,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Enqueues by default so a slow SMTP host never blocks a request. Falls back to an inline
141
+ * send when no job driver is configured (a script, a test, `x` one-shots) — the alternative
142
+ * would be silently dropping mail in exactly the setups where it is easiest to miss.
143
+ */
144
+ export async function send<I>(
145
+ mail: MailDefinition<I>,
146
+ data: I,
147
+ options: SendOptions,
148
+ ): Promise<SendResult> {
149
+ const message = renderMessage(mail, data, options);
150
+ const key = mailIdempotencyKey(message);
151
+ const queue = jobDriver();
152
+
153
+ if (options.sync === true || queue === undefined) {
154
+ // The key is reported even inline, so a caller can dedupe its own retries either way.
155
+ return { ...(await mailDriver().send(message)), idempotencyKey: key };
156
+ }
157
+
158
+ const enqueued = await queue.enqueue({
159
+ name: sendMailJob.name,
160
+ queue: sendMailJob.queue,
161
+ input: message,
162
+ idempotencyKey: key,
163
+ maxAttempts: sendMailJob.retry.attempts,
164
+ onConflict: 'dedupe',
165
+ });
166
+
167
+ return {
168
+ id: enqueued.id,
169
+ driver: 'queue',
170
+ accepted: [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])],
171
+ queued: true,
172
+ idempotencyKey: key,
173
+ };
174
+ }
175
+
176
+ /** Send by id — what the CLI, MCP tools and generated code use. `data` is parsed, not trusted. */
177
+ export async function sendById(
178
+ id: string,
179
+ data: unknown,
180
+ options: SendOptions,
181
+ ): Promise<SendResult> {
182
+ const mail = registry.get(id);
183
+ if (mail === undefined) throw templateUnknown(id, registeredMailIds());
184
+ // The one cast in the package: the registry erased `I`, and `send` re-parses `data`
185
+ // through the mail's own schema before it reaches the template.
186
+ return await send(mail as MailDefinition<unknown>, data, options);
187
+ }
188
+
189
+ /**
190
+ * The runtime backstop for JS callers and generated code — TypeScript already forbids
191
+ * omitting `locale`, so reaching this throw means the type was cast away somewhere.
192
+ */
193
+ function assertLocale(mailId: string, locale: unknown): asserts locale is string {
194
+ if (typeof locale !== 'string' || locale.trim() === '') throw localeMissing(mailId);
195
+ }