toiljs 0.0.104 → 0.0.106
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/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.d.ts +28 -0
- package/build/compiler/auth-emails.js +165 -0
- package/build/compiler/emails.d.ts +2 -0
- package/build/compiler/emails.js +4 -1
- package/build/compiler/index.js +12 -0
- package/build/compiler/toil-docs.generated.js +7 -6
- package/docs/README.md +2 -1
- package/docs/auth/README.md +2 -0
- package/docs/auth/emails.md +91 -0
- package/docs/introduction/README.md +19 -10
- package/docs/introduction/hyperscale.md +1 -1
- package/docs/introduction/vs-other-frameworks.md +3 -3
- package/docs/introduction/why-toil.md +2 -4
- package/docs/llms.txt +1 -0
- package/package.json +1 -1
- package/server/auth/AuthController.ts +16 -40
- package/server/globals/email.ts +15 -2
- package/src/compiler/auth-emails.ts +279 -0
- package/src/compiler/emails.ts +14 -1
- package/src/compiler/index.ts +24 -1
- package/src/compiler/toil-docs.generated.ts +7 -6
- package/test/auth-emails.test.ts +115 -0
- package/test/email-console.test.ts +2 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Customizing the auth emails
|
|
2
|
+
|
|
3
|
+
Built-in auth sends three transactional emails: the email-verification link, the password-reset link, and the two-factor code. Each ships with a plain, working default. You can replace any of them with your own branded template, and you never touch the auth code to do it.
|
|
4
|
+
|
|
5
|
+
## The three emails
|
|
6
|
+
|
|
7
|
+
| Email | When it sends | The value it gives your template |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| Email verification | On register, when email confirmation is on | `link` (the confirm URL) |
|
|
10
|
+
| Password reset | On `POST /auth/reset/request` | `link` (the reset URL) |
|
|
11
|
+
| Two-factor code | On login or 2FA setup, for the email method | `code` (the numeric code) |
|
|
12
|
+
|
|
13
|
+
The defaults are intentionally minimal so a fresh project sends real, correct mail with no setup. To match your brand, override them.
|
|
14
|
+
|
|
15
|
+
## How to override
|
|
16
|
+
|
|
17
|
+
Drop a React template in your project's `emails/` folder with the reserved name for that email. If the file exists, the build uses it; if not, the default stands. This is the same `emails/*.tsx` system you use for your own mail (see [Email](../services/email.md)), so you get full markup and inline CSS.
|
|
18
|
+
|
|
19
|
+
| Reserved file | Overrides | Prop it must read |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `emails/auth-confirm.tsx` | Email verification | `link` |
|
|
22
|
+
| `emails/auth-reset.tsx` | Password reset | `link` |
|
|
23
|
+
| `emails/auth-2fa.tsx` | Two-factor code | `code` |
|
|
24
|
+
|
|
25
|
+
Each template is a default-exported React component. Read the one prop it is given, and it becomes a `{{token}}` hole the edge fills per send. Reading any other prop renders empty, so the build warns you.
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// emails/auth-confirm.tsx -> overrides the email-verification message.
|
|
29
|
+
export const subject = 'Verify your Acme account';
|
|
30
|
+
|
|
31
|
+
export default function AuthConfirm({ link }: { link: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<div style={{ fontFamily: 'system-ui', padding: 24 }}>
|
|
34
|
+
<h1 style={{ color: '#cb9820' }}>Welcome to Acme</h1>
|
|
35
|
+
<p>Tap below to verify your email and finish signing up.</p>
|
|
36
|
+
<p>
|
|
37
|
+
<a
|
|
38
|
+
href={link}
|
|
39
|
+
style={{ background: '#cb9820', color: '#fff', padding: '10px 18px', borderRadius: 8, textDecoration: 'none' }}
|
|
40
|
+
>
|
|
41
|
+
Verify my email
|
|
42
|
+
</a>
|
|
43
|
+
</p>
|
|
44
|
+
<p style={{ color: '#666', fontSize: 13 }}>Or paste this link: {link}</p>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The reset template works the same way with the `link` prop.
|
|
51
|
+
|
|
52
|
+
## The subject line
|
|
53
|
+
|
|
54
|
+
For verification and reset, set the subject with `export const subject = '...'`. Leave it out and the default subject is used (`Confirm your account`, `Reset your password`).
|
|
55
|
+
|
|
56
|
+
The two-factor email is different: its subject is set by the framework because it changes with context (a login code versus a setup code). Your `emails/auth-2fa.tsx` override controls the body and HTML around the `code`, and the subject stays contextual. A `subject` export in that file is ignored.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// emails/auth-2fa.tsx -> overrides the 2FA code message (subject is contextual).
|
|
60
|
+
export default function Auth2fa({ code }: { code: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<div style={{ fontFamily: 'system-ui', padding: 24, textAlign: 'center' }}>
|
|
63
|
+
<p>Your Acme verification code:</p>
|
|
64
|
+
<p style={{ fontSize: 32, fontWeight: 700, letterSpacing: 6 }}>{code}</p>
|
|
65
|
+
<p style={{ color: '#666' }}>It expires in a few minutes.</p>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## What you can and cannot put in
|
|
72
|
+
|
|
73
|
+
Each template is rendered once, at build time, into a static string of HTML with your styles inlined, and the prop becomes a fill hole. So a fixed layout and inline styles work, but per-send logic does not: a `{items.map(...)}` or a conditional runs at build, not per email. For these three messages that is all you need, one link or one code.
|
|
74
|
+
|
|
75
|
+
The only value auth hands each template is the one in the table above (`link` or `code`). There is no user name, no app name, no expiry value. Write those into the template text directly.
|
|
76
|
+
|
|
77
|
+
Styling rules are the same as any toiljs email: write inline `style={{ ... }}`, or import a stylesheet and its rules are inlined for you. A bare CSS import with no inlining has no effect in an email client. See [Email](../services/email.md) for the details.
|
|
78
|
+
|
|
79
|
+
## Delivery stays safe
|
|
80
|
+
|
|
81
|
+
You cannot weaken the security of these flows by overriding a template. Every auth email is sent detached (fire-and-forget), so the response time never reveals whether an address maps to an account. That holds whether the message is the default or your override. Overriding only changes what the email looks like, never how it is sent.
|
|
82
|
+
|
|
83
|
+
## Where the templates go
|
|
84
|
+
|
|
85
|
+
There is nothing to import and nothing to register. The build reads `emails/auth-*.tsx`, bakes the effective templates into the compiled server, and the auth controller sends them. Changing a template and rebuilding (or saving under `toiljs dev`) is all it takes.
|
|
86
|
+
|
|
87
|
+
## Related
|
|
88
|
+
|
|
89
|
+
- [Email](../services/email.md): the full `emails/*.tsx` system, `EmailService`, and `EmailTemplate`.
|
|
90
|
+
- [Configuration](./configuration.md): the auth secrets and the email provider a deployment must set.
|
|
91
|
+
- [Usage](./usage.md): enabling auth, the client API, and guarding routes.
|
|
@@ -18,20 +18,29 @@ Types tie the three together. Change a field on the server and the client stops
|
|
|
18
18
|
|
|
19
19
|
## The problem it solves
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
A good web app should be fast everywhere, secure by default, and able to grow without a rewrite. Getting there is the hard part, and most of it has nothing to do with your actual product.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
You assemble it from rented parts: a host, serverless functions, a database, an auth provider, email, a cache, a queue, analytics, a realtime service. Each is its own account, bill, and SDK, and you keep them all in sync. Under that sits a decade of legacy tooling and a node_modules folder heavier than your app. Worse, the fast and secure version is never the default. A CDN, careful caching, region tuning, hardened auth, current crypto: all of it is extra work, so most apps ship slower and less safe than they should.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
toil deletes that assembly. One framework runs your frontend, backend, and database at the edge, next to users. Auth, email, realtime, and background jobs are built in and owned. Login is post-quantum out of the box. There is nothing to wire up and no infrastructure to reason about. The good version is the only version.
|
|
26
|
+
|
|
27
|
+
Distributing writes worldwide is one of the hard problems it handles under the hood. Most stacks spread reads everywhere but pin every write to one region, so a user far from it waits for a round trip and that region is a single point of failure. ToilDB is built to distribute the writes too: every key has a home region that orders its writes while the data copies outward for fast local reads. You never set any of it up.
|
|
26
28
|
|
|
27
29
|
```mermaid
|
|
28
30
|
flowchart TB
|
|
29
|
-
subgraph
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
subgraph Assemble["The usual way: rent and wire the parts yourself"]
|
|
32
|
+
direction TB
|
|
33
|
+
A["Your app"] --> H["host"]
|
|
34
|
+
A --> F["functions"]
|
|
35
|
+
A --> D["database"]
|
|
36
|
+
A --> Au["auth"]
|
|
37
|
+
A --> Em["email"]
|
|
38
|
+
A --> Rt["realtime"]
|
|
39
|
+
A --> J["jobs"]
|
|
32
40
|
end
|
|
33
|
-
subgraph Toil["toil"]
|
|
34
|
-
|
|
41
|
+
subgraph Toil["toil: one framework, at the edge, by default"]
|
|
42
|
+
direction TB
|
|
43
|
+
A2["Your app"] --> T["frontend + backend + database + auth +<br/>email + realtime + jobs, built in and near users"]
|
|
35
44
|
end
|
|
36
45
|
```
|
|
37
46
|
|
|
@@ -43,7 +52,7 @@ Read these in order. Each one builds on the last.
|
|
|
43
52
|
2. **[What comes built in](./modern-stack.md)** The full list of what toil owns and runs for you, and what it does not.
|
|
44
53
|
3. **[How toil works](./how-it-works.md)** The whole path, from a React click through WebAssembly to ToilDB and back.
|
|
45
54
|
4. **[Why it scales cheaply](./hyperscale.md)** How one small program can serve the whole planet without a per-app server bill.
|
|
46
|
-
5. **[How toil distributes writes](./distributed.md)**
|
|
55
|
+
5. **[How toil distributes writes](./distributed.md)** One of the hardest problems in web infrastructure, and how ToilDB is built to solve it.
|
|
47
56
|
6. **[toil next to other stacks](./vs-other-frameworks.md)** A fair comparison with Next.js, Rails, serverless, and the rest, wins and losses both.
|
|
48
57
|
7. **[The bar toil holds itself to](./design-principles.md)** The RSG rubric, and its one rule: your grade is your weakest part.
|
|
49
58
|
|
|
@@ -51,7 +60,7 @@ Read these in order. Each one builds on the last.
|
|
|
51
60
|
|
|
52
61
|
- **Who it is for:** anyone shipping a real product who wants global speed without a platform team or ten stitched-together services.
|
|
53
62
|
- **Why it is fast:** the code runs next to the user, with no trip to a distant origin.
|
|
54
|
-
- **Why it is different:** it distributes writes, not only reads.
|
|
63
|
+
- **Why it is different:** the whole stack is built in and owned, so there is nothing to assemble, and it distributes writes worldwide, not only reads.
|
|
55
64
|
- **Why it is safe:** the backend is sandboxed, passwords never reach the server in a usable form, secrets never ship in the code, and the browser checks every file it loads.
|
|
56
65
|
|
|
57
66
|
Ready to build? Jump to [Getting started](../getting-started/README.md).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What makes toil hyper-scalable
|
|
2
2
|
|
|
3
|
-
Hyper-scale means serving very large worldwide traffic at low latency without rebuilding your app as it grows.
|
|
3
|
+
Hyper-scale means serving very large worldwide traffic at low latency without rebuilding your app as it grows. Picture traffic climbing from a thousand users to a hundred million across every continent. Do you rewrite the system, or just run more of it?
|
|
4
4
|
|
|
5
5
|
Any stack can reach that scale if you spend enough: dedicated infrastructure per app, a rented vendor for each moving part, and an ops team to keep the seams from tearing. toil reaches the same scale from the other direction, cheaply. The whole design aims to make worldwide reach a default, not a budget line. This page is about cost.
|
|
6
6
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This page compares toil to the stacks you already use, axis by axis. Every tool below is good at what it was built for. The aim is to be concrete about what you gain and what you give up. toil is younger than all of them, and that shows in a few rows.
|
|
4
4
|
|
|
5
|
-
toil trades a large, mature ecosystem for one owned framework.
|
|
5
|
+
toil trades a large, mature ecosystem for one owned framework where the fast, secure, global version is the default. You give up SQL, the Node package universe, and a big integration catalog. You gain a stack that is built in and yours, runs near users, ships post-quantum auth and end-to-end types with no setup, and distributes writes worldwide instead of pinning them to one region. Whether that trade fits your project is what this page answers.
|
|
6
6
|
|
|
7
7
|
## The comparison table
|
|
8
8
|
|
|
@@ -24,13 +24,13 @@ The top rows lean toil's way. The bottom rows lean the incumbents' way. toil win
|
|
|
24
24
|
|
|
25
25
|
## How toil compares to each stack
|
|
26
26
|
|
|
27
|
-
**Next.js / Vercel.** Superb React DX and global edge reads. The
|
|
27
|
+
**Next.js / Vercel.** Superb React DX and global edge reads. The backend, though, is still yours to assemble: a database, auth, email, a queue, and a realtime service, each rented and wired up on its own. Serverless cold starts add latency and per-invocation cost right when a spike hits, and writes still resolve against one primary region. toil keeps a React-first client but ships the whole backend built in, running near the data with no cold start.
|
|
28
28
|
|
|
29
29
|
**Rails / Django.** Mature, productive, and batteries included, with a deep hiring pool. If one region suits you, this is a great and boring choice. The default shape is a single-region monolith with one primary that every write must reach. You can add replicas and standbys to scale, but distributed writes are not in the model.
|
|
30
30
|
|
|
31
31
|
**Serverless functions (Lambda, Cloud Functions).** Elastic, stateless compute that scales to zero. It still sits in front of a central database, so a write burst bottlenecks on that store. Each cold invocation bills and lags on its own.
|
|
32
32
|
|
|
33
|
-
**Edge runtimes (Workers, Deno Deploy).** The closest in spirit to toil's compute model: your code runs near users. The catch is
|
|
33
|
+
**Edge runtimes (Workers, Deno Deploy).** The closest in spirit to toil's compute model: your code runs near users. The catch is everything you bolt on around it. The database you attach is usually single-region, and auth, email, realtime, and typed client calls are still yours to assemble. toil ships those built in, on a database designed to distribute writes.
|
|
34
34
|
|
|
35
35
|
Cloudflare Durable Objects and D1 are the closest mainstream analog to toil's idea, and credit is due. A Durable Object gives one object a single-writer home that orders its writes, the same shape as ToilDB's per-key home. The difference is packaging. With the edge-runtime approach you assemble the pieces yourself: runtime, object or database product, auth, email, and realtime. toil ships distributed writes, the seven database families, auth, email, streaming, and background jobs as one owned stack. Which you prefer is a real trade-off.
|
|
36
36
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
toil is a full-stack framework. You write a React frontend and a TypeScript backend in one project, and toil runs both, plus a database, close to every user worldwide.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Shipping a fast, secure, global app normally takes a pile of rented services and a team that understands infrastructure. toil gives you that as the default: one framework, zero config, quantum-proof login, built on modern tech, running near your users from day one. The same setup serves a pizza site and a planet-scale app. Distributing writes worldwide is one of the hard things it handles for you, not the reason it exists.
|
|
6
6
|
|
|
7
7
|
## The problem with modern stacks
|
|
8
8
|
|
|
@@ -22,9 +22,7 @@ A solo builder and a funded startup hit the same wall. Top-tier infrastructure m
|
|
|
22
22
|
|
|
23
23
|
## What toil delivers
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Four pillars carry it.
|
|
25
|
+
toil was built to reach real scale cheaply, and every design choice serves that. You get top-tier infrastructure by default: one framework instead of ten vendors, zero configuration, and no networking knowledge required. Quantum-proof login is on from the start. Four things make it work.
|
|
28
26
|
|
|
29
27
|
### AAA-grade infrastructure by default
|
|
30
28
|
|
package/docs/llms.txt
CHANGED
|
@@ -61,6 +61,7 @@ This is the canonical documentation, organized by section. Each link points to a
|
|
|
61
61
|
- [Authentication](auth/README.md): Toil ships a complete, **post-quantum password login** you turn on with one line.
|
|
62
62
|
- [Configuring auth for production](auth/configuration.md): Built-in auth runs locally with **zero configuration**, it falls back to published, insecure DEV secrets so `toiljs dev` Just Works.
|
|
63
63
|
- [Extending & integrating auth](auth/extending.md): Built-in auth is deliberately opinionated so the common case is one line.
|
|
64
|
+
- [Customizing the auth emails](auth/emails.md): Override the built-in verification, password-reset, and 2FA emails with your own branded React templates by adding reserved emails/auth-*.tsx files (tokens: {link} for confirm/reset, {code} for 2FA); sends stay detached (constant-time).
|
|
64
65
|
- [How Toil auth works](auth/how-it-works.md): Toil auth is a **post-quantum, password-based, mutually-authenticated** login.
|
|
65
66
|
- [Using auth](auth/usage.md): Either the config flag (canonical) or a one-line import, both do the same thing (the build appends the framework's auth controller + user shape to your server as compiled entries, so `/auth/*` self-mounts):
|
|
66
67
|
|
package/package.json
CHANGED
|
@@ -1188,19 +1188,12 @@ class Auth {
|
|
|
1188
1188
|
switch (method) {
|
|
1189
1189
|
case TWOFA_EMAIL: {
|
|
1190
1190
|
const code = randomCode(TWOFA_DIGITS);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
|
|
1198
|
-
code +
|
|
1199
|
-
'</p>' +
|
|
1200
|
-
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>';
|
|
1201
|
-
// DETACHED (non-suspending) send: constant-time, no provider-RTT
|
|
1202
|
-
// parking (matches the confirm/reset anti-enumeration posture).
|
|
1203
|
-
EmailService.sendDetached(email, subject, text, '2fa', html);
|
|
1191
|
+
// The subject is contextual (login vs setup) so AuthController owns it;
|
|
1192
|
+
// the body/html come from the generated `AuthEmail` — the built-in
|
|
1193
|
+
// default, or an `emails/auth-2fa.tsx` override filling `{code}`. The
|
|
1194
|
+
// send is DETACHED (constant-time, no provider-RTT parking), a property
|
|
1195
|
+
// the override cannot lose (AuthEmail.twofa uses sendDetached).
|
|
1196
|
+
AuthEmail.twofa(email, code, subject);
|
|
1204
1197
|
return twoFaCodeHash(host, username, code);
|
|
1205
1198
|
}
|
|
1206
1199
|
// >>> case TWOFA_TOTP: { return twoFaSecretBinding(username); } <<<
|
|
@@ -1267,19 +1260,11 @@ class Auth {
|
|
|
1267
1260
|
// Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
|
|
1268
1261
|
// logs and the Referer header; the confirm page reads it from location.hash.
|
|
1269
1262
|
const link = baseUrl(ctx) + '/confirm#token=' + crypto.toHex(raw);
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
link +
|
|
1276
|
-
'">Confirm my account</a></p>' +
|
|
1277
|
-
'<p>Or paste this into your browser:<br>' +
|
|
1278
|
-
link +
|
|
1279
|
-
'</p>';
|
|
1280
|
-
// DETACHED (non-suspending) send: constant-time, no provider-RTT parking on
|
|
1281
|
-
// the "account exists" path (see the enumeration note on this method).
|
|
1282
|
-
EmailService.sendDetached(email, subject, text, 'verify', html);
|
|
1263
|
+
// The built-in confirm email, or an `emails/auth-confirm.tsx` override filling
|
|
1264
|
+
// `{link}`. DETACHED (non-suspending) send: constant-time, no provider-RTT
|
|
1265
|
+
// parking on the "account exists" path (see the enumeration note on this
|
|
1266
|
+
// method); AuthEmail.confirm uses sendDetached, so an override keeps that.
|
|
1267
|
+
AuthEmail.confirm(email, link);
|
|
1283
1268
|
}
|
|
1284
1269
|
|
|
1285
1270
|
/** Email a password-reset link. Best-effort (the request path already
|
|
@@ -1292,19 +1277,10 @@ class Auth {
|
|
|
1292
1277
|
* (Residual: the reset-token mint on the exists path still does a sub-ms
|
|
1293
1278
|
* ToilDB write the miss path skips.) */
|
|
1294
1279
|
private sendResetEmail(email: string, link: string): void {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
link +
|
|
1301
|
-
'">Reset my password</a></p>' +
|
|
1302
|
-
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
1303
|
-
'<p>Or paste this into your browser:<br>' +
|
|
1304
|
-
link +
|
|
1305
|
-
'</p>';
|
|
1306
|
-
// DETACHED (non-suspending) send: constant-time, closes the reset-request
|
|
1307
|
-
// email-enumeration timing oracle (see the note above).
|
|
1308
|
-
EmailService.sendDetached(email, subject, text, 'reset', html);
|
|
1280
|
+
// The built-in reset email, or an `emails/auth-reset.tsx` override filling
|
|
1281
|
+
// `{link}`. DETACHED (non-suspending) send: constant-time, closes the
|
|
1282
|
+
// reset-request email-enumeration timing oracle (see the note above);
|
|
1283
|
+
// AuthEmail.reset uses sendDetached, so an override keeps that property.
|
|
1284
|
+
AuthEmail.reset(email, link);
|
|
1309
1285
|
}
|
|
1310
1286
|
}
|
package/server/globals/email.ts
CHANGED
|
@@ -68,7 +68,7 @@ export namespace EmailService {
|
|
|
68
68
|
* the off-core mailer reports a result.
|
|
69
69
|
*
|
|
70
70
|
* `body` is the plain-text body; pass a non-empty `html` to send an HTML
|
|
71
|
-
* message (then `body` is the plain-text alternative
|
|
71
|
+
* message (then `body` is the plain-text alternative; set both for the best
|
|
72
72
|
* deliverability, or leave `body` empty for HTML-only). `purpose` is a short,
|
|
73
73
|
* non-PII tag used for host-side dedup/abuse keying.
|
|
74
74
|
*/
|
|
@@ -181,7 +181,7 @@ export class RenderedEmail {
|
|
|
181
181
|
* welcome.send('alice@example.com', vars, 'welcome');
|
|
182
182
|
*
|
|
183
183
|
* `{{ key }}` (with surrounding spaces) is accepted; an unknown placeholder
|
|
184
|
-
* renders to the empty string. `html` is optional
|
|
184
|
+
* renders to the empty string. `html` is optional: omit it for a plain-text
|
|
185
185
|
* template.
|
|
186
186
|
*/
|
|
187
187
|
export class EmailTemplate {
|
|
@@ -209,6 +209,19 @@ export class EmailTemplate {
|
|
|
209
209
|
const r = this.render(vars);
|
|
210
210
|
return EmailService.send(to, r.subject, r.body, purpose, r.html);
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Render and DETACHED-send to `to`: frames the message and returns via the
|
|
215
|
+
* non-suspending {@link EmailService.sendDetached}, so the call is constant
|
|
216
|
+
* time regardless of whether the recipient exists. Use for transactional auth
|
|
217
|
+
* mail (confirm / reset links, 2FA codes) whose delivery result the caller
|
|
218
|
+
* does not need; this is what keeps the built-in auth flows free of an
|
|
219
|
+
* email-enumeration timing oracle even when an app overrides the template.
|
|
220
|
+
*/
|
|
221
|
+
sendDetached(to: string, vars: Map<string, string>, purpose: string = 'tx'): EmailStatus {
|
|
222
|
+
const r = this.render(vars);
|
|
223
|
+
return EmailService.sendDetached(to, r.subject, r.body, purpose, r.html);
|
|
224
|
+
}
|
|
212
225
|
}
|
|
213
226
|
|
|
214
227
|
/**
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in auth email templates + their per-app OVERRIDE pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The built-in auth controller (`server/auth/AuthController.ts`) sends three
|
|
5
|
+
* transactional emails: the email-verification link, the password-reset link,
|
|
6
|
+
* and the 2FA code. Their default subject/text/html live here as the single
|
|
7
|
+
* source of truth. An app overrides any of them by dropping a reserved-name
|
|
8
|
+
* React template in `emails/`:
|
|
9
|
+
*
|
|
10
|
+
* emails/auth-confirm.tsx -> email verification (interpolates `{link}`)
|
|
11
|
+
* emails/auth-reset.tsx -> password reset (interpolates `{link}`)
|
|
12
|
+
* emails/auth-2fa.tsx -> 2FA code (interpolates `{code}`)
|
|
13
|
+
*
|
|
14
|
+
* Whenever auth is on, the build (re)writes an ambient `_auth_emails.ts` into the
|
|
15
|
+
* toiljs `server/globals` LIB dir, exposing `AuthEmail.confirm/reset/twofa` baked
|
|
16
|
+
* with the app's override when present and the default otherwise. Because it rides
|
|
17
|
+
* the same `lib` set as `EmailService`/`AuthService`, the node_modules-compiled
|
|
18
|
+
* `AuthController` calls it with NO import (an exported namespace in a plain app
|
|
19
|
+
* entry would be module-scoped, not global). Every send goes through
|
|
20
|
+
* `EmailTemplate.sendDetached`, so overriding a template can never reintroduce the
|
|
21
|
+
* auth email-enumeration timing oracle the detached send closes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
import pc from 'picocolors';
|
|
29
|
+
import { createServer } from 'vite';
|
|
30
|
+
|
|
31
|
+
import type { ResolvedToilConfig } from './config.js';
|
|
32
|
+
import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
|
|
33
|
+
import { createViteConfig } from './vite.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The toiljs package's `server/globals` LIB directory: the ambient, no-import
|
|
37
|
+
* surface (`EmailService`, `AuthService`, …) the toilconfig `lib` array points
|
|
38
|
+
* at. The auth-email module is generated HERE, not in the app's `server/` dir,
|
|
39
|
+
* because that is what makes `AuthEmail` resolvable from the node_modules-compiled
|
|
40
|
+
* `AuthController` with NO import (an exported namespace in a plain app entry is
|
|
41
|
+
* module-scoped, not global). Prefers the app's install so a symlinked/linked
|
|
42
|
+
* toiljs is followed; falls back to the running package (hoisted install).
|
|
43
|
+
*/
|
|
44
|
+
function globalsDir(root: string): string {
|
|
45
|
+
const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
|
|
46
|
+
if (fs.existsSync(inApp)) return inApp;
|
|
47
|
+
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
48
|
+
return path.join(pkgDir, 'server', 'globals');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The reserved override names (PascalCase), one per built-in auth email. */
|
|
52
|
+
type AuthName = 'AuthConfirm' | 'AuthReset' | 'Auth2fa';
|
|
53
|
+
|
|
54
|
+
/** One built-in auth email: its reserved override name, the token it fills, the
|
|
55
|
+
* host-side `purpose` tag, and the default subject/text/html (with `{{token}}`). */
|
|
56
|
+
interface AuthEmailSpec {
|
|
57
|
+
/** The generated `AuthEmail.<fn>` this feeds. */
|
|
58
|
+
readonly fn: 'confirm' | 'reset' | 'twofa';
|
|
59
|
+
/** The single runtime value the template interpolates. */
|
|
60
|
+
readonly token: 'link' | 'code';
|
|
61
|
+
/** Host dedup/abuse tag; must match what AuthController passed before. */
|
|
62
|
+
readonly purpose: string;
|
|
63
|
+
/** Default subject. Empty (and ignored) for `twofa`: AuthController passes login/setup subjects. */
|
|
64
|
+
readonly defaultSubject: string;
|
|
65
|
+
readonly defaultText: string;
|
|
66
|
+
readonly defaultHtml: string;
|
|
67
|
+
/** Whether the subject is a runtime arg (twofa) rather than a baked default (confirm/reset). */
|
|
68
|
+
readonly subjectFromArg: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The reserved override name -> its spec. Keys mirror {@link RESERVED_AUTH_EMAIL_NAMES}. */
|
|
72
|
+
const SPECS: Record<AuthName, AuthEmailSpec> = {
|
|
73
|
+
AuthConfirm: {
|
|
74
|
+
fn: 'confirm',
|
|
75
|
+
token: 'link',
|
|
76
|
+
purpose: 'verify',
|
|
77
|
+
subjectFromArg: false,
|
|
78
|
+
defaultSubject: 'Confirm your account',
|
|
79
|
+
defaultText: 'Confirm your account by opening this link:\n{{link}}\n',
|
|
80
|
+
defaultHtml:
|
|
81
|
+
'<p>Confirm your account by clicking the link below:</p>' +
|
|
82
|
+
'<p><a href="{{link}}">Confirm my account</a></p>' +
|
|
83
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
84
|
+
},
|
|
85
|
+
AuthReset: {
|
|
86
|
+
fn: 'reset',
|
|
87
|
+
token: 'link',
|
|
88
|
+
purpose: 'reset',
|
|
89
|
+
subjectFromArg: false,
|
|
90
|
+
defaultSubject: 'Reset your password',
|
|
91
|
+
defaultText: 'Reset your password by opening this link:\n{{link}}\n',
|
|
92
|
+
defaultHtml:
|
|
93
|
+
'<p>We received a request to reset your password. Click the link below:</p>' +
|
|
94
|
+
'<p><a href="{{link}}">Reset my password</a></p>' +
|
|
95
|
+
'<p>If you did not request this, you can ignore this email.</p>' +
|
|
96
|
+
'<p>Or paste this into your browser:<br>{{link}}</p>',
|
|
97
|
+
},
|
|
98
|
+
Auth2fa: {
|
|
99
|
+
fn: 'twofa',
|
|
100
|
+
token: 'code',
|
|
101
|
+
purpose: '2fa',
|
|
102
|
+
subjectFromArg: true,
|
|
103
|
+
// Subject is supplied by AuthController (login vs setup), so it is not baked.
|
|
104
|
+
defaultSubject: '',
|
|
105
|
+
defaultText:
|
|
106
|
+
'Your verification code is {{code}}.\n' +
|
|
107
|
+
'It expires in a few minutes. If you did not request it, ignore this email.\n',
|
|
108
|
+
defaultHtml:
|
|
109
|
+
'<p>Your verification code is:</p>' +
|
|
110
|
+
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">{{code}}</p>' +
|
|
111
|
+
'<p>It expires in a few minutes. If you did not request it, ignore this email.</p>',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const AUTH_NAMES = Object.keys(SPECS) as AuthName[];
|
|
116
|
+
|
|
117
|
+
/** The effective (override-or-default) parts for each auth email. */
|
|
118
|
+
type Parts = Record<AuthName, Effective>;
|
|
119
|
+
interface Effective {
|
|
120
|
+
readonly subject: string;
|
|
121
|
+
readonly text: string;
|
|
122
|
+
readonly html: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const GENERATED_BASENAME = '_auth_emails.ts';
|
|
126
|
+
|
|
127
|
+
function warn(msg: string): void {
|
|
128
|
+
process.stderr.write(` toil: auth emails ${msg}\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A valid AS/JS string literal (double-quoted, fully escaped). */
|
|
132
|
+
function asLit(s: string): string {
|
|
133
|
+
return JSON.stringify(s);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every reserved auth-override file present in `emails/`, by PascalCase name. */
|
|
137
|
+
function reservedFilesIn(emailsDir: string): Map<AuthName, string> {
|
|
138
|
+
const out = new Map<AuthName, string>();
|
|
139
|
+
if (!fs.existsSync(emailsDir)) return out;
|
|
140
|
+
for (const f of fs.readdirSync(emailsDir).sort()) {
|
|
141
|
+
if (!/\.(tsx|jsx)$/.test(f)) continue;
|
|
142
|
+
const name = toPascal(f.replace(/\.(tsx|jsx)$/, ''));
|
|
143
|
+
if (RESERVED_AUTH_EMAIL_NAMES.has(name) && !out.has(name as AuthName))
|
|
144
|
+
out.set(name as AuthName, f);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fold a rendered override into the effective parts for `name`, warning about
|
|
151
|
+
* templates that will not interpolate correctly (a missing required token means
|
|
152
|
+
* the link/code would be absent; an extra token renders empty). A `subject` the
|
|
153
|
+
* renderer defaulted to the module name is treated as "no custom subject".
|
|
154
|
+
*/
|
|
155
|
+
function effectiveFromOverride(name: AuthName, spec: AuthEmailSpec, r: RenderedEmail): Effective {
|
|
156
|
+
if (!r.tokens.includes(spec.token))
|
|
157
|
+
warn(
|
|
158
|
+
`override emails/${name} never uses the {${spec.token}} prop, so the ` +
|
|
159
|
+
`${spec.token === 'link' ? 'link' : 'code'} will be missing from the email.`,
|
|
160
|
+
);
|
|
161
|
+
for (const t of r.tokens)
|
|
162
|
+
if (t !== spec.token)
|
|
163
|
+
warn(
|
|
164
|
+
`override emails/${name} uses {${t}}, which auth does not provide ` +
|
|
165
|
+
`(only {${spec.token}}); it will render empty.`,
|
|
166
|
+
);
|
|
167
|
+
// For twofa the subject is a runtime arg; for confirm/reset use the template's
|
|
168
|
+
// subject unless it defaulted to the component name (no `export const subject`).
|
|
169
|
+
const subject = spec.subjectFromArg || r.subject === name ? spec.defaultSubject : r.subject;
|
|
170
|
+
return { subject, text: r.text, html: r.html };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Codegen one `AuthEmail.<fn>` function. `twofa` takes its subject as an arg. */
|
|
174
|
+
function emitFn(spec: AuthEmailSpec, eff: Effective): string[] {
|
|
175
|
+
const subjectExpr = spec.subjectFromArg ? 'subject' : asLit(eff.subject);
|
|
176
|
+
const sig =
|
|
177
|
+
spec.fn === 'twofa'
|
|
178
|
+
? 'twofa(to: string, code: string, subject: string): void'
|
|
179
|
+
: `${spec.fn}(to: string, ${spec.token}: string): void`;
|
|
180
|
+
return [
|
|
181
|
+
` export function ${sig} {`,
|
|
182
|
+
` const T: string = ${asLit(eff.text)};`,
|
|
183
|
+
` const H: string = ${asLit(eff.html)};`,
|
|
184
|
+
` const v = new Map<string, string>();`,
|
|
185
|
+
` v.set(${asLit(spec.token)}, ${spec.token});`,
|
|
186
|
+
` new EmailTemplate(${subjectExpr}, T, H).sendDetached(to, v, ${asLit(spec.purpose)});`,
|
|
187
|
+
` }`,
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Codegen the ambient `AuthEmail` AssemblyScript module. */
|
|
192
|
+
function moduleSource(parts: Parts): string {
|
|
193
|
+
const out: string[] = [
|
|
194
|
+
'// GENERATED by toiljs from the built-in auth email templates -- DO NOT EDIT.',
|
|
195
|
+
'// Override any of these by adding emails/auth-confirm.tsx, emails/auth-reset.tsx,',
|
|
196
|
+
'// or emails/auth-2fa.tsx (see docs/auth/emails.md). `EmailTemplate` is a toiljs',
|
|
197
|
+
'// global (server/globals/email.ts); the detached send keeps auth constant-time.',
|
|
198
|
+
'',
|
|
199
|
+
'export namespace AuthEmail {',
|
|
200
|
+
];
|
|
201
|
+
for (const name of AUTH_NAMES) out.push(...emitFn(SPECS[name], parts[name]));
|
|
202
|
+
out.push('}');
|
|
203
|
+
return out.join('\n') + '\n';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* (Re)write `_auth_emails.ts` in the toiljs lib globals dir so the built-in auth
|
|
208
|
+
* controller has its email senders, baking any reserved `emails/*.tsx` override
|
|
209
|
+
* over the default. Runs BEFORE the toilscript server build (like
|
|
210
|
+
* {@link renderEmails}) so the module is compiled in. A no-op that removes a stale
|
|
211
|
+
* module when `authOn` is false. Only spins up Vite when an override actually
|
|
212
|
+
* exists.
|
|
213
|
+
*/
|
|
214
|
+
export async function renderAuthEmails(cfg: ResolvedToilConfig, authOn: boolean): Promise<void> {
|
|
215
|
+
const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
|
|
216
|
+
|
|
217
|
+
if (!authOn) {
|
|
218
|
+
if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const emailsDir = path.join(cfg.root, 'emails');
|
|
223
|
+
const overrides = reservedFilesIn(emailsDir);
|
|
224
|
+
|
|
225
|
+
// Start from every default, then render + fold in whatever the app overrides.
|
|
226
|
+
const parts = {} as Parts;
|
|
227
|
+
for (const name of AUTH_NAMES) {
|
|
228
|
+
const spec = SPECS[name];
|
|
229
|
+
parts[name] = { subject: spec.defaultSubject, text: spec.defaultText, html: spec.defaultHtml };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (overrides.size > 0) {
|
|
233
|
+
const { renderToStaticMarkup } = await import('react-dom/server');
|
|
234
|
+
const server = await createServer({
|
|
235
|
+
...(await createViteConfig(cfg)),
|
|
236
|
+
server: { middlewareMode: true, hmr: false },
|
|
237
|
+
appType: 'custom',
|
|
238
|
+
logLevel: 'silent',
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
for (const [name, file] of overrides) {
|
|
242
|
+
try {
|
|
243
|
+
const r = await renderEmailFile(
|
|
244
|
+
server,
|
|
245
|
+
emailsDir,
|
|
246
|
+
file,
|
|
247
|
+
renderToStaticMarkup as (el: unknown) => string,
|
|
248
|
+
);
|
|
249
|
+
if (r) parts[name] = effectiveFromOverride(name, SPECS[name], r);
|
|
250
|
+
else warn(`skipped ${file} (no default-exported component)`);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
await server.close();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const next = moduleSource(parts);
|
|
261
|
+
const prev = fs.existsSync(generatedPath) ? fs.readFileSync(generatedPath, 'utf8') : null;
|
|
262
|
+
if (prev === next) return; // no change: do not bump mtime (would retrigger the dev watcher)
|
|
263
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
264
|
+
fs.writeFileSync(generatedPath, next);
|
|
265
|
+
if (overrides.size > 0)
|
|
266
|
+
process.stdout.write(
|
|
267
|
+
pc.green(' ✓ ') +
|
|
268
|
+
pc.dim(
|
|
269
|
+
`auth emails: overrode ${[...overrides.keys()].map((n) => SPECS[n].fn).join(', ')}`,
|
|
270
|
+
) +
|
|
271
|
+
'\n',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The generated module's basename, so the server watcher can ignore its own output. */
|
|
276
|
+
export const AUTH_EMAILS_GENERATED_BASENAME = GENERATED_BASENAME;
|
|
277
|
+
|
|
278
|
+
// Exported for unit testing the pure fold/codegen without a Vite server.
|
|
279
|
+
export const __test = { moduleSource, effectiveFromOverride, SPECS };
|