toiljs 0.0.105 → 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.
@@ -1188,19 +1188,12 @@ class Auth {
1188
1188
  switch (method) {
1189
1189
  case TWOFA_EMAIL: {
1190
1190
  const code = randomCode(TWOFA_DIGITS);
1191
- const text =
1192
- 'Your verification code is ' +
1193
- code +
1194
- '.\nIt expires in a few minutes. If you did not request it, ignore this email.\n';
1195
- const html =
1196
- '<p>Your verification code is:</p>' +
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
- const subject = 'Confirm your account';
1271
- const text = 'Confirm your account by opening this link:\n' + link + '\n';
1272
- const html =
1273
- '<p>Confirm your account by clicking the link below:</p>' +
1274
- '<p><a href="' +
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
- const subject = 'Reset your password';
1296
- const text = 'Reset your password by opening this link:\n' + link + '\n';
1297
- const html =
1298
- '<p>We received a request to reset your password. Click the link below:</p>' +
1299
- '<p><a href="' +
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
  }
@@ -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 set both for the best
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 omit it for a plain-text
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 };
@@ -63,6 +63,15 @@ const REACT_INTERNAL = new Set([
63
63
  'then', // so a thenable check never tokenizes
64
64
  ]);
65
65
 
66
+ /**
67
+ * Reserved `emails/*.tsx` module names (by PascalCase basename) that OVERRIDE a
68
+ * built-in auth email instead of becoming a generic `Emails.<Name>.send(...)`
69
+ * entry. When one is present, the auth pipeline (src/compiler/auth-emails.ts)
70
+ * bakes it into `_auth_emails.ts` in place of the default; here we just make sure
71
+ * it does NOT also surface as an app-callable `Emails.*` template.
72
+ */
73
+ export const RESERVED_AUTH_EMAIL_NAMES = new Set(['AuthConfirm', 'AuthReset', 'Auth2fa']);
74
+
66
75
  const TOKEN_RE = /\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}/g;
67
76
 
68
77
  function extractTokens(s: string): string[] {
@@ -240,7 +249,7 @@ export function toPascal(base: string): string {
240
249
 
241
250
  /** Server source dir (where the generated module must live to be compiled): the
242
251
  * dir of the first toilconfig entry, else `<root>/server`. */
243
- function serverDir(root: string): string {
252
+ export function serverDir(root: string): string {
244
253
  try {
245
254
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
246
255
  entries?: unknown;
@@ -344,6 +353,10 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
344
353
  const rendered: RenderedEmail[] = [];
345
354
  try {
346
355
  for (const file of files) {
356
+ // Reserved auth-override templates (emails/auth-confirm.tsx, …) are
357
+ // consumed by the auth pipeline, not surfaced as generic `Emails.*`.
358
+ if (RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''))))
359
+ continue;
347
360
  try {
348
361
  const r = await renderEmailFile(
349
362
  server,
@@ -14,6 +14,7 @@ import type { RunningBackend } from 'toiljs/backend';
14
14
 
15
15
  import { loadConfig, type ResolvedToilConfig } from './config.js';
16
16
  import { renderEmails } from './emails.js';
17
+ import { AUTH_EMAILS_GENERATED_BASENAME, renderAuthEmails } from './auth-emails.js';
17
18
  import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
18
19
  import { prerenderStaticParams } from './ssg.js';
19
20
  import {
@@ -179,6 +180,20 @@ function serverImportsAuth(root: string, files: string[]): boolean {
179
180
  return false;
180
181
  }
181
182
 
183
+ /**
184
+ * Whether the built-in auth surface is on for this project: the `server.auth` config flag OR the
185
+ * escape-hatch `import 'toiljs/server/auth'` in a server entry. This is the SAME predicate
186
+ * `buildServer` uses to decide whether to inject `AuthController` (see `authOn` there); the email
187
+ * pipeline reuses it so `_auth_emails.ts` is generated exactly when the controller that calls
188
+ * `AuthEmail.*` is compiled in (a mismatch would be a missing-symbol compile error). Client-only
189
+ * projects (no `toilconfig.json`) are never auth-on.
190
+ */
191
+ function serverAuthEnabled(root: string, configAuth: boolean): boolean {
192
+ if (configAuth) return true;
193
+ if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return false;
194
+ return serverImportsAuth(root, serverEntryFiles(root));
195
+ }
196
+
182
197
  /**
183
198
  * Builds the toilscript server target (which also regenerates `shared/server.ts` via
184
199
  * `--rpcModule`) when the project has one, signalled by a `toilconfig.json` at the root. This
@@ -581,6 +596,7 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
581
596
  // Recompile emails/*.tsx -> the generated module before the server build,
582
597
  // so editing an email template hot-reloads like any other server change.
583
598
  renderEmails(cfg)
599
+ .then(() => renderAuthEmails(cfg, serverAuthEnabled(root, cfg.auth)))
584
600
  .then(() => buildServer(root, cfg.auth))
585
601
  .then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
586
602
  .catch((e: unknown) =>
@@ -599,9 +615,10 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
599
615
  const isServerSource = (file: string): boolean =>
600
616
  file.endsWith('.ts') &&
601
617
  !file.endsWith('.d.ts') &&
602
- // `_emails.ts` is GENERATED by renderEmails on every rebuild; reacting to
618
+ // `_emails.ts` / `_auth_emails.ts` are GENERATED on every rebuild; reacting to
603
619
  // our own output would loop forever (rebuild -> write -> rebuild -> ...).
604
620
  path.basename(file) !== '_emails.ts' &&
621
+ path.basename(file) !== AUTH_EMAILS_GENERATED_BASENAME &&
605
622
  dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
606
623
  const isEmailSource = (file: string): boolean =>
607
624
  /\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
@@ -863,6 +880,9 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
863
880
  if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
864
881
  // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
865
882
  await renderEmails(cfg);
883
+ // Built-in auth: (re)generate `<server>/_auth_emails.ts` (defaults + any emails/auth-*.tsx
884
+ // overrides) so the injected AuthController's `AuthEmail.*` senders compile in.
885
+ await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
866
886
  // Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
867
887
  // emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
868
888
  // `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
@@ -988,6 +1008,9 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
988
1008
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
989
1009
  // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
990
1010
  await renderEmails(cfg);
1011
+ // Built-in auth: (re)generate `<server>/_auth_emails.ts` (defaults + any emails/auth-*.tsx
1012
+ // overrides) so the injected AuthController's `AuthEmail.*` senders compile in.
1013
+ await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
991
1014
  // Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
992
1015
  // server build — so the SSR slots pre-pass below can load the route/layout module graph and
993
1016
  // render the opted-in routes.