toiljs 0.0.44 → 0.0.46

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 (62) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/RSG.md +105 -27
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +12 -2
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +4 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/email-preview.d.ts +12 -0
  9. package/build/compiler/email-preview.js +253 -0
  10. package/build/compiler/emails.d.ts +6 -3
  11. package/build/compiler/emails.js +52 -12
  12. package/build/compiler/index.js +15 -0
  13. package/build/compiler/plugin.js +64 -2
  14. package/build/compiler/vite.js +1 -0
  15. package/build/devserver/.tsbuildinfo +1 -1
  16. package/build/devserver/dotenv.d.ts +8 -0
  17. package/build/devserver/dotenv.js +59 -0
  18. package/build/devserver/email/caps.d.ts +9 -0
  19. package/build/devserver/email/caps.js +0 -0
  20. package/build/devserver/email/config.d.ts +21 -0
  21. package/build/devserver/email/config.js +72 -0
  22. package/build/devserver/email/index.d.ts +25 -0
  23. package/build/devserver/email/index.js +57 -0
  24. package/build/devserver/email/providers.d.ts +12 -0
  25. package/build/devserver/email/providers.js +96 -0
  26. package/build/devserver/email/status.d.ts +10 -0
  27. package/build/devserver/email/status.js +11 -0
  28. package/build/devserver/email/validate.d.ts +2 -0
  29. package/build/devserver/email/validate.js +24 -0
  30. package/build/devserver/email/wire.d.ts +8 -0
  31. package/build/devserver/email/wire.js +32 -0
  32. package/build/devserver/env.js +5 -54
  33. package/build/devserver/host.js +22 -7
  34. package/build/devserver/index.d.ts +2 -0
  35. package/build/devserver/index.js +8 -0
  36. package/build/shared/.tsbuildinfo +1 -1
  37. package/build/shared/index.d.ts +13 -0
  38. package/docs/email.md +64 -22
  39. package/package.json +4 -2
  40. package/src/cli/create.ts +2 -2
  41. package/src/cli/doctor.ts +15 -0
  42. package/src/compiler/config.ts +14 -0
  43. package/src/compiler/email-preview.ts +305 -0
  44. package/src/compiler/emails.ts +82 -12
  45. package/src/compiler/index.ts +20 -0
  46. package/src/compiler/plugin.ts +88 -4
  47. package/src/compiler/vite.ts +4 -0
  48. package/src/devserver/dotenv.ts +94 -0
  49. package/src/devserver/email/caps.ts +0 -0
  50. package/src/devserver/email/config.ts +123 -0
  51. package/src/devserver/email/index.ts +111 -0
  52. package/src/devserver/email/providers.ts +130 -0
  53. package/src/devserver/email/status.ts +23 -0
  54. package/src/devserver/email/validate.ts +40 -0
  55. package/src/devserver/email/wire.ts +55 -0
  56. package/src/devserver/env.ts +8 -65
  57. package/src/devserver/host.ts +29 -12
  58. package/src/devserver/index.ts +20 -0
  59. package/src/shared/index.ts +36 -0
  60. package/test/devserver-email.test.ts +241 -0
  61. package/test/email-preview.test.ts +68 -0
  62. package/test/emails.test.ts +58 -0
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { type Plugin, version as viteVersion } from 'vite';
9
9
 
10
10
  import { AiProvider, type DevtoolsAiConfig, type ResolvedToilConfig } from './config.js';
11
+ import { emailsVersion, listEmails, previewShellHtml, renderEmailByName } from './email-preview.js';
11
12
  import { generate } from './generate.js';
12
13
  import { scanRoutes } from './routes.js';
13
14
 
@@ -58,7 +59,9 @@ async function aiComplete(ai: DevtoolsAiConfig, prompt: string): Promise<string>
58
59
  /** Reads a package's version resolved from `<fromDir>`, or 'unknown'. */
