toiljs 0.0.43 → 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/CHANGELOG.md +9 -0
- package/RSG.md +334 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +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/generate.js +1 -0
- package/build/compiler/index.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +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.d.ts +2 -0
- package/build/devserver/env.js +9 -0
- package/build/devserver/host.js +39 -7
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +8 -0
- package/build/devserver/module.js +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/build/shared/index.d.ts +13 -0
- package/docs/README.md +4 -1
- package/docs/email.md +90 -53
- package/docs/environment.md +97 -0
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/routes/EnvDemo.ts +42 -0
- package/package.json +4 -2
- package/server/globals/environment.ts +82 -0
- package/src/cli/create.ts +2 -1
- package/src/client/auth.ts +1 -1
- package/src/compiler/config.ts +14 -0
- package/src/compiler/generate.ts +1 -0
- package/src/compiler/index.ts +1 -0
- package/src/devserver/crypto.ts +1 -1
- 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 +30 -0
- package/src/devserver/host.ts +71 -12
- package/src/devserver/index.ts +20 -0
- package/src/devserver/module.ts +1 -1
- package/src/shared/index.ts +36 -0
- package/test/devserver-email.test.ts +241 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
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 via
|
|
4
|
+
* the shared loader (`./dotenv.ts`):
|
|
5
|
+
*
|
|
6
|
+
* - `.env` -> plain vars (`Environment.get`); `process.env` overlays
|
|
7
|
+
* - `.env.secrets` -> secrets (`Environment.getSecure`); keep gitignored
|
|
8
|
+
*
|
|
9
|
+
* DISJOINT like the edge: `get` sees only vars, `getSecure` only secrets, so a
|
|
10
|
+
* secret never comes back through `get`. Framework-reserved `TOIL_*` keys are
|
|
11
|
+
* host-only (the email backend config reads them) and never a tenant var/secret.
|
|
12
|
+
*
|
|
13
|
+
* Cached after first read; restart `toiljs dev` to pick up edits. The edge
|
|
14
|
+
* resolves this PER TENANT from `$TOIL_ENV_DIR/<host>.env` + `<host>.env.secrets`
|
|
15
|
+
* (and the edge DB later) through a lazy, bounded cache; dev has a single
|
|
16
|
+
* project, so two files are enough and no eviction is needed.
|
|
17
|
+
*/
|
|
18
|
+
import { loadEnvFiles } from './dotenv.js';
|
|
19
|
+
|
|
20
|
+
/** A plain var by exact key, or `null`. Reads ONLY the `.env` (vars) bucket. */
|
|
21
|
+
export function devEnvGet(key: string): string | null {
|
|
22
|
+
const v = loadEnvFiles(process.cwd()).vars.get(key);
|
|
23
|
+
return v === undefined ? null : v;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A secret by exact key, or `null`. Reads ONLY the `.env.secrets` bucket. */
|
|
27
|
+
export function devEnvGetSecure(key: string): string | null {
|
|
28
|
+
const v = loadEnvFiles(process.cwd()).secrets.get(key);
|
|
29
|
+
return v === undefined ? null : v;
|
|
30
|
+
}
|
package/src/devserver/host.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
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';
|
|
23
|
+
import { devEnvGet, devEnvGetSecure } from './env.js';
|
|
21
24
|
import { ratelimitCheck } from './ratelimit.js';
|
|
22
25
|
|
|
23
26
|
/** Limits identical to the edge's `set_header` / `respond_file` bounds. */
|
|
@@ -105,6 +108,32 @@ function readGuestString(ref: MemoryRef, ptr: number): string {
|
|
|
105
108
|
return m.toString('utf16le', ptr, ptr + byteLen);
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Resolve one `Environment.get`/`getSecure` lookup against the dev env source
|
|
113
|
+
* and write it into the guest buffer, with the edge's return protocol: the value
|
|
114
|
+
* byte length (`0` = present-but-empty), `-1` if `outCap` is too small (the guest
|
|
115
|
+
* retries with a bigger buffer), `-2` if the key is absent.
|
|
116
|
+
*/
|
|
117
|
+
function envLookup(
|
|
118
|
+
ref: MemoryRef,
|
|
119
|
+
keyPtr: number,
|
|
120
|
+
keyLen: number,
|
|
121
|
+
outPtr: number,
|
|
122
|
+
outCap: number,
|
|
123
|
+
secure: boolean,
|
|
124
|
+
): number {
|
|
125
|
+
const key = readBytes(ref, keyPtr, keyLen).toString('utf8');
|
|
126
|
+
const val = secure ? devEnvGetSecure(key) : devEnvGet(key);
|
|
127
|
+
if (val === null) return -2; // ABSENT
|
|
128
|
+
const bytes = Buffer.from(val, 'utf8');
|
|
129
|
+
if (bytes.length > outCap) return -1; // TOO_SMALL
|
|
130
|
+
const m = mem(ref);
|
|
131
|
+
if (outPtr < 0 || outPtr + bytes.length > m.length)
|
|
132
|
+
throw new Error('env_get write out of bounds');
|
|
133
|
+
bytes.copy(m, outPtr);
|
|
134
|
+
return bytes.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
/**
|
|
109
138
|
* Build the `env` import object for one instance. `state` collects what the
|
|
110
139
|
* imperative imports produce during a dispatch; bind a fresh state per request.
|
|
@@ -183,23 +212,53 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
|
|
|
183
212
|
return d.allowed ? 1 : -Math.max(1, d.retryAfterSecs);
|
|
184
213
|
},
|
|
185
214
|
|
|
186
|
-
// `env::email_send`: the
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
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`.
|
|
190
222
|
email_send: (reqPtr: number, reqLen: number): number => {
|
|
191
|
-
// Header: u16 to_len | u16 subj_len | u16 purpose_len | u32 body_len
|
|
192
|
-
// | u32 html_len (14 bytes), then payloads; `to` is first.
|
|
193
223
|
const raw = readBytes(ref, reqPtr, reqLen);
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
const
|
|
197
|
-
|
|
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;
|
|
198
229
|
}
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
245
|
},
|
|
202
246
|
|
|
247
|
+
// `Environment.get` / `getSecure`: copy one tenant env value into the
|
|
248
|
+
// guest buffer. Returns the byte length (0 = present-but-empty), -1 if
|
|
249
|
+
// the buffer is too small (the guest retries bigger), -2 if absent.
|
|
250
|
+
// Disjoint buckets: `env_get` reads vars, `env_get_secure` reads
|
|
251
|
+
// secrets. Mirrors the edge's `env_get_import.rs`; the dev source is
|
|
252
|
+
// `.env` (+ process.env vars) and `.env.secrets` (see ./env.ts).
|
|
253
|
+
env_get: (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number =>
|
|
254
|
+
envLookup(ref, keyPtr, keyLen, outPtr, outCap, false),
|
|
255
|
+
env_get_secure: (
|
|
256
|
+
keyPtr: number,
|
|
257
|
+
keyLen: number,
|
|
258
|
+
outPtr: number,
|
|
259
|
+
outCap: number,
|
|
260
|
+
): number => envLookup(ref, keyPtr, keyLen, outPtr, outCap, true),
|
|
261
|
+
|
|
203
262
|
thread_spawn: (_startArg: number): number => -1,
|
|
204
263
|
|
|
205
264
|
// `Date.now()` -> wall-clock milliseconds, matching the edge host.
|
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;
|