toiljs 0.0.44 → 0.0.45
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/RSG.md +105 -27
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +4 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/index.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 +29 -3
- package/package.json +3 -1
- package/src/compiler/config.ts +14 -0
- package/src/compiler/index.ts +1 -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
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|