59
60
  function depVersion(fromDir: string, name: string): string {
60
61
  try {
61
- const pkgPath = createRequire(path.join(fromDir, 'package.json')).resolve(`${name}/package.json`);
62
+ const pkgPath = createRequire(path.join(fromDir, 'package.json')).resolve(
63
+ `${name}/package.json`,
64
+ );
62
65
  const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
63
66
  return raw.version ?? 'unknown';
64
67
  } catch {
@@ -69,7 +72,12 @@ function depVersion(fromDir: string, name: string): string {
69
72
  /** toiljs's own version (package.json two levels up from build/compiler). */
70
73
  function frameworkVersion(): string {
71
74
  try {
72
- const p = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
75
+ const p = path.resolve(
76
+ path.dirname(fileURLToPath(import.meta.url)),
77
+ '..',
78
+ '..',
79
+ 'package.json',
80
+ );
73
81
  const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as { version?: string };
74
82
  return raw.version ?? '0.0.0';
75
83
  } catch {
@@ -231,7 +239,9 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
231
239
  const parsed = JSON.parse(body || '{}') as { prompt?: string };
232
240
  // Cap the prompt actually forwarded upstream (independent of the raw-body cap).
233
241
  const prompt =
234
- typeof parsed.prompt === 'string' ? parsed.prompt.slice(0, 16000) : '';
242
+ typeof parsed.prompt === 'string'
243
+ ? parsed.prompt.slice(0, 16000)
244
+ : '';
235
245
  const text = await aiComplete(ai, prompt);
236
246
  res.setHeader('content-type', 'application/json');
237
247
  res.end(JSON.stringify({ text }));
@@ -243,7 +253,11 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
243
253
  );
244
254
  res.statusCode = 500;
245
255
  res.setHeader('content-type', 'application/json');
246
- res.end(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
256
+ res.end(
257
+ JSON.stringify({
258
+ error: 'AI request failed (see dev server logs).',
259
+ }),
260
+ );
247
261
  }
248
262
  })();
249
263
  });
@@ -291,6 +305,76 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
291
305
  }
292
306
  });
293
307
 
308
+ // Email preview tool (dev only). `/__toil/emails` -> a standalone page that lists
309
+ // `emails/*.tsx`, renders the selected one through the live SSR server (so edits and
310
+ // imported `client/*` CSS show), and live-refreshes by polling `/version`.
311
+ // Sub-paths are registered before the page so connect's prefix match resolves them first.
312
+ server.middlewares.use('/__toil/emails/list', (req, res) => {
313
+ if (isCrossSiteRequest(req.headers)) {
314
+ res.statusCode = 403;
315
+ res.end();
316
+ return;
317
+ }
318
+ res.setHeader('content-type', 'application/json');
319
+ res.end(JSON.stringify(listEmails(cfg)));
320
+ });
321
+ server.middlewares.use('/__toil/emails/render', (req, res) => {
322
+ if (isCrossSiteRequest(req.headers)) {
323
+ res.statusCode = 403;
324
+ res.end();
325
+ return;
326
+ }
327
+ void (async () => {
328
+ try {
329
+ const name = new URL(req.url ?? '', 'http://localhost').searchParams.get(
330
+ 'name',
331
+ );
332
+ if (!name) {
333
+ res.statusCode = 400;
334
+ res.end();
335
+ return;
336
+ }
337
+ const rendered = await renderEmailByName(server, cfg, name);
338
+ if (!rendered) {
339
+ res.statusCode = 404;
340
+ res.end();
341
+ return;
342
+ }
343
+ res.setHeader('content-type', 'application/json');
344
+ res.end(JSON.stringify(rendered));
345
+ } catch (e) {
346
+ // Log the detail to the dev's terminal; the page shows a generic message.
347
+ process.stderr.write(
348
+ `toil: /__toil/emails/render failed: ${e instanceof Error ? e.message : String(e)}\n`,
349
+ );
350
+ res.statusCode = 500;
351
+ res.end();
352
+ }
353
+ })();
354
+ });
355
+ // A tiny mtime fingerprint of `emails/*` + client CSS the page polls (~1s) to detect
356
+ // edits. Polling (not SSE) because the wasm dev server proxies `/__toil/*` to Vite by
357
+ // buffering the whole response, so a long-lived stream would hang; a short poll works
358
+ // in every mode.
359
+ server.middlewares.use('/__toil/emails/version', (req, res) => {
360
+ if (isCrossSiteRequest(req.headers)) {
361
+ res.statusCode = 403;
362
+ res.end();
363
+ return;
364
+ }
365
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
366
+ res.end(emailsVersion(cfg));
367
+ });
368
+ server.middlewares.use('/__toil/emails', (req, res) => {
369
+ if (isCrossSiteRequest(req.headers)) {
370
+ res.statusCode = 403;
371
+ res.end();
372
+ return;
373
+ }
374
+ res.setHeader('content-type', 'text/html; charset=utf-8');
375
+ res.end(previewShellHtml());
376
+ });
377
+
294
378
  // Trailing slash so a sibling like `routes-extra/` doesn't match the `routes/` prefix.
