toiljs 0.0.44 → 0.0.46

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/RSG.md +105 -27
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +12 -2
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +4 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/email-preview.d.ts +12 -0
  9. package/build/compiler/email-preview.js +253 -0
  10. package/build/compiler/emails.d.ts +6 -3
  11. package/build/compiler/emails.js +52 -12
  12. package/build/compiler/index.js +15 -0
  13. package/build/compiler/plugin.js +64 -2
  14. package/build/compiler/vite.js +1 -0
  15. package/build/devserver/.tsbuildinfo +1 -1
  16. package/build/devserver/dotenv.d.ts +8 -0
  17. package/build/devserver/dotenv.js +59 -0
  18. package/build/devserver/email/caps.d.ts +9 -0
  19. package/build/devserver/email/caps.js +0 -0
  20. package/build/devserver/email/config.d.ts +21 -0
  21. package/build/devserver/email/config.js +72 -0
  22. package/build/devserver/email/index.d.ts +25 -0
  23. package/build/devserver/email/index.js +57 -0
  24. package/build/devserver/email/providers.d.ts +12 -0
  25. package/build/devserver/email/providers.js +96 -0
  26. package/build/devserver/email/status.d.ts +10 -0
  27. package/build/devserver/email/status.js +11 -0
  28. package/build/devserver/email/validate.d.ts +2 -0
  29. package/build/devserver/email/validate.js +24 -0
  30. package/build/devserver/email/wire.d.ts +8 -0
  31. package/build/devserver/email/wire.js +32 -0
  32. package/build/devserver/env.js +5 -54
  33. package/build/devserver/host.js +22 -7
  34. package/build/devserver/index.d.ts +2 -0
  35. package/build/devserver/index.js +8 -0
  36. package/build/shared/.tsbuildinfo +1 -1
  37. package/build/shared/index.d.ts +13 -0
  38. package/docs/email.md +64 -22
  39. package/package.json +4 -2
  40. package/src/cli/create.ts +2 -2
  41. package/src/cli/doctor.ts +15 -0
  42. package/src/compiler/config.ts +14 -0
  43. package/src/compiler/email-preview.ts +305 -0
  44. package/src/compiler/emails.ts +82 -12
  45. package/src/compiler/index.ts +20 -0
  46. package/src/compiler/plugin.ts +88 -4
  47. package/src/compiler/vite.ts +4 -0
  48. package/src/devserver/dotenv.ts +94 -0
  49. package/src/devserver/email/caps.ts +0 -0
  50. package/src/devserver/email/config.ts +123 -0
  51. package/src/devserver/email/index.ts +111 -0
  52. package/src/devserver/email/providers.ts +130 -0
  53. package/src/devserver/email/status.ts +23 -0
  54. package/src/devserver/email/validate.ts +40 -0
  55. package/src/devserver/email/wire.ts +55 -0
  56. package/src/devserver/env.ts +8 -65
  57. package/src/devserver/host.ts +29 -12
  58. package/src/devserver/index.ts +20 -0
  59. package/src/shared/index.ts +36 -0
  60. package/test/devserver-email.test.ts +241 -0
  61. package/test/email-preview.test.ts +68 -0
  62. package/test/emails.test.ts +58 -0
