toiljs 0.0.115 → 0.0.117
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 +10 -0
- package/README.md +16 -7
- package/TYPESCRIPT_LAW.md +1946 -2073
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2375 -258
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +1 -1
- package/build/client/dev/error-overlay.js +1 -1
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.js +7 -3
- package/build/compiler/emails.js +1 -1
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +25 -4
- package/build/compiler/pages.js +2 -3
- package/build/compiler/prerender.d.ts +3 -8
- package/build/compiler/prerender.js +88 -127
- package/build/compiler/ssg.js +3 -4
- package/build/compiler/template-build.js +3 -4
- package/build/compiler/toil-docs.generated.js +6 -5
- package/build/compiler/vite.js +3 -6
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/database.js +7 -3
- package/build/devserver/db/types.js +1 -1
- package/build/devserver/production.js +4 -3
- package/build/devserver/runtime/crypto.d.ts +1 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/logger/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/routes/analytics.tsx +7 -5
- package/examples/basic/server/tsconfig.json +1 -3
- package/package.json +45 -44
- package/presets/no-uint8array-tostring.js +120 -153
- package/presets/oxlint.d.ts +5 -0
- package/presets/oxlint.js +224 -0
- package/server/runtime/tsconfig.json +1 -3
- package/tsconfig.json +2 -11
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
- package/.github/changelog-config.json +0 -45
- package/.github/dependabot.yml +0 -27
- package/.github/workflows/ci.yml +0 -222
- package/.vscode/settings.json +0 -10
- package/docs/README.md +0 -107
- package/docs/auth/README.md +0 -104
- package/docs/auth/configuration.md +0 -105
- package/docs/auth/emails.md +0 -92
- package/docs/auth/extending.md +0 -202
- package/docs/auth/how-it-works.md +0 -138
- package/docs/auth/usage.md +0 -188
- package/docs/backend/README.md +0 -143
- package/docs/backend/data.md +0 -351
- package/docs/backend/rest.md +0 -402
- package/docs/backend/rpc.md +0 -226
- package/docs/background/README.md +0 -114
- package/docs/background/daemons.md +0 -276
- package/docs/background/derive.md +0 -179
- package/docs/cli/README.md +0 -379
- package/docs/concepts/ai-guide.md +0 -132
- package/docs/concepts/config.md +0 -416
- package/docs/concepts/decorators.md +0 -127
- package/docs/concepts/security.md +0 -108
- package/docs/concepts/tiers.md +0 -166
- package/docs/concepts/types.md +0 -216
- package/docs/database/README.md +0 -143
- package/docs/database/capacity.md +0 -350
- package/docs/database/counters.md +0 -174
- package/docs/database/documents.md +0 -277
- package/docs/database/events.md +0 -307
- package/docs/database/membership.md +0 -246
- package/docs/database/setup.md +0 -155
- package/docs/database/unique.md +0 -216
- package/docs/database/views.md +0 -246
- package/docs/frontend/README.md +0 -104
- package/docs/frontend/components.md +0 -286
- package/docs/frontend/data-fetching.md +0 -260
- package/docs/frontend/images.md +0 -148
- package/docs/frontend/metadata.md +0 -344
- package/docs/frontend/navigation.md +0 -296
- package/docs/frontend/rendering.md +0 -236
- package/docs/frontend/routing.md +0 -346
- package/docs/frontend/scripts.md +0 -118
- package/docs/frontend/search.md +0 -191
- package/docs/frontend/styling.md +0 -147
- package/docs/frontend/toil-global.md +0 -216
- package/docs/getting-started/README.md +0 -81
- package/docs/getting-started/create-project.md +0 -131
- package/docs/getting-started/deploy.md +0 -101
- package/docs/getting-started/first-app.md +0 -215
- package/docs/getting-started/installation.md +0 -124
- package/docs/getting-started/migrating.md +0 -125
- package/docs/getting-started/project-structure.md +0 -163
- package/docs/introduction/README.md +0 -66
- package/docs/introduction/design-principles.md +0 -81
- package/docs/introduction/distributed.md +0 -91
- package/docs/introduction/how-it-works.md +0 -121
- package/docs/introduction/hyperscale.md +0 -70
- package/docs/introduction/modern-stack.md +0 -85
- package/docs/introduction/vs-other-frameworks.md +0 -64
- package/docs/introduction/why-toil.md +0 -99
- package/docs/llms.txt +0 -95
- package/docs/realtime/README.md +0 -102
- package/docs/realtime/channels.md +0 -211
- package/docs/realtime/streams.md +0 -369
- package/docs/services/README.md +0 -60
- package/docs/services/analytics.md +0 -279
- package/docs/services/caching.md +0 -175
- package/docs/services/cookies.md +0 -235
- package/docs/services/crypto.md +0 -209
- package/docs/services/email.md +0 -289
- package/docs/services/environment.md +0 -141
- package/docs/services/ratelimit.md +0 -174
- package/docs/services/time.md +0 -85
- package/eslint.config.js +0 -57
- package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
- package/presets/eslint.js +0 -88
- package/src/backend/index.ts +0 -204
- package/src/cli/configure.ts +0 -363
- package/src/cli/create.ts +0 -924
- package/src/cli/db.ts +0 -158
- package/src/cli/diagnostics.ts +0 -665
- package/src/cli/doctor.ts +0 -982
- package/src/cli/features.ts +0 -170
- package/src/cli/index.ts +0 -326
- package/src/cli/notify.ts +0 -120
- package/src/cli/proc.ts +0 -56
- package/src/cli/ui.ts +0 -169
- package/src/cli/update.ts +0 -248
- package/src/cli/updates.ts +0 -92
- package/src/cli/validate.ts +0 -31
- package/src/cli/version-check.ts +0 -125
- package/src/client/auth.ts +0 -869
- package/src/client/channel/channel.ts +0 -146
- package/src/client/components/Form.tsx +0 -65
- package/src/client/components/Image.tsx +0 -160
- package/src/client/components/Script.tsx +0 -113
- package/src/client/components/Slot.tsx +0 -21
- package/src/client/dev/devtools.tsx +0 -1089
- package/src/client/dev/error-overlay.tsx +0 -225
- package/src/client/errors.ts +0 -11
- package/src/client/head/head.ts +0 -182
- package/src/client/head/metadata.ts +0 -110
- package/src/client/index.ts +0 -130
- package/src/client/navigation/Link.tsx +0 -108
- package/src/client/navigation/NavLink.tsx +0 -86
- package/src/client/navigation/navigation.ts +0 -235
- package/src/client/navigation/prefetch.ts +0 -169
- package/src/client/navigation/scroll.ts +0 -53
- package/src/client/routing/Router.tsx +0 -216
- package/src/client/routing/action.ts +0 -122
- package/src/client/routing/error-boundary.tsx +0 -43
- package/src/client/routing/hooks.ts +0 -140
- package/src/client/routing/lazy.ts +0 -77
- package/src/client/routing/loader.ts +0 -381
- package/src/client/routing/match.ts +0 -47
- package/src/client/routing/mount.tsx +0 -90
- package/src/client/routing/params-context.ts +0 -10
- package/src/client/routing/slot-context.ts +0 -7
- package/src/client/rpc.ts +0 -97
- package/src/client/search/search.ts +0 -189
- package/src/client/search/use-page-search.ts +0 -73
- package/src/client/ssr/markers.tsx +0 -172
- package/src/client/stream/client.ts +0 -237
- package/src/client/types.ts +0 -73
- package/src/compiler/auth-emails.ts +0 -312
- package/src/compiler/config.ts +0 -369
- package/src/compiler/docs.ts +0 -127
- package/src/compiler/email-preview.ts +0 -314
- package/src/compiler/emails.ts +0 -404
- package/src/compiler/fonts.ts +0 -89
- package/src/compiler/generate.ts +0 -471
- package/src/compiler/image-blur.ts +0 -42
- package/src/compiler/image-report.ts +0 -88
- package/src/compiler/index.ts +0 -1362
- package/src/compiler/pages.ts +0 -49
- package/src/compiler/plugin.ts +0 -391
- package/src/compiler/prerender.ts +0 -263
- package/src/compiler/routes.ts +0 -170
- package/src/compiler/seo.ts +0 -415
- package/src/compiler/sri.ts +0 -144
- package/src/compiler/ssg.ts +0 -177
- package/src/compiler/ssr-codegen.ts +0 -85
- package/src/compiler/template-build.ts +0 -676
- package/src/compiler/template.ts +0 -262
- package/src/compiler/toil-docs.generated.ts +0 -77
- package/src/compiler/vite.ts +0 -245
- package/src/devserver/analytics/index.ts +0 -317
- package/src/devserver/config/dotenv.ts +0 -102
- package/src/devserver/config/env.ts +0 -30
- package/src/devserver/config/ratelimit.ts +0 -123
- package/src/devserver/daemon/catalog.ts +0 -120
- package/src/devserver/daemon/cron.ts +0 -87
- package/src/devserver/daemon/host.ts +0 -134
- package/src/devserver/daemon/index.ts +0 -369
- package/src/devserver/daemon/runtime.ts +0 -48
- package/src/devserver/db/catalog.ts +0 -109
- package/src/devserver/db/database.ts +0 -1834
- package/src/devserver/db/derives.ts +0 -121
- package/src/devserver/db/index.ts +0 -20
- package/src/devserver/db/routeKinds.ts +0 -191
- package/src/devserver/db/types.ts +0 -168
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +0 -141
- package/src/devserver/email/console.ts +0 -348
- package/src/devserver/email/index.ts +0 -155
- package/src/devserver/email/providers.ts +0 -131
- package/src/devserver/email/status.ts +0 -23
- package/src/devserver/email/validate.ts +0 -37
- package/src/devserver/email/wire.ts +0 -55
- package/src/devserver/http/cache.ts +0 -0
- package/src/devserver/http/envelope.ts +0 -157
- package/src/devserver/http/proxy.ts +0 -172
- package/src/devserver/http/runtime.ts +0 -387
- package/src/devserver/index.ts +0 -43
- package/src/devserver/production-ipc.ts +0 -63
- package/src/devserver/production-worker.ts +0 -83
- package/src/devserver/production.ts +0 -718
- package/src/devserver/runtime/crypto.ts +0 -587
- package/src/devserver/runtime/host.ts +0 -427
- package/src/devserver/runtime/module.ts +0 -442
- package/src/devserver/server.ts +0 -251
- package/src/devserver/ssr.ts +0 -208
- package/src/devserver/stream/catalog.ts +0 -106
- package/src/devserver/stream/host.ts +0 -42
- package/src/devserver/stream/index.ts +0 -301
- package/src/devserver/stream/manager.ts +0 -167
- package/src/devserver/stream/router.ts +0 -101
- package/src/devserver/stream/wire.ts +0 -58
- package/src/devserver/stream/ws.ts +0 -78
- package/src/devserver/wasm/sections.ts +0 -59
- package/src/devserver/wasm/surface.ts +0 -115
- package/src/io/FastMap.ts +0 -151
- package/src/io/FastSet.ts +0 -113
- package/src/io/codec.ts +0 -362
- package/src/io/index.ts +0 -10
- package/src/io/lengths.ts +0 -14
- package/src/io/types.ts +0 -19
- package/src/logger/index.ts +0 -22
- package/src/shared/index.ts +0 -46
- package/test/analytics-dev.test.ts +0 -113
- package/test/assembly/aspect-shim.ts +0 -33
- package/test/assembly/cookie.spec.ts +0 -303
- package/test/assembly/example.spec.ts +0 -22
- package/test/assembly/ssr.spec.ts +0 -95
- package/test/assembly.test.ts +0 -59
- package/test/auth-emails.test.ts +0 -118
- package/test/built-ssr.test.ts +0 -148
- package/test/channel.test.ts +0 -21
- package/test/config-host-port.test.ts +0 -59
- package/test/configure.test.ts +0 -94
- package/test/daemon-build.test.ts +0 -278
- package/test/daemon-catalog.test.ts +0 -349
- package/test/daemon-emulation.test.ts +0 -256
- package/test/db.test.ts +0 -0
- package/test/devserver-database.test.ts +0 -1102
- package/test/devserver-email.test.ts +0 -306
- package/test/devserver-pqauth.test.ts +0 -93
- package/test/devserver-secrets.test.ts +0 -63
- package/test/devserver.test.ts +0 -259
- package/test/doctor.test.ts +0 -298
- package/test/dom/Image.test.tsx +0 -144
- package/test/dom/Link.test.tsx +0 -67
- package/test/dom/NavLink.test.tsx +0 -37
- package/test/dom/Script.test.tsx +0 -48
- package/test/dom/action.test.tsx +0 -146
- package/test/dom/error-overlay.test.tsx +0 -44
- package/test/dom/loader.test.tsx +0 -121
- package/test/dom/navigation.test.ts +0 -59
- package/test/dom/revalidate.test.tsx +0 -38
- package/test/dom/route-head.test.tsx +0 -56
- package/test/dom/router-loading.test.tsx +0 -44
- package/test/dom/scroll.test.ts +0 -56
- package/test/dom/slot.test.tsx +0 -131
- package/test/dom/use-metadata.test.tsx +0 -58
- package/test/dom/view-transitions.test.tsx +0 -53
- package/test/dynamic-routing.test.ts +0 -132
- package/test/email-console.test.ts +0 -86
- package/test/email-preview.test.ts +0 -73
- package/test/emails.test.ts +0 -58
- package/test/errors.test.ts +0 -21
- package/test/example-guestbook.test.ts +0 -123
- package/test/features.test.ts +0 -149
- package/test/fixtures/bignum-wire/spec.ts +0 -25
- package/test/fixtures/daemon-app.ts +0 -65
- package/test/fixtures/stream-echo.ts +0 -26
- package/test/fixtures/stream-gate.ts +0 -28
- package/test/fixtures/stream-trap.ts +0 -18
- package/test/fixtures/stream-typed.ts +0 -41
- package/test/fonts.test.ts +0 -28
- package/test/global-setup.ts +0 -17
- package/test/head.test.ts +0 -42
- package/test/image-blur.test.ts +0 -50
- package/test/io.test.ts +0 -117
- package/test/metadata.test.ts +0 -40
- package/test/navlink.test.ts +0 -28
- package/test/pages.test.ts +0 -105
- package/test/placeholder.test.ts +0 -9
- package/test/pqauth-e2e.test.ts +0 -216
- package/test/pqauth-email.test.ts +0 -801
- package/test/prerender.test.ts +0 -116
- package/test/prettier-plugin.test.ts +0 -46
- package/test/routes.test.ts +0 -76
- package/test/rpc-bignum-wire.test.ts +0 -164
- package/test/rpc-dispatch.test.ts +0 -132
- package/test/rpc-kinds.test.ts +0 -18
- package/test/rpc.test.ts +0 -66
- package/test/search.test.ts +0 -114
- package/test/seo.test.ts +0 -175
- package/test/slot-layouts.test.ts +0 -69
- package/test/sri.test.ts +0 -129
- package/test/ssg.test.ts +0 -36
- package/test/ssr-hydration.test.tsx +0 -122
- package/test/ssr-render.test.ts +0 -197
- package/test/ssr-template.test.tsx +0 -482
- package/test/stream-emulation.test.ts +0 -479
- package/test/ui.test.ts +0 -40
- package/test/update.test.ts +0 -58
- package/test/validate.test.ts +0 -42
- package/test/version-check.test.ts +0 -130
- package/tests/data-parity/generated-parity.ts +0 -99
- package/tests/data-parity/parity.ts +0 -80
- package/tests/data-parity/spec.ts +0 -46
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DEV EMAIL EMULATOR: renders an outgoing email as an ASCII-drawn box in the
|
|
3
|
-
* terminal so confirm / reset / 2FA flows are testable locally WITHOUT a real
|
|
4
|
-
* inbox (the whole point of dev). It converts the HTML body to readable text WITH
|
|
5
|
-
* terminal color + font styling (bold / italic / underline / colored text taken
|
|
6
|
-
* from the HTML), lays it out under a left-barred box, and highlights the ACTION
|
|
7
|
-
* (the one-time link, which carries its token in the `#token=` fragment and so is
|
|
8
|
-
* directly clickable, or the numeric 2FA code). Dev-only; the production edge never
|
|
9
|
-
* renders or logs token material.
|
|
10
|
-
*
|
|
11
|
-
* The box uses a LEFT bar plus full top/bottom rules (no right border), so colored
|
|
12
|
-
* spans never break column alignment and long URLs stay on one unbroken, clickable
|
|
13
|
-
* line. Styling is emitted only when the terminal supports color (a piped/CI run
|
|
14
|
-
* gets clean plain text).
|
|
15
|
-
*/
|
|
16
|
-
import pc from 'picocolors';
|
|
17
|
-
|
|
18
|
-
import { type ParsedEmail } from './wire.js';
|
|
19
|
-
|
|
20
|
-
const WIDTH = 68; // wrap column for body text (URLs are left un-wrapped)
|
|
21
|
-
|
|
22
|
-
/** Decode the handful of HTML entities our built-in templates can emit. */
|
|
23
|
-
function decodeEntities(s: string): string {
|
|
24
|
-
return s
|
|
25
|
-
.replace(/ /g, ' ')
|
|
26
|
-
.replace(/&/g, '&')
|
|
27
|
-
.replace(/</g, '<')
|
|
28
|
-
.replace(/>/g, '>')
|
|
29
|
-
.replace(/"/g, '"')
|
|
30
|
-
.replace(/�*39;|'/g, "'")
|
|
31
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h: string) => String.fromCodePoint(parseInt(h, 16)))
|
|
32
|
-
.replace(/&#(\d+);/g, (_m, d: string) => String.fromCodePoint(parseInt(d, 10)));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A plain-text (no color) rendering of an HTML email body: anchors become
|
|
37
|
-
* `text (url)`, block elements become line breaks, remaining tags are stripped,
|
|
38
|
-
* entities decoded, whitespace collapsed. Kept for the action-extraction fallback
|
|
39
|
-
* and any caller that wants text without escape codes.
|
|
40
|
-
*/
|
|
41
|
-
export function htmlToText(html: string): string {
|
|
42
|
-
let s = html;
|
|
43
|
-
// <a href="URL">TEXT</a> -> "TEXT (URL)" (or just URL when text == url / empty)
|
|
44
|
-
s = s.replace(
|
|
45
|
-
/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
|
|
46
|
-
(_m, href: string, inner: string) => {
|
|
47
|
-
const t = inner.replace(/<[^>]+>/g, '').trim();
|
|
48
|
-
return t.length > 0 && t !== href ? `${t} (${href})` : href;
|
|
49
|
-
},
|
|
50
|
-
);
|
|
51
|
-
s = s.replace(/<br\s*\/?>/gi, '\n');
|
|
52
|
-
s = s.replace(/<\/?(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|section|header|footer)\b[^>]*>/gi, '\n');
|
|
53
|
-
s = s.replace(/<[^>]+>/g, '');
|
|
54
|
-
s = decodeEntities(s);
|
|
55
|
-
s = s.replace(/[ \t]+/g, ' ');
|
|
56
|
-
s = s
|
|
57
|
-
.split('\n')
|
|
58
|
-
.map((l) => l.trim())
|
|
59
|
-
.join('\n')
|
|
60
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
61
|
-
.trim();
|
|
62
|
-
return s;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The actionable bit of an email: the first link, else a 4-8 digit code, else null. */
|
|
66
|
-
export function emailAction(parsed: ParsedEmail): { kind: 'link' | 'code'; value: string } | null {
|
|
67
|
-
const body = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
|
|
68
|
-
const link = /https?:\/\/[^\s"'<>)]+/.exec(body);
|
|
69
|
-
if (link !== null) return { kind: 'link', value: link[0] };
|
|
70
|
-
const code = /\b(\d{4,8})\b/.exec(body);
|
|
71
|
-
if (code !== null) return { kind: 'code', value: code[1] };
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// --- HTML -> STYLED terminal spans -----------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
type Rgb = readonly [number, number, number];
|
|
78
|
-
interface Style {
|
|
79
|
-
bold?: boolean;
|
|
80
|
-
italic?: boolean;
|
|
81
|
-
underline?: boolean;
|
|
82
|
-
color?: Rgb;
|
|
83
|
-
}
|
|
84
|
-
interface Word {
|
|
85
|
-
readonly text: string;
|
|
86
|
-
readonly style: Style;
|
|
87
|
-
/** Whether whitespace separated this word from the previous one in the source
|
|
88
|
-
* (so `<b>bob</b>,` renders as `bob,` not `bob ,`). */
|
|
89
|
-
readonly spaceBefore: boolean;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A small palette of CSS color names our templates (and most emails) use. */
|
|
93
|
-
const NAMED: Record<string, Rgb> = {
|
|
94
|
-
black: [0, 0, 0], white: [235, 235, 235], red: [229, 57, 53], green: [67, 160, 71],
|
|
95
|
-
blue: [30, 136, 229], yellow: [253, 216, 53], orange: [251, 140, 0], purple: [142, 36, 170],
|
|
96
|
-
magenta: [216, 27, 96], pink: [233, 30, 99], cyan: [0, 172, 193], teal: [0, 137, 123],
|
|
97
|
-
indigo: [57, 73, 171], gray: [128, 128, 128], grey: [128, 128, 128], silver: [192, 192, 192],
|
|
98
|
-
gold: [255, 193, 7], brown: [121, 85, 72], navy: [26, 35, 126], lime: [124, 179, 66],
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/** Parse a CSS color value (`#rgb`, `#rrggbb`, `rgb(...)`, or a named color) to RGB. */
|
|
102
|
-
export function parseColor(value: string): Rgb | undefined {
|
|
103
|
-
const s = value.trim().toLowerCase();
|
|
104
|
-
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
|
|
105
|
-
if (hex !== null) {
|
|
106
|
-
const h = hex[1];
|
|
107
|
-
if (h.length === 3) {
|
|
108
|
-
return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];
|
|
109
|
-
}
|
|
110
|
-
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
111
|
-
}
|
|
112
|
-
const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/.exec(s);
|
|
113
|
-
if (rgb !== null) {
|
|
114
|
-
return [Math.min(255, +rgb[1]), Math.min(255, +rgb[2]), Math.min(255, +rgb[3])];
|
|
115
|
-
}
|
|
116
|
-
return NAMED[s];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** The style delta a tag (name + raw attributes) contributes. */
|
|
120
|
-
function styleFromTag(name: string, attrs: string): Style {
|
|
121
|
-
const d: Style = {};
|
|
122
|
-
if (name === 'b' || name === 'strong' || /^h[1-6]$/.test(name)) d.bold = true;
|
|
123
|
-
if (name === 'i' || name === 'em') d.italic = true;
|
|
124
|
-
if (name === 'u' || name === 'ins') d.underline = true;
|
|
125
|
-
if (name === 'a') {
|
|
126
|
-
d.underline = true;
|
|
127
|
-
d.color = NAMED.cyan; // links stand out
|
|
128
|
-
}
|
|
129
|
-
if (name === 'code' || name === 'kbd' || name === 'pre' || name === 'samp') d.color = NAMED.gray;
|
|
130
|
-
const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(attrs);
|
|
131
|
-
if (styleAttr !== null) {
|
|
132
|
-
const css = styleAttr[1].toLowerCase();
|
|
133
|
-
const colorM = /(?:^|;)\s*color\s*:\s*([^;]+)/.exec(css);
|
|
134
|
-
if (colorM !== null) {
|
|
135
|
-
const c = parseColor(colorM[1]);
|
|
136
|
-
if (c !== undefined) d.color = c;
|
|
137
|
-
}
|
|
138
|
-
if (/font-weight\s*:\s*(?:bold|bolder|[6-9]00)/.test(css)) d.bold = true;
|
|
139
|
-
if (/font-style\s*:\s*italic/.test(css)) d.italic = true;
|
|
140
|
-
if (/text-decoration[^;]*:\s*[^;]*underline/.test(css)) d.underline = true;
|
|
141
|
-
}
|
|
142
|
-
if (name === 'font') {
|
|
143
|
-
const fc = /\bcolor\s*=\s*["']([^"']+)["']/i.exec(attrs);
|
|
144
|
-
if (fc !== null) {
|
|
145
|
-
const c = parseColor(fc[1]);
|
|
146
|
-
if (c !== undefined) d.color = c;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return d;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Flatten a style stack (last color / any true flag wins). */
|
|
153
|
-
function compose(stack: readonly Style[]): Style {
|
|
154
|
-
const out: Style = {};
|
|
155
|
-
for (const s of stack) {
|
|
156
|
-
if (s.bold) out.bold = true;
|
|
157
|
-
if (s.italic) out.italic = true;
|
|
158
|
-
if (s.underline) out.underline = true;
|
|
159
|
-
if (s.color !== undefined) out.color = s.color;
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const BLOCK = new Set([
|
|
165
|
-
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'table', 'ul', 'ol',
|
|
166
|
-
'blockquote', 'section', 'header', 'footer', 'br', 'hr', 'article', 'main', 'nav', 'aside',
|
|
167
|
-
]);
|
|
168
|
-
|
|
169
|
-
/** Parse an HTML body into paragraphs (split on block elements) of styled words,
|
|
170
|
-
* preserving whether each word was space-separated from the previous one. */
|
|
171
|
-
function htmlToParagraphs(html: string): Word[][] {
|
|
172
|
-
const paras: Word[][] = [];
|
|
173
|
-
let cur: Word[] = [];
|
|
174
|
-
let pendingSpace = false; // a space boundary carried across tag boundaries
|
|
175
|
-
const flush = (): void => {
|
|
176
|
-
if (cur.length > 0) {
|
|
177
|
-
paras.push(cur);
|
|
178
|
-
cur = [];
|
|
179
|
-
}
|
|
180
|
-
pendingSpace = false;
|
|
181
|
-
};
|
|
182
|
-
const stack: Style[] = [];
|
|
183
|
-
const hrefs: (string | null)[] = [];
|
|
184
|
-
const re = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>|([^<]+)/g;
|
|
185
|
-
let m: RegExpExecArray | null;
|
|
186
|
-
while ((m = re.exec(html)) !== null) {
|
|
187
|
-
const raw = m[4];
|
|
188
|
-
if (raw !== undefined) {
|
|
189
|
-
const norm = decodeEntities(raw).replace(/\s+/g, ' ').trim();
|
|
190
|
-
if (norm.length === 0) {
|
|
191
|
-
if (/\s/.test(raw)) pendingSpace = true;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
const leading = /^\s/.test(raw) || pendingSpace;
|
|
195
|
-
const st = compose(stack);
|
|
196
|
-
const words = norm.split(' ');
|
|
197
|
-
for (let i = 0; i < words.length; i++) {
|
|
198
|
-
cur.push({ text: words[i], style: st, spaceBefore: cur.length > 0 && (i > 0 || leading) });
|
|
199
|
-
}
|
|
200
|
-
pendingSpace = /\s$/.test(raw);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
const closing = m[1] === '/';
|
|
204
|
-
const name = m[2].toLowerCase();
|
|
205
|
-
const attrs = m[3] ?? '';
|
|
206
|
-
if (BLOCK.has(name)) {
|
|
207
|
-
flush();
|
|
208
|
-
if (/^h[1-6]$/.test(name)) {
|
|
209
|
-
if (closing) stack.pop();
|
|
210
|
-
else stack.push(styleFromTag(name, attrs));
|
|
211
|
-
}
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
if (closing) {
|
|
215
|
-
stack.pop();
|
|
216
|
-
if (name === 'a') {
|
|
217
|
-
const href = hrefs.pop();
|
|
218
|
-
if (href) {
|
|
219
|
-
cur.push({
|
|
220
|
-
text: `(${href})`,
|
|
221
|
-
style: { ...compose(stack), color: NAMED.gray },
|
|
222
|
-
spaceBefore: cur.length > 0,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} else {
|
|
227
|
-
stack.push(styleFromTag(name, attrs));
|
|
228
|
-
if (name === 'a') {
|
|
229
|
-
const hm = /\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs);
|
|
230
|
-
hrefs.push(hm !== null ? hm[1] : null);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
flush();
|
|
235
|
-
return paras;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Apply a style to a string as terminal escapes (no-op when color is unsupported). */
|
|
239
|
-
function paint(style: Style, s: string): string {
|
|
240
|
-
if (!pc.isColorSupported) return s;
|
|
241
|
-
let open = '';
|
|
242
|
-
if (style.bold) open += '\x1b[1m';
|
|
243
|
-
if (style.italic) open += '\x1b[3m';
|
|
244
|
-
if (style.underline) open += '\x1b[4m';
|
|
245
|
-
if (style.color !== undefined) open += `\x1b[38;2;${style.color[0]};${style.color[1]};${style.color[2]}m`;
|
|
246
|
-
return open === '' ? s : `${open}${s}\x1b[0m`;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Word-wrap styled words to `width` VISIBLE columns; over-long words (URLs) overflow.
|
|
250
|
-
* Honors each word's `spaceBefore` so attached punctuation is not pushed off. */
|
|
251
|
-
function wrapWords(words: readonly Word[], width: number): Word[][] {
|
|
252
|
-
const lines: Word[][] = [];
|
|
253
|
-
let line: Word[] = [];
|
|
254
|
-
let len = 0;
|
|
255
|
-
for (const w of words) {
|
|
256
|
-
const sep = line.length > 0 && w.spaceBefore ? 1 : 0;
|
|
257
|
-
if (line.length > 0 && len + sep + w.text.length > width) {
|
|
258
|
-
lines.push(line);
|
|
259
|
-
line = [w];
|
|
260
|
-
len = w.text.length;
|
|
261
|
-
} else {
|
|
262
|
-
line.push(w);
|
|
263
|
-
len += sep + w.text.length;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
if (line.length > 0) lines.push(line);
|
|
267
|
-
return lines;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Render an HTML body to styled terminal lines (visible width <= `width`). Empty
|
|
272
|
-
* strings mark blank separator lines between paragraphs. Exported for tests.
|
|
273
|
-
*/
|
|
274
|
-
export function renderHtmlBody(html: string, width: number = WIDTH): string[] {
|
|
275
|
-
const paras = htmlToParagraphs(html);
|
|
276
|
-
const out: string[] = [];
|
|
277
|
-
for (let p = 0; p < paras.length; p++) {
|
|
278
|
-
if (p > 0) out.push(''); // one blank line between paragraphs
|
|
279
|
-
for (const line of wrapWords(paras[p], width)) {
|
|
280
|
-
out.push(
|
|
281
|
-
line.map((w, i) => (i > 0 && w.spaceBefore ? ' ' : '') + paint(w.style, w.text)).join(''),
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return out;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Word-wrap PLAIN text (the fallback when there is no HTML body). */
|
|
289
|
-
function wrap(text: string, width: number): string[] {
|
|
290
|
-
const out: string[] = [];
|
|
291
|
-
for (const para of text.split('\n')) {
|
|
292
|
-
if (para.length === 0) {
|
|
293
|
-
out.push('');
|
|
294
|
-
continue;
|
|
295
|
-
}
|
|
296
|
-
let line = '';
|
|
297
|
-
for (const word of para.split(' ')) {
|
|
298
|
-
if (line.length === 0) line = word;
|
|
299
|
-
else if (line.length + 1 + word.length <= width) line += ' ' + word;
|
|
300
|
-
else {
|
|
301
|
-
out.push(line);
|
|
302
|
-
line = word;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
out.push(line);
|
|
306
|
-
}
|
|
307
|
-
return out;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* The full ASCII box for one dev email. `status` is a short label for the header
|
|
312
|
-
* (e.g. `not sent, no provider`, `sent`, `deduped`). The body is rendered from the
|
|
313
|
-
* HTML with color + font styling when present, else from the plain-text body.
|
|
314
|
-
*/
|
|
315
|
-
export function renderEmailConsole(parsed: ParsedEmail, status: string): string {
|
|
316
|
-
const bar = pc.dim(' │ ');
|
|
317
|
-
const rule = (label: string): string => {
|
|
318
|
-
const head = label.length > 0 ? `─ ${label} ` : '─';
|
|
319
|
-
const fill = '─'.repeat(Math.max(0, WIDTH + 2 - head.length));
|
|
320
|
-
return pc.dim(` ├${head}${fill}`);
|
|
321
|
-
};
|
|
322
|
-
const purpose = parsed.purpose.length > 0 ? ` · ${parsed.purpose}` : '';
|
|
323
|
-
const header = `─ ✉ ${pc.bold('Email')} ${pc.dim(`(dev${purpose} · ${status})`)} `;
|
|
324
|
-
const topFill = '─'.repeat(Math.max(0, WIDTH + 2 - `─ ✉ Email (dev${purpose} · ${status}) `.length));
|
|
325
|
-
|
|
326
|
-
const lines: string[] = [];
|
|
327
|
-
lines.push(pc.dim(` ┌${header}${topFill}`));
|
|
328
|
-
lines.push(bar + pc.dim('To: ') + parsed.to);
|
|
329
|
-
lines.push(bar + pc.dim('Subject: ') + parsed.subject);
|
|
330
|
-
lines.push(pc.dim(' │'));
|
|
331
|
-
|
|
332
|
-
const bodyLines =
|
|
333
|
-
parsed.html.length > 0 ? renderHtmlBody(parsed.html, WIDTH) : wrap(parsed.body, WIDTH);
|
|
334
|
-
for (const l of bodyLines) lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
|
|
335
|
-
|
|
336
|
-
const action = emailAction(parsed);
|
|
337
|
-
if (action !== null) {
|
|
338
|
-
if (action.kind === 'link') {
|
|
339
|
-
lines.push(rule('🔗 click to continue'));
|
|
340
|
-
lines.push(bar + pc.cyan(pc.underline(action.value)));
|
|
341
|
-
} else {
|
|
342
|
-
lines.push(rule('🔢 code'));
|
|
343
|
-
lines.push(bar + pc.bold(pc.yellow(action.value)));
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
lines.push(pc.dim(` └${'─'.repeat(WIDTH + 2)}`));
|
|
347
|
-
return lines.join('\n');
|
|
348
|
-
}
|
|
@@ -1,155 +0,0 @@
|
|
|
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 '../config/dotenv.js';
|
|
13
|
-
import { EmailCaps } from './caps.js';
|
|
14
|
-
import { type ResolvedEmailConfig, resolveEmailConfig } from './config.js';
|
|
15
|
-
import { type OutboundMessage, sendVia } from './providers.js';
|
|
16
|
-
import { EmailStatus } from './status.js';
|
|
17
|
-
import { validRecipient } from './validate.js';
|
|
18
|
-
import { type ParsedEmail, parseEmailBlob } 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
|
-
// --- Test-only capture seam ---------------------------------------------------
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* A message the dev email pipeline decided to send (a real provider send, or the
|
|
81
|
-
* unconfigured stub), captured for TESTS ONLY. The built-in auth controller
|
|
82
|
-
* delivers its one-time confirm/reset tokens via email, so an e2e test reads the
|
|
83
|
-
* token straight out of the emitted link here. This is NOT a production path —
|
|
84
|
-
* the edge never imports this module, and real sends are unchanged.
|
|
85
|
-
*/
|
|
86
|
-
export interface SentEmail {
|
|
87
|
-
readonly to: string;
|
|
88
|
-
readonly subject: string;
|
|
89
|
-
/** Plain-text body (the confirm/reset link appears here and in `html`). */
|
|
90
|
-
readonly text: string;
|
|
91
|
-
readonly html: string;
|
|
92
|
-
/** Short non-PII tag the guest passed (e.g. `verify`, `reset`). */
|
|
93
|
-
readonly purpose: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Bounded ring of the most-recent dev sends (test seam). */
|
|
97
|
-
export const __sentEmails: SentEmail[] = [];
|
|
98
|
-
const SENT_EMAILS_MAX = 256;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Record one dev send into {@link __sentEmails}. Called at the exact point the
|
|
102
|
-
* dev pipeline decides to send/stub, so it captures precisely the messages the
|
|
103
|
-
* guest emitted (and nothing it dropped/deduped).
|
|
104
|
-
*/
|
|
105
|
-
export function recordSentEmail(msg: ParsedEmail): void {
|
|
106
|
-
__sentEmails.push({
|
|
107
|
-
to: msg.to,
|
|
108
|
-
subject: msg.subject,
|
|
109
|
-
text: msg.body,
|
|
110
|
-
html: msg.html,
|
|
111
|
-
purpose: msg.purpose,
|
|
112
|
-
});
|
|
113
|
-
while (__sentEmails.length > SENT_EMAILS_MAX) __sentEmails.shift();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Clear the captured dev-send ring (tests call this between cases). */
|
|
117
|
-
export function __clearSentEmails(): void {
|
|
118
|
-
__sentEmails.length = 0;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// --- Process-level singleton (one project per dev/self-host process) ----------
|
|
122
|
-
|
|
123
|
-
let service: NodeEmailService | null = null;
|
|
124
|
-
|
|
125
|
-
export interface EmailInitResult {
|
|
126
|
-
/** The service, or `null` when email is unconfigured / invalid. */
|
|
127
|
-
readonly service: NodeEmailService | null;
|
|
128
|
-
/** A note for the startup banner: the provider label, or a config warning. */
|
|
129
|
-
readonly note: string | null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Build the email service from the `toil.config.ts` `email` section + the
|
|
134
|
-
* project's `.env.secrets` (`TOIL_EMAIL_*`, holding the API key) and install it
|
|
135
|
-
* as the process singleton. Call once at startup. Returns a startup note.
|
|
136
|
-
*/
|
|
137
|
-
export function initEmailService(
|
|
138
|
-
root: string,
|
|
139
|
-
cfgEmail: EmailBackendConfig | null | undefined,
|
|
140
|
-
): EmailInitResult {
|
|
141
|
-
const reserved = loadEnvFiles(root).reserved;
|
|
142
|
-
const { config, warning } = resolveEmailConfig(cfgEmail, reserved);
|
|
143
|
-
service = config === null ? null : new NodeEmailService(config);
|
|
144
|
-
return { service, note: service === null ? warning : service.providerLabel };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** The installed email service, or `null` when email is not configured. */
|
|
148
|
-
export function getEmailService(): NodeEmailService | null {
|
|
149
|
-
return service;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Reset the singleton (tests). */
|
|
153
|
-
export function resetEmailService(): void {
|
|
154
|
-
service = null;
|
|
155
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
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 only when there is a credential; a local/auth-less relay takes none.
|
|
95
|
-
auth: cfg.apiKey.length > 0 ? { user: smtp.user, pass: cfg.apiKey } : undefined,
|
|
96
|
-
connectionTimeout: POST_TIMEOUT_MS,
|
|
97
|
-
greetingTimeout: POST_TIMEOUT_MS,
|
|
98
|
-
});
|
|
99
|
-
} catch {
|
|
100
|
-
// nodemailer not installed: SMTP unavailable.
|
|
101
|
-
return EmailStatus.ProviderError;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let backoff = BACKOFF_BASE_MS;
|
|
105
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
106
|
-
try {
|
|
107
|
-
await transporter.sendMail({
|
|
108
|
-
from: msg.from,
|
|
109
|
-
to: msg.to,
|
|
110
|
-
subject: msg.subject,
|
|
111
|
-
text: msg.body.length > 0 ? msg.body : undefined,
|
|
112
|
-
html: msg.html.length > 0 ? msg.html : undefined,
|
|
113
|
-
});
|
|
114
|
-
return EmailStatus.Sent;
|
|
115
|
-
} catch (e) {
|
|
116
|
-
// SMTP 5xx is a PERMANENT failure (bad auth / rejected recipient):
|
|
117
|
-
// terminal. 4xx / transport errors are transient: retry with backoff.
|
|
118
|
-
const code = (e as { responseCode?: number }).responseCode;
|
|
119
|
-
if (typeof code === 'number' && code >= 500) return EmailStatus.ProviderError;
|
|
120
|
-
if (attempt === MAX_ATTEMPTS) break;
|
|
121
|
-
await sleep(backoff);
|
|
122
|
-
backoff *= 2;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return EmailStatus.ProviderError;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Dispatch to the configured provider. */
|
|
129
|
-
export function sendVia(cfg: ResolvedEmailConfig, msg: OutboundMessage): Promise<EmailStatus> {
|
|
130
|
-
return cfg.provider === 'resend' ? sendResend(cfg, msg) : sendSmtp(cfg, msg);
|
|
131
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
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
|
-
}
|