295
379
  const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
296
380
  const onChange = (file: string): void => {
@@ -154,6 +154,10 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
154
154
  alias: {
155
155
  'toiljs/client': cfg.runtimePath,
156
156
  'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
157
+ // `client/*` -> the project's client source dir, so anything (pages, and notably
158
+ // `emails/*.tsx`) can reuse existing client assets, e.g. `import 'client/styles/x.css'`.
159
+ // Vite's string alias matches only `client` or `client/...`, never `toiljs/client`.
160
+ client: cfg.clientAbsDir,
157
161
  // `shared/*` is resolved by sharedResolverPlugin (above) so a missing generated
158
162
  // shared/server.ts gives an actionable error instead of an opaque load failure.
159
163
  ...polyfillShimAliases,
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared dotenv loader for the dev server (and the future Node self-host): reads
3
+ * a project's `.env` (plain vars) + `.env.secrets` (secrets) and splits out the
4
+ * framework-reserved `TOIL_*` keys (host-only), mirroring the edge's `env_store`.
5
+ *
6
+ * Vite/devserver-free on purpose — both the `Environment.get/getSecure` dev
7
+ * source (`./env.ts`) and the email backend config (`./email/config.ts`) consume
8
+ * this one loader, and the self-host will too.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ /** Keys with this prefix are framework-reserved/host-only (never a tenant var/secret). */
14
+ export const RESERVED_PREFIX = 'TOIL_';
15
+
16
+ export interface LoadedEnv {
17
+ /** Non-reserved `.env` entries + `process.env` overlay → `Environment.get`. */
18
+ readonly vars: Map<string, string>;
19
+ /** Non-reserved `.env.secrets` entries → `Environment.getSecure`. */
20
+ readonly secrets: Map<string, string>;
21
+ /** Reserved `TOIL_*` entries from either file (+ `process.env`) → host-only config. */
22
+ readonly reserved: Map<string, string>;
23
+ }
24
+
25
+ const cache = new Map<string, LoadedEnv>();
26
+
27
+ /** Parse one dotenv value: take inside matching quotes, else cut an inline ` #`. */
28
+ function parseValue(rest: string): string {
29
+ const q = rest[0];
30
+ if (q === '"' || q === "'") {
31
+ const end = rest.indexOf(q, 1);
32
+ return end < 0 ? rest.slice(1) : rest.slice(1, end);
33
+ }
34
+ const hash = rest.indexOf(' #');
35
+ return (hash < 0 ? rest : rest.slice(0, hash)).trimEnd();
36
+ }
37
+
38
+ /**
39
+ * Parse dotenv text into `plain` (non-reserved) and `reserved` (`TOIL_*`):
40
+ * `KEY=value`, `#` comments, optional `export`, optional surrounding quotes.
41
+ */
42
+ function parseDotenv(text: string, plain: Map<string, string>, reserved: Map<string, string>): void {
43
+ for (const raw of text.split('\n')) {
44
+ let line = raw.trim();
45
+ if (line.length === 0 || line.startsWith('#')) continue;
46
+ if (line.startsWith('export ')) line = line.slice('export '.length);
47
+ const eq = line.indexOf('=');
48
+ if (eq < 0) continue;
49
+ const key = line.slice(0, eq).trim();
50
+ if (key.length === 0) continue;
51
+ const val = parseValue(line.slice(eq + 1).trim());
52
+ (key.startsWith(RESERVED_PREFIX) ? reserved : plain).set(key, val);
53
+ }
54
+ }
55
+
56
+ function readFileInto(file: string, plain: Map<string, string>, reserved: Map<string, string>): void {
57
+ try {
58
+ parseDotenv(fs.readFileSync(file, 'utf8'), plain, reserved);
59
+ } catch {
60
+ /* file absent: skip */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Load `<root>/.env` + `<root>/.env.secrets`, cached by resolved root. `process.env`
66
+ * overlays first (non-reserved → vars, `TOIL_*` → reserved), then the files take
67
+ * precedence. Secrets come only from `.env.secrets`.
68
+ */
69
+ export function loadEnvFiles(root: string): LoadedEnv {
70
+ const key = path.resolve(root);
71
+ const hit = cache.get(key);
72
+ if (hit) return hit;
73
+
74
+ const vars = new Map<string, string>();
75
+ const secrets = new Map<string, string>();
76
+ const reserved = new Map<string, string>();
77
+
78
+ // process.env overlay: non-reserved as plain vars, TOIL_* as reserved config.
79
+ for (const [k, v] of Object.entries(process.env)) {
80
+ if (typeof v !== 'string') continue;
81
+ (k.startsWith(RESERVED_PREFIX) ? reserved : vars).set(k, v);
82
+ }
83
+ readFileInto(path.join(key, '.env'), vars, reserved);
84
+ readFileInto(path.join(key, '.env.secrets'), secrets, reserved);
85
+
86
+ const out: LoadedEnv = { vars, secrets, reserved };
87
+ cache.set(key, out);
88
+ return out;
89
+ }
90
+
91
+ /** Drop the cache (tests). */
92
+ export function clearEnvCache(): void {
93
+ cache.clear();
94
+ }
Binary file
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Resolve the dev / self-host email config: merge the typed `toil.config.ts`
3
+ * `email` section (non-secret) with the reserved `TOIL_EMAIL_*` env keys (which
4
+ * WIN, for edge parity + 12-factor), and pull the API key from
5
+ * `TOIL_EMAIL_API_KEY` (a secret, only ever in `.env.secrets`/the environment).
6
+ *
7
+ * Mirrors the edge's `host/email.rs` (`EmailSection::from_reserved` + `resolve`):
8
+ * provider parse, `from` validation, Gmail/SMTP defaults. Returns a `null`
9
+ * config when email is simply not set up (silent), or a `warning` when it is
10
+ * partially configured but invalid (logged at startup, treated as Disabled).
11
+ */
12
+ import type { EmailBackendConfig } from 'toiljs/shared';
13
+
14
+ import { validFrom } from './validate.js';
15
+
16
+ export type ResolvedProvider = 'resend' | 'smtp';
17
+
18
+ export interface ResolvedSmtp {
19
+ readonly host: string;
20
+ readonly port: number;
21
+ readonly user: string;
22
+ }
23
+
24
+ export interface ResolvedEmailConfig {
25
+ readonly provider: ResolvedProvider;
26
+ readonly from: string;
27
+ readonly apiKey: string;
28
+ readonly maxPerMin: number;
29
+ readonly maxPerDay: number;
30
+ readonly maxPerRecipientPerHour: number;
31
+ /** Present iff `provider === 'smtp'`. */
32
+ readonly smtp?: ResolvedSmtp;
33
+ }
34
+
35
+ export interface ResolveResult {
36
+ /** The resolved config, or `null` when email is unconfigured / invalid. */
37
+ readonly config: ResolvedEmailConfig | null;
38
+ /** A reason email is off despite partial config (logged at startup). */
39
+ readonly warning: string | null;
40
+ }
41
+
42
+ /** `TOIL_EMAIL_<NAME>` from the reserved map, trimmed; `undefined` if absent/empty. */
43
+ function envOf(reserved: Map<string, string>, name: string): string | undefined {
44
+ const v = reserved.get(`TOIL_EMAIL_${name}`);
45
+ const t = v?.trim();
46
+ return t ? t : undefined;
47
+ }
48
+
49
+ function parseBool(v: string | undefined): boolean | undefined {
50
+ if (v === undefined) return undefined;
51
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
52
+ }
53
+
54
+ function parseInt0(v: string | undefined, fallback: number): number {
55
+ if (v === undefined) return fallback;
56
+ const n = Number.parseInt(v, 10);
57
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
58
+ }
59
+
60
+ export function resolveEmailConfig(
61
+ cfg: EmailBackendConfig | null | undefined,
62
+ reserved: Map<string, string>,
63
+ ): ResolveResult {
64
+ const c = cfg ?? {};
65
+ // Env (TOIL_EMAIL_*) overrides the config file; api key is env-only.
66
+ const providerId = (envOf(reserved, 'PROVIDER') ?? c.provider ?? 'resend').toLowerCase();
67
+ const from = envOf(reserved, 'FROM') ?? c.from?.trim();
68
+ const apiKey = envOf(reserved, 'API_KEY');
69
+ const enabled = parseBool(envOf(reserved, 'ENABLED'));
70
+
71
+ // Unconfigured: explicitly disabled, or no `from` and no key at all -> silent off.
72
+ if (enabled === false) return { config: null, warning: null };
73
+ if (from === undefined && apiKey === undefined && cfg == null) {
74
+ return { config: null, warning: null };
75
+ }
76
+
77
+ if (from === undefined) {
78
+ return { config: null, warning: 'email config present but `from` is missing' };
79
+ }
80
+ if (!validFrom(from)) {
81
+ return { config: null, warning: 'email `from` is not a valid address (CRLF or no `@`)' };
82
+ }
83
+ if (apiKey === undefined) {
84
+ return {
85
+ config: null,
86
+ warning: 'email config present but TOIL_EMAIL_API_KEY is not set (in .env.secrets)',
87
+ };
88
+ }
89
+
90
+ let provider: ResolvedProvider;
91
+ let smtp: ResolvedSmtp | undefined;
92
+ if (providerId === 'resend') {
93
+ provider = 'resend';
94
+ } else if (providerId === 'gmail' || providerId === 'smtp') {
95
+ provider = 'smtp';
96
+ const isGmail = providerId === 'gmail';
97
+ const host = envOf(reserved, 'SMTP_HOST') ?? c.smtp?.host?.trim() ?? (isGmail ? 'smtp.gmail.com' : '');
98
+ if (!host) {
99
+ return { config: null, warning: 'provider `smtp` requires TOIL_EMAIL_SMTP_HOST' };
100
+ }
101
+ const port = parseInt0(envOf(reserved, 'SMTP_PORT'), c.smtp?.port ?? 0) || 587;
102
+ const user = envOf(reserved, 'SMTP_USER') ?? c.smtp?.user?.trim() ?? from;
103
+ smtp = { host, port, user };
104
+ } else {
105
+ return { config: null, warning: `unknown email provider "${providerId}" (resend|gmail|smtp)` };
106
+ }
107
+
108
+ return {
109
+ config: {
110
+ provider,
111
+ from,
112
+ apiKey,
113
+ maxPerMin: parseInt0(envOf(reserved, 'MAX_PER_MIN'), c.maxPerMin ?? 60),
114
+ maxPerDay: parseInt0(envOf(reserved, 'MAX_PER_DAY'), c.maxPerDay ?? 0),
115
+ maxPerRecipientPerHour: parseInt0(
116
+ envOf(reserved, 'MAX_PER_RECIPIENT_PER_HOUR'),
117
+ c.maxPerRecipientPerHour ?? 5,
118
+ ),
119
+ smtp,
120
+ },
121
+ warning: null,
122
+ };
123
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The dev / self-host email service — the full edge pipeline in Node, reusable
3
+ * by the future self-host runtime (this folder has NO Vite/devserver coupling).
4
+ *
5
+ * Split for the sync-wasm constraint: `prepare()` is SYNCHRONOUS (parse +
6
+ * validate + dedup + budget + per-recipient cap) and returns the terminal status
7
+ * to hand the guest now; `deliver()` is ASYNC (the actual provider send). A
8
+ * synchronous host (the dev server) fires `deliver()` and returns `Sent`
9
+ * optimistically; an async-capable host (a future self-host) can `await deliver()`
10
+ * for the true status.
11
+ */
12
+ import { loadEnvFiles } from '../dotenv.js';
13
+ import { EmailCaps } from './caps.js';
14
+ import { resolveEmailConfig, type ResolvedEmailConfig } from './config.js';
15
+ import { sendVia, type OutboundMessage } from './providers.js';
16
+ import { EmailStatus } from './status.js';
17
+ import { validRecipient } from './validate.js';
18
+ import { parseEmailBlob, type ParsedEmail } from './wire.js';
19
+
20
+ import type { EmailBackendConfig } from 'toiljs/shared';
21
+
22
+ export { EmailStatus } from './status.js';
23
+ export type { ResolvedEmailConfig } from './config.js';
24
+
25
+ export interface PrepareResult {
26
+ /** The status to return to the guest now. `Sent` means proceed to `deliver`. */
27
+ readonly status: EmailStatus;
28
+ /** The parsed message, present iff `status === Sent` (caller should deliver). */
29
+ readonly parsed: ParsedEmail | null;
30
+ }
31
+
32
+ export class NodeEmailService {
33
+ private readonly caps = new EmailCaps();
34
+ constructor(private readonly config: ResolvedEmailConfig) {}
35
+
36
+ /** The configured provider id (for the startup banner / logs). */
37
+ get providerLabel(): string {
38
+ return `${this.config.provider} (${this.config.from})`;
39
+ }
40
+
41
+ /**
42
+ * Synchronous pre-send: parse the wire blob, validate the recipient, then
43
+ * dedup + per-minute/day budget + per-recipient cap (all committing). Returns
44
+ * a terminal status (`BadRecipient` / `Deduped` / `Budget` / `RecipientCapped`),
45
+ * or `{ status: Sent, parsed }` meaning the caller should `deliver(parsed)`.
46
+ */
47
+ prepare(blob: Buffer, now: number = Date.now()): PrepareResult {
48
+ const parsed = parseEmailBlob(blob);
49
+ if (parsed === null || !validRecipient(parsed.to)) {
50
+ return { status: EmailStatus.BadRecipient, parsed: null };
51
+ }
52
+ if (this.caps.isDuplicate(parsed.to, parsed.purpose, now)) {
53
+ return { status: EmailStatus.Deduped, parsed: null };
54
+ }
55
+ if (!this.caps.budgetOk(this.config.maxPerMin, this.config.maxPerDay, now)) {
56
+ return { status: EmailStatus.Budget, parsed: null };
57
+ }
58
+ if (!this.caps.recipientOk(parsed.to, this.config.maxPerRecipientPerHour, now)) {
59
+ return { status: EmailStatus.RecipientCapped, parsed: null };
60
+ }
61
+ return { status: EmailStatus.Sent, parsed };
62
+ }
63
+
64
+ /** Actually send via the configured provider; resolves to the real status. */
65
+ deliver(parsed: ParsedEmail): Promise<EmailStatus> {
66
+ const msg: OutboundMessage = {
67
+ from: this.config.from,
68
+ to: parsed.to,
69
+ subject: parsed.subject,
70
+ body: parsed.body,
71
+ html: parsed.html,
72
+ };
73
+ return sendVia(this.config, msg);
74
+ }
75
+ }
76
+
77
+ // --- Process-level singleton (one project per dev/self-host process) ----------
78
+
79
+ let service: NodeEmailService | null = null;
80
+
81
+ export interface EmailInitResult {
82
+ /** The service, or `null` when email is unconfigured / invalid. */
83
+ readonly service: NodeEmailService | null;
84
+ /** A note for the startup banner: the provider label, or a config warning. */
85
+ readonly note: string | null;
86
+ }
87
+
88
+ /**
89
+ * Build the email service from the `toil.config.ts` `email` section + the
90
+ * project's `.env.secrets` (`TOIL_EMAIL_*`, holding the API key) and install it
91
+ * as the process singleton. Call once at startup. Returns a startup note.
92
+ */
93
+ export function initEmailService(
94
+ root: string,
95
+ cfgEmail: EmailBackendConfig | null | undefined,
96
+ ): EmailInitResult {
97
+ const reserved = loadEnvFiles(root).reserved;
98
+ const { config, warning } = resolveEmailConfig(cfgEmail, reserved);
99
+ service = config === null ? null : new NodeEmailService(config);
100
+ return { service, note: service === null ? warning : service.providerLabel };
101
+ }
102
+
103
+ /** The installed email service, or `null` when email is not configured. */
104
+ export function getEmailService(): NodeEmailService | null {
105
+ return service;
106
+ }
107
+
108
+ /** Reset the singleton (tests). */
109
+ export function resetEmailService(): void {
110
+ service = null;
111
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Provider transports for the dev / self-host mailer, mirroring the edge's
3
+ * `mailer.rs`: Resend over `fetch` (`send_resend`) and SMTP over nodemailer
4
+ * (`send_smtp`). Both retry transient failures with capped backoff; a permanent
5
+ * rejection is terminal (`ProviderError`).
6
+ *
7
+ * nodemailer is imported lazily, so a Resend-only project never loads it.
8
+ */
9
+ import { EmailStatus } from './status.js';
10
+ import type { ResolvedEmailConfig } from './config.js';
11
+
12
+ const MAX_ATTEMPTS = 3;
13
+ const BACKOFF_BASE_MS = 200;
14
+ const POST_TIMEOUT_MS = 10_000;
15
+
16
+ /** The concrete parts of one send (already validated + rendered). */
17
+ export interface OutboundMessage {
18
+ readonly from: string;
19
+ readonly to: string;
20
+ readonly subject: string;
21
+ /** Plain-text body (may be empty when only `html` is set). */
22
+ readonly body: string;
23
+ /** Optional HTML body (empty = plain-text send). */
24
+ readonly html: string;
25
+ }
26
+
27
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
28
+
29
+ /**
30
+ * Classify an HTTP status (or `null` for a transport failure): 2xx → `Sent`,
31
+ * 4xx → terminal `ProviderError` (a bad address keeps being bad), else
32
+ * (5xx / transport) → retry.
33
+ */
34
+ function classifyHttp(status: number | null): EmailStatus | 'retry' {
35
+ if (status !== null && status >= 200 && status < 300) return EmailStatus.Sent;
36
+ if (status !== null && status >= 400 && status < 500) return EmailStatus.ProviderError;
37
+ return 'retry';
38
+ }
39
+
40
+ /** Drive one Resend POST with retry. */
41
+ export async function sendResend(
42
+ cfg: ResolvedEmailConfig,
43
+ msg: OutboundMessage,
44
+ ): Promise<EmailStatus> {
45
+ const payload: Record<string, unknown> = {
46
+ from: msg.from,
47
+ to: [msg.to],
48
+ subject: msg.subject,
49
+ };
50
+ if (msg.body.length > 0) payload.text = msg.body;
51
+ if (msg.html.length > 0) payload.html = msg.html;
52
+
53
+ let backoff = BACKOFF_BASE_MS;
54
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
55
+ let status: number | null = null;
56
+ try {
57
+ const res = await fetch('https://api.resend.com/emails', {
58
+ method: 'POST',
59
+ headers: {
60
+ authorization: `Bearer ${cfg.apiKey}`,
61
+ 'content-type': 'application/json',
62
+ },
63
+ body: JSON.stringify(payload),
64
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS),
65
+ });
66
+ status = res.status;
67
+ } catch {
68
+ status = null; // transport error → retry
69
+ }
70
+ const verdict = classifyHttp(status);
71
+ if (verdict !== 'retry') return verdict;
72
+ if (attempt === MAX_ATTEMPTS) break;
73
+ await sleep(backoff);
74
+ backoff *= 2;
75
+ }
76
+ return EmailStatus.ProviderError;
77
+ }
78
+
79
+ /** Send one email over SMTP (nodemailer) with retry. */
80
+ export async function sendSmtp(
81
+ cfg: ResolvedEmailConfig,
82
+ msg: OutboundMessage,
83
+ ): Promise<EmailStatus> {
84
+ const smtp = cfg.smtp;
85
+ if (smtp === undefined) return EmailStatus.ProviderError; // resolve() guarantees Some for smtp
86
+
87
+ let transporter: import('nodemailer').Transporter;
88
+ try {
89
+ const nodemailer = await import('nodemailer');
90
+ transporter = nodemailer.createTransport({
91
+ host: smtp.host,
92
+ port: smtp.port,
93
+ secure: smtp.port === 465, // 465 = implicit TLS; else STARTTLS
94
+ auth: { user: smtp.user, pass: cfg.apiKey },
95
+ connectionTimeout: POST_TIMEOUT_MS,
96
+ greetingTimeout: POST_TIMEOUT_MS,
97
+ });
98
+ } catch {
99
+ // nodemailer not installed: SMTP unavailable.
100
+ return EmailStatus.ProviderError;
101
+ }
102
+
103
+ let backoff = BACKOFF_BASE_MS;
104
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
105
+ try {
106
+ await transporter.sendMail({
107
+ from: msg.from,
108
+ to: msg.to,
109
+ subject: msg.subject,
110
+ text: msg.body.length > 0 ? msg.body : undefined,
111
+ html: msg.html.length > 0 ? msg.html : undefined,
112
+ });
113
+ return EmailStatus.Sent;
114
+ } catch (e) {
115
+ // SMTP 5xx is a PERMANENT failure (bad auth / rejected recipient):
116
+ // terminal. 4xx / transport errors are transient: retry with backoff.
117
+ const code = (e as { responseCode?: number }).responseCode;
118
+ if (typeof code === 'number' && code >= 500) return EmailStatus.ProviderError;
119
+ if (attempt === MAX_ATTEMPTS) break;
120
+ await sleep(backoff);
121
+ backoff *= 2;
122
+ }
123
+ }
124
+ return EmailStatus.ProviderError;
125
+ }
126
+
127
+ /** Dispatch to the configured provider. */
128
+ export function sendVia(cfg: ResolvedEmailConfig, msg: OutboundMessage): Promise<EmailStatus> {
129
+ return cfg.provider === 'resend' ? sendResend(cfg, msg) : sendSmtp(cfg, msg);
130
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `i32` status `env::email_send` returns to the guest. Byte-identical to the
3
+ * edge's `EmailStatus` (toil-backend `host/email.rs`, `#[repr(i32)]`) and the
4
+ * guest `EmailStatus` enum (`server/globals/email.ts`). `Sent` and `Deduped` are
5
+ * success; the rest say why it was not delivered and whether a retry could help.
6
+ */
7
+ export enum EmailStatus {
8
+ Sent = 0,
9
+ /** No email config (or not enabled). */
10
+ Disabled = 1,
11
+ /** Per-process minute/day budget exhausted. Retriable later. */
12
+ Budget = 2,
13
+ /** Per-recipient hourly cap hit. Terminal for this recipient/window. */
14
+ RecipientCapped = 3,
15
+ /** An identical recent (recipient, purpose) send was collapsed. Treat as sent. */
16
+ Deduped = 4,
17
+ /** Saturated / queue full. Retriable; back off. */
18
+ TryLater = 5,
19
+ /** The recipient failed host-side validation (CRLF, multiple addresses, malformed). */
20
+ BadRecipient = 6,
21
+ /** The provider rejected the send, or transport failed after retries. Terminal. */
22
+ ProviderError = 7,
23
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Strict single-recipient check for the guest-supplied `to`, byte-for-byte the
3
+ * edge's (`host/email.rs::valid_recipient`). Rejects header injection
4
+ * (CR/LF/NUL), multiple addresses (comma, semicolon, whitespace, angle
5
+ * brackets, quotes), and the obviously malformed, so a guest can never smuggle a
6
+ * Bcc or a second envelope recipient into the provider call. Exactly one `@`,
7
+ * non-empty local part, dotted domain.
8
+ */
9
+ const FORBIDDEN = new Set(['\r', '\n', '\0', ',', ';', ' ', '\t', '<', '>', '"']);
10
+
11
+ export function validRecipient(s: string): boolean {
12
+ if (s.length === 0 || Buffer.byteLength(s, 'utf8') > 320) return false;
13
+ for (const ch of s) {
14
+ if (FORBIDDEN.has(ch)) return false;
15
+ }
16
+ const parts = s.split('@');
17
+ if (parts.length !== 2) return false; // not exactly one '@'
18
+ const [local, domain] = parts;
19
+ return (
20
+ local.length > 0 &&
21
+ domain.includes('.') &&
22
+ !domain.startsWith('.') &&
23
+ !domain.endsWith('.')
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Lenient validation of the operator-supplied `from` (`host/email.rs::valid_from`):
29
+ * trusted config, so the only hard requirement is no header injection; the
30
+ * provider validates deliverability.
31
+ */
32
+ export function validFrom(s: string): boolean {
33
+ return (
34
+ Buffer.byteLength(s, 'utf8') <= 320 &&
35
+ s.includes('@') &&
36
+ !s.includes('\r') &&
37
+ !s.includes('\n') &&
38
+ !s.includes('\0')
39
+ );
40
+ }