package/src/cli/create.ts CHANGED
@@ -156,14 +156,14 @@ function scaffold(
156
156
  ' "compilerOptions": {\n' +
157
157
  ' "paths": { "shared/*": ["./shared/*"] }\n' +
158
158
  ' },\n' +
159
- ' "include": ["client", "shared", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
159
+ ' "include": ["client", "shared", "emails", "toil.config.ts", "toil-env.d.ts", "toil-routes.d.ts"]\n' +
160
160
  '}\n',
161
161
  'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
162
162
  '.prettierrc': '"toiljs/prettier"\n',
163
163
  // Generated files don't need formatting. (toilscript server decorators like @main /
164
164
  // @remote-on-functions are handled by the toiljs/prettier-plugin, so server/ is not ignored.)
165
165
  '.prettierignore':
166
- 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
166
+ 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_emails.ts\nserver/toil-server-env.d.ts\n',
167
167
  '.gitignore':
168
168
  'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
169
169
  // Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
package/src/cli/doctor.ts CHANGED
@@ -296,9 +296,24 @@ function applyRpcFix(root: string): RpcFixResult {
296
296
  // and synthesizing one would narrow what TypeScript sees.
297
297
  if (Array.isArray(tsconfig.include)) {
298
298
  const include = [...(tsconfig.include as unknown[])];
299
+ let changedInclude = false;
300
+ // `shared` (the @data/@remote RPC alias target) right after `client`.
299
301
  if (!include.includes('shared')) {
300
302
  const at = include.indexOf('client');
301
303
  include.splice(at >= 0 ? at + 1 : include.length, 0, 'shared');
304
+ changedInclude = true;
305
+ }
306
+ // `emails` (the React email-template pipeline) + `toil.config.ts`, so the
307
+ // typescript-eslint project service / editor cover them — otherwise
308
+ // `emails/*.tsx` (and a typed `toil.config.ts`) raise "not found by the
309
+ // project service". Harmless when absent (a non-matching glob).
310
+ for (const entry of ['emails', 'toil.config.ts']) {
311
+ if (!include.includes(entry)) {
312
+ include.push(entry);
313
+ changedInclude = true;
314
+ }
315
+ }
316
+ if (changedInclude) {
302
317
  tsconfig.include = include;
303
318
  touched = true;
304
319
  }
@@ -3,10 +3,12 @@ import path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
5
  import { type InlineConfig } from 'vite';
6
+ import { type EmailBackendConfig } from 'toiljs/shared';
6
7
 
7
8
  import { type SeoConfig } from './seo.js';
8
9
 
9
10
  export type { SeoConfig } from './seo.js';
11
+ export type { EmailBackendConfig, SmtpBackendConfig } from 'toiljs/shared';
10
12
 
11
13
  /** Built-in AI providers the dev toolbar can proxy to. */
12
14
  export enum AiProvider {
@@ -103,6 +105,15 @@ export interface ServerConfig {
103
105
  readonly srcDir?: string;
104
106
  /** Server build output directory, relative to root. Default `build/server`. */
105
107
  readonly outDir?: string;
108
+ /**
109
+ * Email backend config (the dev server and the future Node self-host). The
110
+ * non-secret pieces — provider, `from`, send caps, SMTP host/port/user. The
111
+ * API key / SMTP password is a SECRET and lives ONLY in `.env.secrets`
112
+ * (`TOIL_EMAIL_API_KEY`); any `TOIL_EMAIL_*` env var overrides the matching
113
+ * field here. The production edge ignores this (it reads `TOIL_EMAIL_*` from
114
+ * the per-tenant env store); this drives `toiljs dev` / self-host.
115
+ */
116
+ readonly email?: EmailBackendConfig;
106
117
  }
107
118
 
108
119
  /**
@@ -144,6 +155,8 @@ export interface ResolvedToilConfig {
144
155
  readonly devtoolsAi: DevtoolsAiConfig | null;
145
156
  /** Build-time SEO config, or `null` when not configured. */
146
157
  readonly seo: SeoConfig | null;
158
+ /** The `server.email` backend config (dev / self-host), or `null` when unset. */
159
+ readonly email: EmailBackendConfig | null;
147
160
  /** Absolute path to the framework client runtime (`toiljs/client`). */
148
161
  readonly runtimePath: string;
149
162
  readonly vite: InlineConfig;
@@ -215,6 +228,7 @@ export async function loadConfig(
215
228
  ? (client.devtools.ai ?? null)
216
229
  : null,
217
230
  seo: client.seo ?? null,
231
+ email: user.server?.email ?? null,
218
232
  runtimePath: resolveRuntimePath(),
219
233
  vite: client.vite ?? {},
220
234
  };
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Dev-only email preview tool. Backs the `/__toil/emails*` endpoints (wired in
3
+ * `plugin.ts`): a standalone page that lists `emails/*.tsx`, renders the selected
4
+ * one through the live Vite SSR server (so edits and imported `client/*` CSS show
5
+ * up), and fills `{{token}}` holes from inputs the same way the edge does at send
6
+ * time. Build-path parity comes from sharing `renderEmailFile` with the codegen
7
+ * pass (`emails.ts`), so what you preview is what gets baked into `server/_emails.ts`.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ import type { ViteDevServer } from 'vite';
14
+
15
+ import type { ResolvedToilConfig } from './config.js';
16
+ import { renderEmailFile, toPascal, type RenderedEmail } from './emails.js';
17
+
18
+ /** One discoverable email: its generated `Emails.<name>` and its absolute file. */
19
+ export interface EmailListItem {
20
+ name: string;
21
+ /** Absolute path, used for "open in editor" (`/__toil/open?file=`). */
22
+ file: string;
23
+ }
24
+
25
+ /** The `emails/` dir for a project (sibling of `client/` and `server/`). */
26
+ export function emailsDir(cfg: ResolvedToilConfig): string {
27
+ return path.join(cfg.root, 'emails');
28
+ }
29
+
30
+ /**
31
+ * A cheap change fingerprint (`<newestMtime>:<fileCount>`) over `emails/*.tsx|jsx`
32
+ * and the project's client CSS, polled by the preview page to detect edits (any
33
+ * save bumps an mtime to ~now; add/remove changes the count). Stat-only, so it is
34
+ * fine to poll ~1/s. Used instead of a long-lived stream, which the buffering wasm
35
+ * dev proxy can't forward.
36
+ */
37
+ export function emailsVersion(cfg: ResolvedToilConfig): string {
38
+ let newest = 0;
39
+ let count = 0;
40
+ const CSS = /\.(css|scss|sass|less|styl|pcss|postcss)$/;
41
+ const walk = (dir: string, match: RegExp): void => {
42
+ let entries: fs.Dirent[];
43
+ try {
44
+ entries = fs.readdirSync(dir, { withFileTypes: true });
45
+ } catch {
46
+ return;
47
+ }
48
+ for (const e of entries) {
49
+ const full = path.join(dir, e.name);
50
+ if (e.isDirectory()) {
51
+ if (e.name !== 'node_modules') walk(full, match);
52
+ } else if (match.test(e.name)) {
53
+ try {
54
+ const m = fs.statSync(full).mtimeMs;
55
+ if (m > newest) newest = m;
56
+ count++;
57
+ } catch {
58
+ // file vanished between readdir and stat; ignore
59
+ }
60
+ }
61
+ }
62
+ };
63
+ // Email templates and any styles beside them (emails/styles/*), plus client
64
+ // CSS in case an email reuses `client/styles/*`.
65
+ walk(emailsDir(cfg), /\.(tsx|jsx|css|scss|sass|less|styl|pcss|postcss)$/);
66
+ walk(cfg.clientAbsDir, CSS);
67
+ return `${String(newest)}:${String(count)}`;
68
+ }
69
+
70
+ /** List `emails/*.tsx|jsx`, mapped to their generated names. Cheap (no render). */
71
+ export function listEmails(cfg: ResolvedToilConfig): EmailListItem[] {
72
+ const dir = emailsDir(cfg);
73
+ if (!fs.existsSync(dir)) return [];
74
+ return fs
75
+ .readdirSync(dir)
76
+ .filter((f) => /\.(tsx|jsx)$/.test(f))
77
+ .sort()
78
+ .map((f) => ({
79
+ name: toPascal(path.basename(f).replace(/\.(tsx|jsx)$/, '')),
80
+ file: path.join(dir, f),
81
+ }));
82
+ }
83
+
84
+ /**
85
+ * Render the email whose generated name is `name` through the live SSR server,
86
+ * or `null` if there is no such file. Drops the module from the SSR cache first
87
+ * so an edit is reflected on every request (the watcher also invalidates on save;
88
+ * this makes a manual refresh fresh too).
89
+ */
90
+ export async function renderEmailByName(
91
+ server: ViteDevServer,
92
+ cfg: ResolvedToilConfig,
93
+ name: string,
94
+ ): Promise<RenderedEmail | null> {
95
+ const item = listEmails(cfg).find((e) => e.name === name);
96
+ if (!item) return null;
97
+ const node =
98
+ server.moduleGraph.getModuleById(item.file) ??
99
+ (await server.moduleGraph.getModuleByUrl(item.file));
100
+ if (node) server.moduleGraph.invalidateModule(node);
101
+ const { renderToStaticMarkup } = await import('react-dom/server');
102
+ return renderEmailFile(
103
+ server,
104
+ emailsDir(cfg),
105
+ path.basename(item.file),
106
+ renderToStaticMarkup as (el: unknown) => string,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * The self-contained preview page (served at `/__toil/emails`). Plain HTML + a
112
+ * tiny inline script -- no client-runtime dependency, so it works in both the
113
+ * client-only and wasm-server dev modes. Token substitution happens here in the
114
+ * browser (`{{token}}` -> input value), so typing is instant and the iframe shows
115
+ * exactly the edge's hole-fill path.
116
+ */
117
+ export function previewShellHtml(): string {
118
+ return `<!doctype html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="utf-8" />
122
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
123
+ <title>Email preview · toiljs</title>
124
+ <style>
125
+ :root { color-scheme: dark; }
126
+ * { box-sizing: border-box; }
127
+ body { margin: 0; height: 100vh; display: flex; font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #0c0c11; color: #e7e9f0; }
128
+ #side { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; display: flex; flex-direction: column; background: #101016; }
129
+ .brand { padding: 14px 16px; font-weight: 600; border-bottom: 1px solid #23232e; }
130
+ #list { list-style: none; margin: 0; padding: 6px; overflow: auto; flex: 1; }
131
+ #list li { padding: 8px 10px; border-radius: 8px; cursor: pointer; color: #c8cee0; }
132
+ #list li:hover { background: #181820; }
133
+ #list li.on { background: #1d1d6b33; color: #fff; }
134
+ #list li.muted { color: #6b7080; cursor: default; }
135
+ .hint { padding: 10px 16px; font-size: 12px; color: #6b7080; border-top: 1px solid #23232e; }
136
+ .hint code { color: #9aa1b8; }
137
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
138
+ .empty { margin: auto; color: #6b7080; }
139
+ #view { display: flex; flex-direction: column; height: 100%; }
140
+ .bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #23232e; }
141
+ .subj { min-width: 0; flex: 1; display: flex; align-items: baseline; gap: 8px; }
142
+ .subj .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #6b7080; }
143
+ #subject { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
144
+ .actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
145
+ .seg { display: flex; border: 1px solid #2c2c38; border-radius: 8px; overflow: hidden; }
146
+ .seg-btn { background: #15151c; border: 0; color: #8b90a4; font: inherit; padding: 6px 12px; cursor: pointer; }
147
+ .seg-btn.on { background: #2563ff; color: #fff; }
148
+ .btn { background: #15151c; border: 1px solid #2c2c38; color: #c8cee0; font: inherit; padding: 6px 12px; border-radius: 8px; cursor: pointer; }
149
+ .btn:hover { color: #fff; border-color: #3a3a48; }
150
+ .body { display: flex; flex: 1; min-height: 0; }
151
+ .tokens { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; padding: 12px; overflow: auto; }
152
+ .field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
153
+ .fname { font-size: 12px; color: #9aa1b8; }
154
+ .field input { background: #0c0c11; border: 1px solid #2c2c38; border-radius: 6px; color: #e7e9f0; font: inherit; padding: 6px 8px; }
155
+ .field input:focus { outline: none; border-color: #2563ff; }
156
+ .muted { color: #6b7080; font-size: 12px; }
157
+ .preview { flex: 1; min-width: 0; background: #f6f7f9; }
158
+ #frame { width: 100%; height: 100%; border: 0; background: #fff; }
159
+ #text { width: 100%; height: 100%; margin: 0; padding: 16px; overflow: auto; background: #0c0c11; color: #c8cee0; white-space: pre-wrap; }
160
+ </style>
161
+ </head>
162
+ <body>
163
+ <aside id="side">
164
+ <div class="brand">✉ Emails</div>
165
+ <ul id="list"></ul>
166
+ <div class="hint">Author in <code>emails/*.tsx</code>; this updates live on save.</div>
167
+ </aside>
168
+ <main id="main">
169
+ <div id="empty" class="empty">Select an email to preview.</div>
170
+ <section id="view" hidden>
171
+ <header class="bar">
172
+ <div class="subj"><span class="lbl">Subject</span><span id="subject"></span></div>
173
+ <div class="actions">
174
+ <div class="seg"><button id="tab-html" class="seg-btn on">HTML</button><button id="tab-text" class="seg-btn">Text</button></div>
175
+ <button id="open" class="btn">Open in editor</button>
176
+ </div>
177
+ </header>
178
+ <div class="body">
179
+ <div class="tokens" id="tokens"></div>
180
+ <div class="preview"><iframe id="frame" title="email preview"></iframe><pre id="text" hidden></pre></div>
181
+ </div>
182
+ </section>
183
+ </main>
184
+ <script>
185
+ (function () {
186
+ var BASE = '/__toil/emails';
187
+ var listEl = document.getElementById('list');
188
+ var subjectEl = document.getElementById('subject');
189
+ var frame = document.getElementById('frame');
190
+ var textEl = document.getElementById('text');
191
+ var tokensEl = document.getElementById('tokens');
192
+ var emptyEl = document.getElementById('empty');
193
+ var viewEl = document.getElementById('view');
194
+ var tabHtml = document.getElementById('tab-html');
195
+ var tabText = document.getElementById('tab-text');
196
+ var openBtn = document.getElementById('open');
197
+ var current = null, rendered = null, format = 'html', values = {};
198
+
199
+ function fill(s) {
200
+ return String(s).replace(/\\{\\{\\s*([A-Za-z_$][\\w$]*)\\s*\\}\\}/g, function (m, k) {
201
+ return Object.prototype.hasOwnProperty.call(values, k) ? values[k] : m;
202
+ });
203
+ }
204
+ function paint() {
205
+ if (!rendered) return;
206
+ subjectEl.textContent = fill(rendered.subject);
207
+ if (format === 'html') {
208
+ frame.hidden = false; textEl.hidden = true;
209
+ frame.srcdoc = fill(rendered.html);
210
+ } else {
211
+ frame.hidden = true; textEl.hidden = false;
212
+ textEl.textContent = fill(rendered.text);
213
+ }
214
+ }
215
+ function paintTokens() {
216
+ tokensEl.textContent = '';
217
+ if (!rendered.tokens.length) {
218
+ var none = document.createElement('div');
219
+ none.className = 'muted'; none.textContent = 'No {{tokens}} in this email.';
220
+ tokensEl.appendChild(none); return;
221
+ }
222
+ rendered.tokens.forEach(function (t) {
223
+ var row = document.createElement('label'); row.className = 'field';
224
+ var span = document.createElement('span'); span.className = 'fname'; span.textContent = t;
225
+ var inp = document.createElement('input');
226
+ inp.value = values[t] != null ? values[t] : t;
227
+ values[t] = inp.value;
228
+ inp.addEventListener('input', function () { values[t] = inp.value; paint(); });
229
+ row.appendChild(span); row.appendChild(inp); tokensEl.appendChild(row);
230
+ });
231
+ }
232
+ function setFormat(f) {
233
+ format = f;
234
+ tabHtml.classList.toggle('on', f === 'html');
235
+ tabText.classList.toggle('on', f === 'text');
236
+ paint();
237
+ }
238
+ tabHtml.addEventListener('click', function () { setFormat('html'); });
239
+ tabText.addEventListener('click', function () { setFormat('text'); });
240
+ openBtn.addEventListener('click', function () {
241
+ if (current) fetch('/__toil/open?file=' + encodeURIComponent(current.file)).catch(function () {});
242
+ });
243
+
244
+ function select(item, keep) {
245
+ current = item;
246
+ Array.prototype.forEach.call(listEl.children, function (li) {
247
+ li.classList.toggle('on', li.getAttribute('data-name') === item.name);
248
+ });
249
+ fetch(BASE + '/render?name=' + encodeURIComponent(item.name)).then(function (r) {
250
+ if (!r.ok) throw new Error('render failed');
251
+ return r.json();
252
+ }).then(function (data) {
253
+ rendered = data;
254
+ if (!keep) values = {};
255
+ emptyEl.hidden = true; viewEl.hidden = false;
256
+ paintTokens(); paint();
257
+ }).catch(function () {
258
+ emptyEl.hidden = false; viewEl.hidden = true;
259
+ emptyEl.textContent = 'Could not render ' + item.name + ' (see dev server logs).';
260
+ });
261
+ }
262
+ function buildList(items) {
263
+ listEl.textContent = '';
264
+ if (!items.length) {
265
+ var li = document.createElement('li');
266
+ li.className = 'muted'; li.textContent = 'No emails/*.tsx found.';
267
+ listEl.appendChild(li); return;
268
+ }
269
+ items.forEach(function (it) {
270
+ var li = document.createElement('li');
271
+ li.setAttribute('data-name', it.name);
272
+ li.textContent = it.name;
273
+ li.classList.toggle('on', !!current && it.name === current.name);
274
+ li.addEventListener('click', function () { select(it, false); });
275
+ listEl.appendChild(li);
276
+ });
277
+ }
278
+ function refresh() {
279
+ fetch(BASE + '/list').then(function (r) { return r.json(); }).then(function (items) {
280
+ buildList(items);
281
+ if (!items.length) {
282
+ current = null; rendered = null;
283
+ emptyEl.hidden = false; viewEl.hidden = true;
284
+ emptyEl.textContent = 'No emails/*.tsx found.';
285
+ return;
286
+ }
287
+ var match = current && items.filter(function (it) { return it.name === current.name; })[0];
288
+ select(match || items[0], !!match);
289
+ }).catch(function () {});
290
+ }
291
+ refresh();
292
+ // Live refresh: poll a cheap mtime fingerprint; re-render when it changes.
293
+ var version = null;
294
+ setInterval(function () {
295
+ fetch(BASE + '/version').then(function (r) { return r.text(); }).then(function (v) {
296
+ if (version === null) { version = v; return; }
297
+ if (v !== version) { version = v; refresh(); }
298
+ }).catch(function () {});
299
+ }, 1000);
300
+ })();
301
+ </script>
302
+ </body>
303
+ </html>
304
+ `;
305
+ }
@@ -21,7 +21,7 @@
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
- import { createServer } from 'vite';
24
+ import { createServer, type ViteDevServer } from 'vite';
25
25
 
26
26
  import type { ResolvedToilConfig } from './config.js';
27
27
  import { createViteConfig } from './vite.js';
@@ -38,7 +38,7 @@ interface EmailModule {
38
38
  }
39
39
 
40
40
  /** One email rendered to its baked, token-holed parts. */
41
- interface RenderedEmail {
41
+ export interface RenderedEmail {
42
42
  name: string;
43
43
  subject: string;
44
44
  html: string;
@@ -132,13 +132,17 @@ async function renderModule(
132
132
  name: string,
133
133
  mod: EmailModule,
134
134
  render: (el: unknown) => string,
135
+ css = '',
135
136
  ): Promise<RenderedEmail | null> {
136
137
  if (typeof mod.default !== 'function') return null;
137
138
 
138
139
  const seen = new Set<string>();
139
140
  const component = mod.default as (props: unknown) => unknown;
140
141
  let html = render(component(tokenProps(seen)));
141
- html = await inlineCss(html);
142
+ // CSS the component imported (e.g. `import 'client/styles/email.css'`) is
143
+ // prepended as a <style> block so it gets inlined into element style="" like
144
+ // an inline block would -- under SSR a bare CSS import otherwise has no effect.
145
+ html = await inlineCss(css ? `<style>${css}</style>${html}` : html);
142
146
 
143
147
  const subject = typeof mod.subject === 'string' ? mod.subject : name;
144
148
  const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
@@ -161,8 +165,71 @@ async function renderModule(
161
165
  return { name, subject, html, text, tokens, purpose };
162
166
  }
163
167
 
168
+ const CSS_RE = /\.(css|scss|sass|less|styl|pcss|postcss)(\?|$)/;
169
+
170
+ /**
171
+ * Collect the CSS an email module transitively imports, as one string. Under SSR
172
+ * a bare `import 'client/styles/email.css'` produces no output, so we walk the
173
+ * Vite module graph from the email module, collect its CSS deps, and re-import
174
+ * each with `?inline` (Vite then returns the processed CSS as the default export,
175
+ * Tailwind/PostCSS included). The caller hands the result to `renderModule`,
176
+ * which inlines it into the HTML. Best-effort: a CSS dep that can't be inlined is
177
+ * skipped (the component's inline `style={{}}` props still render).
178
+ */
179
+ export async function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string> {
180
+ const seen = new Set<string>();
181
+ const cssIds = new Set<string>();
182
+ const visit = (id: string): void => {
183
+ if (seen.has(id)) return;
184
+ seen.add(id);
185
+ const mod = server.moduleGraph.getModuleById(id);
186
+ if (!mod) return;
187
+ for (const dep of mod.importedModules) {
188
+ const depId = dep.id ?? dep.url;
189
+ if (!depId) continue;
190
+ if (CSS_RE.test(depId)) cssIds.add(depId);
191
+ else visit(depId);
192
+ }
193
+ };
194
+ visit(moduleId);
195
+
196
+ let css = '';
197
+ for (const id of cssIds) {
198
+ const base = id.split('?')[0] ?? id;
199
+ try {
200
+ const mod = (await server.ssrLoadModule(`${base}?inline`)) as { default?: unknown };
201
+ if (typeof mod.default === 'string') css += mod.default + '\n';
202
+ } catch {
203
+ // skip a CSS dep we can't inline
204
+ }
205
+ }
206
+ return css;
207
+ }
208
+
209
+ /**
210
+ * Load one `emails/*.tsx` through `server` (SSR), collect any CSS it imports, and
211
+ * render it to its baked, token-holed parts. Shared by the build/codegen pass and
212
+ * the dev preview tool so both produce byte-identical output. Throws if the module
213
+ * fails to load; returns `null` if it has no default-exported component.
214
+ */
215
+ export async function renderEmailFile(
216
+ server: ViteDevServer,
217
+ emailsDir: string,
218
+ file: string,
219
+ render: (el: unknown) => string,
220
+ ): Promise<RenderedEmail | null> {
221
+ const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
222
+ const filePath = path.join(emailsDir, file);
223
+ const mod = (await server.ssrLoadModule(filePath)) as EmailModule;
224
+ const node =
225
+ server.moduleGraph.getModuleById(filePath) ??
226
+ (await server.moduleGraph.getModuleByUrl(filePath));
227
+ const css = node?.id ? await collectModuleCss(server, node.id) : '';
228
+ return renderModule(name, mod, render, css);
229
+ }
230
+
164
231
  /** `welcome-email` / `welcome_email` -> `WelcomeEmail`. */
165
- function toPascal(base: string): string {
232
+ export function toPascal(base: string): string {
166
233
  return base
167
234
  .split(/[-_\s.]+/)
168
235
  .filter(Boolean)
@@ -222,7 +289,9 @@ function renderModuleSource(rendered: RenderedEmail[]): string {
222
289
  .concat(params.map((p) => `${p.param}: string`))
223
290
  .concat(`purpose: string = ${asLit(e.purpose)}`)
224
291
  .join(', ');
225
- out.push(` /** Render and send this email to \`to\`. Returns the send's EmailStatus. */`);
292
+ out.push(
293
+ ` /** Render and send this email to \`to\`. Returns the send's EmailStatus. */`,
294
+ );
226
295
  out.push(` export function send(${sig}): EmailStatus {`);
227
296
  out.push(` const __v = new Map<string, string>();`);
228
297
  for (const p of params) out.push(` __v.set(${asLit(p.token)}, ${p.param});`);
@@ -274,17 +343,18 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
274
343
  const rendered: RenderedEmail[] = [];
275
344
  try {
276
345
  for (const file of files) {
277
- const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
278
- let mod: EmailModule;
279
346
  try {
280
- mod = (await server.ssrLoadModule(path.join(emailsDir, file))) as EmailModule;
347
+ const r = await renderEmailFile(
348
+ server,
349
+ emailsDir,
350
+ file,
351
+ renderToStaticMarkup as (el: unknown) => string,
352
+ );
353
+ if (r) rendered.push(r);
354
+ else warn(`skipped ${file} (no default-exported component)`);
281
355
  } catch (err) {
282
356
  warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
283
- continue;
284
357
  }
285
- const r = await renderModule(name, mod, renderToStaticMarkup as (el: unknown) => string);
286
- if (r) rendered.push(r);
287
- else warn(`skipped ${file} (no default-exported component)`);
288
358
  }
289
359
  } finally {
290
360
  await server.close();
@@ -268,6 +268,23 @@ export interface ToilCommandOptions {
268
268
  readonly serverOnly?: boolean;
269
269
  }
270
270
 
271
+ /** Prints the email-preview URL under the dev banner, when the project has an
272
+ * `emails/` folder. `localUrl` is the resolved base (ends in `/`); skipped if
273
+ * the server didn't report one. */
274
+ function printEmailsUrl(cfg: ResolvedToilConfig, localUrl: string | undefined): void {
275
+ if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails'))) return;
276
+ process.stdout.write(
277
+ ' ' +
278
+ pc.green('✉') +
279
+ ' ' +
280
+ pc.bold('Emails') +
281
+ ': ' +
282
+ pc.cyan(`${localUrl}__toil/emails`) +
283
+ pc.dim(' (preview)') +
284
+ '\n',
285
+ );
286
+ }
287
+
271
288
  /**
272
289
  * Starts the dev server. Client-only projects get the plain Vite dev server on
273
290
  * the configured port, unchanged. Projects with a server target
@@ -293,6 +310,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
293
310
  const server = await createServer(await createViteConfig(cfg));
294
311
  await server.listen();
295
312
  server.printUrls();
313
+ printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
296
314
  return server;
297
315
  }
298
316
 
@@ -310,6 +328,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
310
328
  port: cfg.port,
311
329
  wasmFile: serverWasmFile(cfg.root),
312
330
  vite: { host: '127.0.0.1', port: vitePort },
331
+ email: cfg.email ?? undefined,
313
332
  });
314
333
  server.httpServer?.once('close', () => {
315
334
  void front.close();
@@ -324,6 +343,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
324
343
  pc.dim(' (wasm server + vite)') +
325
344
  '\n',
326
345
  );
346
+ printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
327
347
 
328
348
  // Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
329
349
  // and the dev server hot-swaps the recompiled wasm module.