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.
- package/CHANGELOG.md +9 -0
- package/RSG.md +105 -27
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +12 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +4 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/email-preview.d.ts +12 -0
- package/build/compiler/email-preview.js +253 -0
- package/build/compiler/emails.d.ts +6 -3
- package/build/compiler/emails.js +52 -12
- package/build/compiler/index.js +15 -0
- package/build/compiler/plugin.js +64 -2
- package/build/compiler/vite.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/dotenv.d.ts +8 -0
- package/build/devserver/dotenv.js +59 -0
- package/build/devserver/email/caps.d.ts +9 -0
- package/build/devserver/email/caps.js +0 -0
- package/build/devserver/email/config.d.ts +21 -0
- package/build/devserver/email/config.js +72 -0
- package/build/devserver/email/index.d.ts +25 -0
- package/build/devserver/email/index.js +57 -0
- package/build/devserver/email/providers.d.ts +12 -0
- package/build/devserver/email/providers.js +96 -0
- package/build/devserver/email/status.d.ts +10 -0
- package/build/devserver/email/status.js +11 -0
- package/build/devserver/email/validate.d.ts +2 -0
- package/build/devserver/email/validate.js +24 -0
- package/build/devserver/email/wire.d.ts +8 -0
- package/build/devserver/email/wire.js +32 -0
- package/build/devserver/env.js +5 -54
- package/build/devserver/host.js +22 -7
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +8 -0
- package/build/shared/.tsbuildinfo +1 -1
- package/build/shared/index.d.ts +13 -0
- package/docs/email.md +64 -22
- package/package.json +4 -2
- package/src/cli/create.ts +2 -2
- package/src/cli/doctor.ts +15 -0
- package/src/compiler/config.ts +14 -0
- package/src/compiler/email-preview.ts +305 -0
- package/src/compiler/emails.ts +82 -12
- package/src/compiler/index.ts +20 -0
- package/src/compiler/plugin.ts +88 -4
- package/src/compiler/vite.ts +4 -0
- package/src/devserver/dotenv.ts +94 -0
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +123 -0
- package/src/devserver/email/index.ts +111 -0
- package/src/devserver/email/providers.ts +130 -0
- package/src/devserver/email/status.ts +23 -0
- package/src/devserver/email/validate.ts +40 -0
- package/src/devserver/email/wire.ts +55 -0
- package/src/devserver/env.ts +8 -65
- package/src/devserver/host.ts +29 -12
- package/src/devserver/index.ts +20 -0
- package/src/shared/index.ts +36 -0
- package/test/devserver-email.test.ts +241 -0
- package/test/email-preview.test.ts +68 -0
- package/test/emails.test.ts +58 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode the `email_send` request blob the guest writes into wasm memory — the
|
|
3
|
+
* v2 wire format, byte-for-byte the edge's (`email_send_import.rs::parse_request`
|
|
4
|
+
* and `server/globals/email.ts`):
|
|
5
|
+
*
|
|
6
|
+
* u16 to_len | u16 subject_len | u16 purpose_len | u32 body_len | u32 html_len
|
|
7
|
+
* [to][subject][purpose][body][html] (14-byte LE header, then UTF-8 payloads)
|
|
8
|
+
*
|
|
9
|
+
* `html_len == 0` is a plain-text send. Returns `null` on truncation, a length
|
|
10
|
+
* that overruns the blob, trailing garbage, or non-UTF-8 — all guest encode bugs
|
|
11
|
+
* the caller maps to `BadRecipient`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ParsedEmail {
|
|
14
|
+
readonly to: string;
|
|
15
|
+
readonly subject: string;
|
|
16
|
+
readonly purpose: string;
|
|
17
|
+
readonly body: string;
|
|
18
|
+
readonly html: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HEADER_LEN = 14;
|
|
22
|
+
|
|
23
|
+
export function parseEmailBlob(raw: Buffer): ParsedEmail | null {
|
|
24
|
+
if (raw.length < HEADER_LEN) return null;
|
|
25
|
+
const toLen = raw.readUInt16LE(0);
|
|
26
|
+
const subjectLen = raw.readUInt16LE(2);
|
|
27
|
+
const purposeLen = raw.readUInt16LE(4);
|
|
28
|
+
const bodyLen = raw.readUInt32LE(6);
|
|
29
|
+
const htmlLen = raw.readUInt32LE(10);
|
|
30
|
+
|
|
31
|
+
const total = toLen + subjectLen + purposeLen + bodyLen + htmlLen;
|
|
32
|
+
// Exact fit: the five payloads must consume the rest of the blob precisely.
|
|
33
|
+
if (HEADER_LEN + total !== raw.length) return null;
|
|
34
|
+
|
|
35
|
+
let off = HEADER_LEN;
|
|
36
|
+
const take = (n: number): string | null => {
|
|
37
|
+
const end = off + n;
|
|
38
|
+
const slice = raw.subarray(off, end);
|
|
39
|
+
// Reject invalid UTF-8 (Buffer.toString is lossy, so verify by round-trip).
|
|
40
|
+
const s = slice.toString('utf8');
|
|
41
|
+
if (Buffer.byteLength(s, 'utf8') !== n) return null;
|
|
42
|
+
off = end;
|
|
43
|
+
return s;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const to = take(toLen);
|
|
47
|
+
const subject = take(subjectLen);
|
|
48
|
+
const purpose = take(purposeLen);
|
|
49
|
+
const body = take(bodyLen);
|
|
50
|
+
const html = take(htmlLen);
|
|
51
|
+
if (to === null || subject === null || purpose === null || body === null || html === null) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return { to, subject, purpose, body, html };
|
|
55
|
+
}
|
package/src/devserver/env.ts
CHANGED
|
@@ -1,87 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dev-only source for `Environment.get` / `getSecure`, mirroring the edge's
|
|
3
|
-
* per-tenant env store. Reads two optional dotenv files at the project root
|
|
3
|
+
* per-tenant env store. Reads two optional dotenv files at the project root via
|
|
4
|
+
* the shared loader (`./dotenv.ts`):
|
|
4
5
|
*
|
|
5
6
|
* - `.env` -> plain vars (`Environment.get`); `process.env` overlays
|
|
6
7
|
* - `.env.secrets` -> secrets (`Environment.getSecure`); keep gitignored
|
|
7
8
|
*
|
|
8
9
|
* DISJOINT like the edge: `get` sees only vars, `getSecure` only secrets, so a
|
|
9
10
|
* secret never comes back through `get`. Framework-reserved `TOIL_*` keys are
|
|
10
|
-
* host-only
|
|
11
|
+
* host-only (the email backend config reads them) and never a tenant var/secret.
|
|
11
12
|
*
|
|
12
13
|
* Cached after first read; restart `toiljs dev` to pick up edits. The edge
|
|
13
14
|
* resolves this PER TENANT from `$TOIL_ENV_DIR/<host>.env` + `<host>.env.secrets`
|
|
14
15
|
* (and the edge DB later) through a lazy, bounded cache; dev has a single
|
|
15
16
|
* project, so two files are enough and no eviction is needed.
|
|
16
17
|
*/
|
|
17
|
-
import
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
|
|
20
|
-
/** Keys with this prefix are framework-reserved/host-only (never exposed). */
|
|
21
|
-
const RESERVED_PREFIX = 'TOIL_';
|
|
22
|
-
|
|
23
|
-
interface DevEnv {
|
|
24
|
-
vars: Map<string, string>;
|
|
25
|
-
secrets: Map<string, string>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let cache: DevEnv | null = null;
|
|
29
|
-
|
|
30
|
-
/** Parse one dotenv value: take inside matching quotes, else cut an inline ` #`. */
|
|
31
|
-
function parseValue(rest: string): string {
|
|
32
|
-
const q = rest[0];
|
|
33
|
-
if (q === '"' || q === "'") {
|
|
34
|
-
const end = rest.indexOf(q, 1);
|
|
35
|
-
return end < 0 ? rest.slice(1) : rest.slice(1, end);
|
|
36
|
-
}
|
|
37
|
-
const hash = rest.indexOf(' #');
|
|
38
|
-
return (hash < 0 ? rest : rest.slice(0, hash)).trimEnd();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Minimal dotenv parser: `KEY=value`, `#` comments, optional `export`, quotes. */
|
|
42
|
-
function parseDotenv(text: string, into: 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 || key.startsWith(RESERVED_PREFIX)) continue; // reserved/host-only
|
|
51
|
-
into.set(key, parseValue(line.slice(eq + 1).trim()));
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function readFileInto(file: string, into: Map<string, string>): void {
|
|
56
|
-
try {
|
|
57
|
-
parseDotenv(fs.readFileSync(path.join(process.cwd(), file), 'utf8'), into);
|
|
58
|
-
} catch {
|
|
59
|
-
/* file absent: skip */
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function load(): DevEnv {
|
|
64
|
-
if (cache) return cache;
|
|
65
|
-
const vars = new Map<string, string>();
|
|
66
|
-
const secrets = new Map<string, string>();
|
|
67
|
-
// process.env overlays as plain vars (convenient in dev); never as secrets.
|
|
68
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
69
|
-
if (typeof v === 'string' && !k.startsWith(RESERVED_PREFIX)) vars.set(k, v);
|
|
70
|
-
}
|
|
71
|
-
readFileInto('.env', vars);
|
|
72
|
-
readFileInto('.env.secrets', secrets);
|
|
73
|
-
cache = { vars, secrets };
|
|
74
|
-
return cache;
|
|
75
|
-
}
|
|
18
|
+
import { loadEnvFiles } from './dotenv.js';
|
|
76
19
|
|
|
77
20
|
/** A plain var by exact key, or `null`. Reads ONLY the `.env` (vars) bucket. */
|
|
78
21
|
export function devEnvGet(key: string): string | null {
|
|
79
|
-
const
|
|
80
|
-
return
|
|
22
|
+
const v = loadEnvFiles(process.cwd()).vars.get(key);
|
|
23
|
+
return v === undefined ? null : v;
|
|
81
24
|
}
|
|
82
25
|
|
|
83
26
|
/** A secret by exact key, or `null`. Reads ONLY the `.env.secrets` bucket. */
|
|
84
27
|
export function devEnvGetSecure(key: string): string | null {
|
|
85
|
-
const
|
|
86
|
-
return
|
|
28
|
+
const v = loadEnvFiles(process.cwd()).secrets.get(key);
|
|
29
|
+
return v === undefined ? null : v;
|
|
87
30
|
}
|
package/src/devserver/host.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
|
|
21
|
+
import { EmailStatus, getEmailService } from './email/index.js';
|
|
22
|
+
import { parseEmailBlob } from './email/wire.js';
|
|
21
23
|
import { devEnvGet, devEnvGetSecure } from './env.js';
|
|
22
24
|
import { ratelimitCheck } from './ratelimit.js';
|
|
23
25
|
|
|
@@ -210,21 +212,36 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
|
|
|
210
212
|
return d.allowed ? 1 : -Math.max(1, d.retryAfterSecs);
|
|
211
213
|
},
|
|
212
214
|
|
|
213
|
-
// `env::email_send`: the
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
215
|
+
// `env::email_send`: the FULL email pipeline in dev (./email): parse +
|
|
216
|
+
// recipient validation + dedup + per-min/day budget + per-recipient cap
|
|
217
|
+
// run SYNCHRONOUSLY (exact status — BadRecipient/Deduped/Budget/
|
|
218
|
+
// RecipientCapped), then the real provider send is FIRE-AND-FORGET (a
|
|
219
|
+
// sync wasm import can't await it), so the guest gets Sent optimistically
|
|
220
|
+
// and the true outcome is logged. Unconfigured email stays a log-only
|
|
221
|
+
// mock returning Sent. Mirrors the edge's `email_send_import.rs`.
|
|
217
222
|
email_send: (reqPtr: number, reqLen: number): number => {
|
|
218
|
-
// Header: u16 to_len | u16 subj_len | u16 purpose_len | u32 body_len
|
|
219
|
-
// | u32 html_len (14 bytes), then payloads; `to` is first.
|
|
220
223
|
const raw = readBytes(ref, reqPtr, reqLen);
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
const
|
|
224
|
-
|
|
224
|
+
const svc = getEmailService();
|
|
225
|
+
if (svc === null) {
|
|
226
|
+
const to = parseEmailBlob(raw)?.to ?? '<unparsed>';
|
|
227
|
+
process.stdout.write(` ✉ dev email_send -> ${to} (no email config; not sent)\n`);
|
|
228
|
+
return EmailStatus.Sent;
|
|
225
229
|
}
|
|
226
|
-
|
|
227
|
-
|
|
230
|
+
const { status, parsed } = svc.prepare(raw);
|
|
231
|
+
if (parsed === null) {
|
|
232
|
+
process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
|
|
233
|
+
return status;
|
|
234
|
+
}
|
|
235
|
+
void svc
|
|
236
|
+
.deliver(parsed)
|
|
237
|
+
.then((s) => {
|
|
238
|
+
const label = s === EmailStatus.Sent ? 'sent' : EmailStatus[s];
|
|
239
|
+
process.stdout.write(` ✉ dev email_send -> ${parsed.to} (${label})\n`);
|
|
240
|
+
})
|
|
241
|
+
.catch((e: unknown) => {
|
|
242
|
+
process.stdout.write(` ✉ dev email_send -> ${parsed.to} (error: ${String(e)})\n`);
|
|
243
|
+
});
|
|
244
|
+
return EmailStatus.Sent; // optimistic; sync wasm can't await the send
|
|
228
245
|
},
|
|
229
246
|
|
|
230
247
|
// `Environment.get` / `getSecure`: copy one tenant env value into the
|
package/src/devserver/index.ts
CHANGED
|
@@ -25,7 +25,10 @@ import path from 'node:path';
|
|
|
25
25
|
import { Server, type Request, type Response } from '@dacely/hyper-express';
|
|
26
26
|
import pc from 'picocolors';
|
|
27
27
|
|
|
28
|
+
import type { EmailBackendConfig } from 'toiljs/shared';
|
|
29
|
+
|
|
28
30
|
import { applyCacheRule, lookupCache } from './cache.js';
|
|
31
|
+
import { initEmailService } from './email/index.js';
|
|
29
32
|
import { METHOD_CODES, type EnvelopeRequest } from './envelope.js';
|
|
30
33
|
import { WasmServerModule } from './module.js';
|
|
31
34
|
import { proxyToVite, wireWebsocketProxy, type ViteTarget } from './proxy.js';
|
|
@@ -81,6 +84,12 @@ export interface DevServerOptions {
|
|
|
81
84
|
readonly vite: ViteTarget;
|
|
82
85
|
/** Max request body bytes. Default 8 MB. */
|
|
83
86
|
readonly maxBodyLength?: number;
|
|
87
|
+
/**
|
|
88
|
+
* The `toil.config.ts` `server.email` section (non-secret). When set (and the
|
|
89
|
+
* API key is in `.env.secrets`), `EmailService.send` really sends in dev;
|
|
90
|
+
* otherwise it stays a log-only mock. See `./email`.
|
|
91
|
+
*/
|
|
92
|
+
readonly email?: EmailBackendConfig;
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
/** A running dev server. */
|
|
@@ -170,6 +179,17 @@ function sendWasmResponse(
|
|
|
170
179
|
export async function startDevServer(options: DevServerOptions): Promise<RunningDevServer> {
|
|
171
180
|
const host = options.host ?? '127.0.0.1';
|
|
172
181
|
const root = path.resolve(options.root);
|
|
182
|
+
|
|
183
|
+
// Wire the email service from toil.config `server.email` + `.env.secrets`
|
|
184
|
+
// (TOIL_EMAIL_*). Configured -> real sends; otherwise the import stays a
|
|
185
|
+
// log-only mock. A partial-but-invalid config logs why it stayed off.
|
|
186
|
+
const emailInit = initEmailService(root, options.email);
|
|
187
|
+
if (emailInit.service !== null) {
|
|
188
|
+
process.stdout.write(pc.dim(` ✉ email enabled: ${emailInit.note}`) + '\n');
|
|
189
|
+
} else if (emailInit.note !== null) {
|
|
190
|
+
process.stdout.write(pc.yellow(' ! ') + pc.dim(`email off: ${emailInit.note}`) + '\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
173
193
|
const module = new WasmServerModule(options.wasmFile);
|
|
174
194
|
|
|
175
195
|
let warnedMissing = false;
|
package/src/shared/index.ts
CHANGED
|
@@ -8,3 +8,39 @@ export const FRAMEWORK_NAME = 'toiljs';
|
|
|
8
8
|
export interface ToilTarget {
|
|
9
9
|
readonly name: 'client' | 'compiler' | 'cli' | 'server';
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/** SMTP connection config (non-secret) for the `smtp` / `gmail` providers. */
|
|
13
|
+
export interface SmtpBackendConfig {
|
|
14
|
+
/** SMTP host. Empty + provider `gmail` defaults to `smtp.gmail.com`. */
|
|
15
|
+
readonly host?: string;
|
|
16
|
+
/** Submission port. `0`/unset defaults to 587 (STARTTLS); 465 = implicit TLS. */
|
|
17
|
+
readonly port?: number;
|
|
18
|
+
/** SMTP username. Defaults to `from`. */
|
|
19
|
+
readonly user?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The **non-secret** email backend config — the typed `email` section of
|
|
24
|
+
* `toil.config.ts` (`server.email`), consumed by the dev server and the future
|
|
25
|
+
* Node self-host. The provider API key / SMTP password is a SECRET and is NEVER
|
|
26
|
+
* here: it comes from `.env.secrets` (`TOIL_EMAIL_API_KEY`). Any `TOIL_EMAIL_*`
|
|
27
|
+
* env var overrides the matching field here.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors the edge's `TOIL_EMAIL_*` keys (toil-backend `host/email.rs`
|
|
30
|
+
* `EmailSection`). Lives in `toiljs/shared` so both the compiler (config schema)
|
|
31
|
+
* and the dev server (resolver) can reference it regardless of build order.
|
|
32
|
+
*/
|
|
33
|
+
export interface EmailBackendConfig {
|
|
34
|
+
/** `"resend"` (JSON API) | `"gmail"` | `"smtp"` (SMTP). Default `"resend"`. */
|
|
35
|
+
readonly provider?: 'resend' | 'gmail' | 'smtp';
|
|
36
|
+
/** The "from" address. Validated (single address, no CRLF). */
|
|
37
|
+
readonly from?: string;
|
|
38
|
+
/** Per-process send ceiling, sends/minute (rolling). `0` = unlimited. Default 60. */
|
|
39
|
+
readonly maxPerMin?: number;
|
|
40
|
+
/** Per-process send ceiling, sends/day (rolling). `0` = unlimited. Default 0. */
|
|
41
|
+
readonly maxPerDay?: number;
|
|
42
|
+
/** Per-recipient hourly cap (anti-abuse). Default 5. */
|
|
43
|
+
readonly maxPerRecipientPerHour?: number;
|
|
44
|
+
/** SMTP connection details (the `gmail` / `smtp` providers). */
|
|
45
|
+
readonly smtp?: SmtpBackendConfig;
|
|
46
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev / self-host email pipeline (src/devserver/email): wire parse,
|
|
3
|
+
* recipient validation, in-process caps (dedup + per-recipient + per-min/day
|
|
4
|
+
* budget), config resolution (toil.config + TOIL_EMAIL_* env), the Resend
|
|
5
|
+
* transport, and the NodeEmailService prepare/deliver split. Mirrors the edge
|
|
6
|
+
* (toil-backend host/email.rs, mailer.rs, email_budget.rs).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { EmailCaps } from '../src/devserver/email/caps.js';
|
|
11
|
+
import { resolveEmailConfig, type ResolvedEmailConfig } from '../src/devserver/email/config.js';
|
|
12
|
+
import { NodeEmailService } from '../src/devserver/email/index.js';
|
|
13
|
+
import { sendResend } from '../src/devserver/email/providers.js';
|
|
14
|
+
import { EmailStatus } from '../src/devserver/email/status.js';
|
|
15
|
+
import { validFrom, validRecipient } from '../src/devserver/email/validate.js';
|
|
16
|
+
import { parseEmailBlob } from '../src/devserver/email/wire.js';
|
|
17
|
+
|
|
18
|
+
/** Encode an `email_send` blob exactly like the guest (`server/globals/email.ts`). */
|
|
19
|
+
function encodeBlob(to: string, subject: string, purpose: string, body: string, html: string): Buffer {
|
|
20
|
+
const e = (s: string): Buffer => Buffer.from(s, 'utf8');
|
|
21
|
+
const [t, s, p, b, h] = [e(to), e(subject), e(purpose), e(body), e(html)];
|
|
22
|
+
const head = Buffer.alloc(14);
|
|
23
|
+
head.writeUInt16LE(t.length, 0);
|
|
24
|
+
head.writeUInt16LE(s.length, 2);
|
|
25
|
+
head.writeUInt16LE(p.length, 4);
|
|
26
|
+
head.writeUInt32LE(b.length, 6);
|
|
27
|
+
head.writeUInt32LE(h.length, 10);
|
|
28
|
+
return Buffer.concat([head, t, s, p, b, h]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const reserved = (o: Record<string, string>): Map<string, string> => new Map(Object.entries(o));
|
|
32
|
+
|
|
33
|
+
describe('validate', () => {
|
|
34
|
+
it('accepts plain addresses, rejects injection / multiple / malformed', () => {
|
|
35
|
+
expect(validRecipient('user@example.com')).toBe(true);
|
|
36
|
+
expect(validRecipient('a.b+tag@sub.example.co')).toBe(true);
|
|
37
|
+
for (const bad of [
|
|
38
|
+
'a@b.com\r\nBcc: evil@x.com',
|
|
39
|
+
'a@b.com\nDATA',
|
|
40
|
+
'a@b.com\0',
|
|
41
|
+
'a@b.com,c@d.com',
|
|
42
|
+
'a@b.com;c@d.com',
|
|
43
|
+
'a@b.com c@d.com',
|
|
44
|
+
'<a@b.com>',
|
|
45
|
+
'"a"@b.com',
|
|
46
|
+
'',
|
|
47
|
+
'nobody',
|
|
48
|
+
'a@b@c.com',
|
|
49
|
+
'@b.com',
|
|
50
|
+
'a@bcom',
|
|
51
|
+
'a@.com',
|
|
52
|
+
'a@b.',
|
|
53
|
+
]) {
|
|
54
|
+
expect(validRecipient(bad), bad).toBe(false);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('validFrom is lenient but rejects header injection', () => {
|
|
59
|
+
expect(validFrom('me@example.com')).toBe(true);
|
|
60
|
+
expect(validFrom('a@b.com\r\nBcc: x')).toBe(false);
|
|
61
|
+
expect(validFrom('no-at-sign')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('wire parse', () => {
|
|
66
|
+
it('round-trips a well-formed blob', () => {
|
|
67
|
+
const raw = encodeBlob('a@b.com', 'Hi', 'verify', '123456', '<b>x</b>');
|
|
68
|
+
const p = parseEmailBlob(raw);
|
|
69
|
+
expect(p).toEqual({ to: 'a@b.com', subject: 'Hi', purpose: 'verify', body: '123456', html: '<b>x</b>' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects truncated / trailing-garbage / non-UTF8', () => {
|
|
73
|
+
expect(parseEmailBlob(Buffer.alloc(4))).toBeNull(); // shorter than header
|
|
74
|
+
const raw = encodeBlob('a@b.com', 's', 'p', 'body', '');
|
|
75
|
+
expect(parseEmailBlob(raw.subarray(0, raw.length - 1))).toBeNull(); // truncated payload
|
|
76
|
+
expect(parseEmailBlob(Buffer.concat([raw, Buffer.from([0xff])]))).toBeNull(); // trailing garbage
|
|
77
|
+
const bad = Buffer.from(raw);
|
|
78
|
+
bad[bad.length - 1] = 0xff; // corrupt last body byte -> invalid UTF-8
|
|
79
|
+
expect(parseEmailBlob(bad)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('caps', () => {
|
|
84
|
+
it('dedup collapses an identical (recipient, purpose) within the window', () => {
|
|
85
|
+
const c = new EmailCaps();
|
|
86
|
+
expect(c.isDuplicate('a@b.com', 'verify', 0)).toBe(false); // first
|
|
87
|
+
expect(c.isDuplicate('a@b.com', 'verify', 1_000)).toBe(true); // repeat in window
|
|
88
|
+
expect(c.isDuplicate('a@b.com', 'reset', 1_000)).toBe(false); // different purpose
|
|
89
|
+
expect(c.isDuplicate('a@b.com', 'verify', 31_000)).toBe(false); // after the 30s window
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('per-recipient hourly cap holds then resets', () => {
|
|
93
|
+
const c = new EmailCaps();
|
|
94
|
+
expect(c.recipientOk('x@y.com', 2, 0)).toBe(true);
|
|
95
|
+
expect(c.recipientOk('x@y.com', 2, 10)).toBe(true);
|
|
96
|
+
expect(c.recipientOk('x@y.com', 2, 20)).toBe(false); // third over the cap
|
|
97
|
+
expect(c.recipientOk('z@y.com', 2, 20)).toBe(true); // a different recipient is independent
|
|
98
|
+
expect(c.recipientOk('x@y.com', 2, 3_600_001)).toBe(true); // next hour resets
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('per-minute and per-day budgets both gate, 0 = unlimited', () => {
|
|
102
|
+
const c = new EmailCaps();
|
|
103
|
+
expect(c.budgetOk(2, 0, 0)).toBe(true);
|
|
104
|
+
expect(c.budgetOk(2, 0, 0)).toBe(true);
|
|
105
|
+
expect(c.budgetOk(2, 0, 0)).toBe(false); // minute cap (2) exhausted at the same instant
|
|
106
|
+
// A separate budget instance with only a day cap.
|
|
107
|
+
const d = new EmailCaps();
|
|
108
|
+
expect(d.budgetOk(0, 1, 0)).toBe(true);
|
|
109
|
+
expect(d.budgetOk(0, 1, 0)).toBe(false); // day cap (1) exhausted
|
|
110
|
+
// Unlimited (0/0) never blocks.
|
|
111
|
+
const u = new EmailCaps();
|
|
112
|
+
for (let i = 0; i < 100; i++) expect(u.budgetOk(0, 0, i)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('config resolution', () => {
|
|
117
|
+
it('is unconfigured (silent) with no config + no env', () => {
|
|
118
|
+
expect(resolveEmailConfig(null, reserved({}))).toEqual({ config: null, warning: null });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('merges toil.config + env, api key from env, env wins', () => {
|
|
122
|
+
const { config } = resolveEmailConfig(
|
|
123
|
+
{ provider: 'resend', from: 'cfg@x.com', maxPerMin: 10 },
|
|
124
|
+
reserved({ TOIL_EMAIL_API_KEY: 're_k', TOIL_EMAIL_FROM: 'env@x.com' }),
|
|
125
|
+
);
|
|
126
|
+
expect(config).toMatchObject({ provider: 'resend', from: 'env@x.com', apiKey: 're_k', maxPerMin: 10 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('gmail resolves to smtp with defaults', () => {
|
|
130
|
+
const { config } = resolveEmailConfig(
|
|
131
|
+
{ provider: 'gmail', from: 'me@gmail.com' },
|
|
132
|
+
reserved({ TOIL_EMAIL_API_KEY: 'app-pw' }),
|
|
133
|
+
);
|
|
134
|
+
expect(config).toMatchObject({ provider: 'smtp', smtp: { host: 'smtp.gmail.com', port: 587, user: 'me@gmail.com' } });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('warns (off) on partial/invalid config', () => {
|
|
138
|
+
// from but no api key
|
|
139
|
+
expect(resolveEmailConfig({ from: 'a@b.com' }, reserved({})).warning).toMatch(/API_KEY/);
|
|
140
|
+
// bad from
|
|
141
|
+
expect(resolveEmailConfig({ from: 'no-at' }, reserved({ TOIL_EMAIL_API_KEY: 'k' })).warning).toMatch(/from/);
|
|
142
|
+
// smtp without host
|
|
143
|
+
expect(
|
|
144
|
+
resolveEmailConfig({ provider: 'smtp', from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k' })).warning,
|
|
145
|
+
).toMatch(/SMTP_HOST/);
|
|
146
|
+
// unknown provider
|
|
147
|
+
expect(
|
|
148
|
+
resolveEmailConfig({ from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_PROVIDER: 'mailgun' }))
|
|
149
|
+
.warning,
|
|
150
|
+
).toMatch(/unknown/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('TOIL_EMAIL_ENABLED=false disables silently', () => {
|
|
154
|
+
expect(
|
|
155
|
+
resolveEmailConfig({ provider: 'resend', from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_ENABLED: 'false' })),
|
|
156
|
+
).toEqual({ config: null, warning: null });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Resend transport', () => {
|
|
161
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
162
|
+
|
|
163
|
+
const cfg: ResolvedEmailConfig = {
|
|
164
|
+
provider: 'resend',
|
|
165
|
+
from: 'noreply@x.com',
|
|
166
|
+
apiKey: 're_secret',
|
|
167
|
+
maxPerMin: 0,
|
|
168
|
+
maxPerDay: 0,
|
|
169
|
+
maxPerRecipientPerHour: 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
it('2xx -> Sent, with the right payload + Bearer auth', async () => {
|
|
173
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
174
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
175
|
+
const status = await sendResend(cfg, { from: cfg.from, to: 'a@b.com', subject: 'Hi', body: 'text', html: '<b>h</b>' });
|
|
176
|
+
expect(status).toBe(EmailStatus.Sent);
|
|
177
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
178
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
179
|
+
expect((init.headers as Record<string, string>).authorization).toBe('Bearer re_secret');
|
|
180
|
+
expect(JSON.parse(init.body as string)).toEqual({ from: 'noreply@x.com', to: ['a@b.com'], subject: 'Hi', text: 'text', html: '<b>h</b>' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('4xx -> ProviderError, no retry', async () => {
|
|
184
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 422 }));
|
|
185
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
186
|
+
const status = await sendResend(cfg, { from: cfg.from, to: 'a@b.com', subject: 's', body: 'b', html: '' });
|
|
187
|
+
expect(status).toBe(EmailStatus.ProviderError);
|
|
188
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('NodeEmailService pipeline', () => {
|
|
193
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
194
|
+
|
|
195
|
+
const base: ResolvedEmailConfig = {
|
|
196
|
+
provider: 'resend',
|
|
197
|
+
from: 'noreply@x.com',
|
|
198
|
+
apiKey: 're_secret',
|
|
199
|
+
maxPerMin: 0,
|
|
200
|
+
maxPerDay: 0,
|
|
201
|
+
maxPerRecipientPerHour: 0,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
it('prepare returns terminal statuses and a parsed message to deliver', () => {
|
|
205
|
+
const svc = new NodeEmailService(base);
|
|
206
|
+
// Bad recipient.
|
|
207
|
+
expect(svc.prepare(encodeBlob('not-an-email', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.BadRecipient);
|
|
208
|
+
// Valid -> Sent + parsed.
|
|
209
|
+
const ok = svc.prepare(encodeBlob('a@b.com', 's', 'verify', 'b', ''), 0);
|
|
210
|
+
expect(ok.status).toBe(EmailStatus.Sent);
|
|
211
|
+
expect(ok.parsed?.to).toBe('a@b.com');
|
|
212
|
+
// Identical (to, purpose) again -> Deduped.
|
|
213
|
+
expect(svc.prepare(encodeBlob('a@b.com', 's', 'verify', 'b', ''), 1_000).status).toBe(EmailStatus.Deduped);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('enforces the per-minute budget', () => {
|
|
217
|
+
const svc = new NodeEmailService({ ...base, maxPerMin: 2 });
|
|
218
|
+
// Distinct recipients so dedup/recipient caps don't fire first.
|
|
219
|
+
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
220
|
+
expect(svc.prepare(encodeBlob('b@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
221
|
+
expect(svc.prepare(encodeBlob('c@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Budget);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('enforces the per-recipient hourly cap', () => {
|
|
225
|
+
const svc = new NodeEmailService({ ...base, maxPerRecipientPerHour: 2 });
|
|
226
|
+
// Same recipient, distinct purposes so dedup doesn't fire.
|
|
227
|
+
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p1', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
228
|
+
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p2', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
229
|
+
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p3', 'b', ''), 0).status).toBe(EmailStatus.RecipientCapped);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('deliver actually sends via the provider', async () => {
|
|
233
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
234
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
235
|
+
const svc = new NodeEmailService(base);
|
|
236
|
+
const { parsed } = svc.prepare(encodeBlob('a@b.com', 's', 'p', 'b', ''), 0);
|
|
237
|
+
expect(parsed).not.toBeNull();
|
|
238
|
+
expect(await svc.deliver(parsed!)).toBe(EmailStatus.Sent);
|
|
239
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { createServer } from 'vite';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { loadConfig } from '../src/compiler/config';
|
|
7
|
+
import {
|
|
8
|
+
emailsVersion,
|
|
9
|
+
listEmails,
|
|
10
|
+
previewShellHtml,
|
|
11
|
+
renderEmailByName,
|
|
12
|
+
} from '../src/compiler/email-preview';
|
|
13
|
+
import { createViteConfig } from '../src/compiler/vite';
|
|
14
|
+
|
|
15
|
+
const EXAMPLE = path.resolve(__dirname, '../examples/basic');
|
|
16
|
+
|
|
17
|
+
describe('email preview end-to-end (examples/basic)', () => {
|
|
18
|
+
it('lists Welcome and inlines its emails/styles/email.css; client/* alias resolves', async () => {
|
|
19
|
+
const cfg = await loadConfig({ root: EXAMPLE });
|
|
20
|
+
const items = listEmails(cfg);
|
|
21
|
+
expect(items.map((i) => i.name)).toContain('Welcome');
|
|
22
|
+
|
|
23
|
+
const server = await createServer({
|
|
24
|
+
...(await createViteConfig(cfg)),
|
|
25
|
+
server: { middlewareMode: true, hmr: false },
|
|
26
|
+
appType: 'custom',
|
|
27
|
+
logLevel: 'silent',
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
const r = await renderEmailByName(server, cfg, 'Welcome');
|
|
31
|
+
if (!r) throw new Error('Welcome did not render');
|
|
32
|
+
// tokens discovered from props
|
|
33
|
+
expect(r.tokens).toEqual(['code', 'name']);
|
|
34
|
+
// subject token template
|
|
35
|
+
expect(r.subject).toBe('Welcome, {{name}}!');
|
|
36
|
+
// .email-title { color: #111827 } from emails/styles/email.css inlined onto the <h1>
|
|
37
|
+
expect(r.html).toMatch(/<h1[^>]*style="[^"]*color:\s*#111827/i);
|
|
38
|
+
// .email-card backgroundColor inlined onto the <table>
|
|
39
|
+
expect(r.html).toMatch(/<table[^>]*style="[^"]*background-color:\s*#f6f7f9/i);
|
|
40
|
+
|
|
41
|
+
// The `client/*` reuse alias still resolves project CSS (the documented
|
|
42
|
+
// `import 'client/styles/…'` path), independent of where the demo keeps its styles.
|
|
43
|
+
const aliased = (await server.ssrLoadModule('client/styles/main.css?inline')) as {
|
|
44
|
+
default?: unknown;
|
|
45
|
+
};
|
|
46
|
+
expect(typeof aliased.default).toBe('string');
|
|
47
|
+
} finally {
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
50
|
+
}, 30000);
|
|
51
|
+
|
|
52
|
+
it('emailsVersion is a non-empty mtime:count fingerprint', async () => {
|
|
53
|
+
const cfg = await loadConfig({ root: EXAMPLE });
|
|
54
|
+
const v = emailsVersion(cfg);
|
|
55
|
+
expect(v).toMatch(/^\d+(\.\d+)?:\d+$/);
|
|
56
|
+
// at least Welcome.tsx + the client CSS files were counted
|
|
57
|
+
expect(Number(v.split(':')[1])).toBeGreaterThan(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('the preview shell wires the dev endpoints', () => {
|
|
61
|
+
const html = previewShellHtml();
|
|
62
|
+
expect(html).toContain("var BASE = '/__toil/emails'");
|
|
63
|
+
for (const frag of ["BASE + '/list'", "BASE + '/render?name='", "BASE + '/version'"]) {
|
|
64
|
+
expect(html).toContain(frag);
|
|
65
|
+
}
|
|
66
|
+
expect(html).toContain('/__toil/open?file='); // open-in-editor
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createElement, type ReactElement } from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { __test } from '../src/compiler/emails';
|
|
6
|
+
|
|
7
|
+
const render = (el: unknown): string => renderToStaticMarkup(el as ReactElement);
|
|
8
|
+
|
|
9
|
+
describe('renderModule', () => {
|
|
10
|
+
it('discovers props as {{tokens}} and renders placeholders (alpha-sorted, deduped)', async () => {
|
|
11
|
+
const mod = {
|
|
12
|
+
default: (p: { name: string; code: string }) =>
|
|
13
|
+
createElement('p', null, `Hi ${p.name}, code ${p.code}`),
|
|
14
|
+
};
|
|
15
|
+
const r = await __test.renderModule('Welcome', mod, render);
|
|
16
|
+
if (!r) throw new Error('expected a rendered email');
|
|
17
|
+
expect(r.tokens).toEqual(['code', 'name']);
|
|
18
|
+
expect(r.html).toContain('{{name}}');
|
|
19
|
+
expect(r.html).toContain('{{code}}');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when there is no default-exported component', async () => {
|
|
23
|
+
expect(await __test.renderModule('X', { default: 'nope' }, render)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('inlines imported CSS into element style="" (the reuse path)', async () => {
|
|
27
|
+
const mod = { default: () => createElement('h1', { className: 'email-title' }, 'Hello') };
|
|
28
|
+
const css = '.email-title { color: #111827; font-size: 22px; }';
|
|
29
|
+
const r = await __test.renderModule('Styled', mod, render, css);
|
|
30
|
+
if (!r) throw new Error('expected a rendered email');
|
|
31
|
+
// The class rule is moved onto the element as an inline style by the inliner.
|
|
32
|
+
expect(r.html).toMatch(/<h1[^>]*style="[^"]*color:\s*#111827/i);
|
|
33
|
+
expect(r.html).toMatch(/font-size:\s*22px/i);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('renderModuleSource', () => {
|
|
38
|
+
it('generates a typed Emails.<Name>.send with alpha-sorted token params', () => {
|
|
39
|
+
const src = __test.renderModuleSource([
|
|
40
|
+
{
|
|
41
|
+
name: 'Welcome',
|
|
42
|
+
subject: 'Welcome, {{name}}!',
|
|
43
|
+
html: '<p>{{code}}</p>',
|
|
44
|
+
text: 'code {{code}}',
|
|
45
|
+
tokens: ['code', 'name'],
|
|
46
|
+
purpose: 'welcome',
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
expect(src).toContain('export namespace Emails {');
|
|
50
|
+
expect(src).toContain('export namespace Welcome {');
|
|
51
|
+
expect(src).toContain(
|
|
52
|
+
'export function send(to: string, code: string, name: string, purpose: string = "welcome")',
|
|
53
|
+
);
|
|
54
|
+
expect(src).toContain(
|
|
55
|
+
'return new EmailTemplate(SUBJECT, TEXT, HTML).send(to, __v, purpose);',
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|