toiljs 0.0.45 → 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.
@@ -0,0 +1,253 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { renderEmailFile, toPascal } from './emails.js';
4
+ export function emailsDir(cfg) {
5
+ return path.join(cfg.root, 'emails');
6
+ }
7
+ export function emailsVersion(cfg) {
8
+ let newest = 0;
9
+ let count = 0;
10
+ const CSS = /\.(css|scss|sass|less|styl|pcss|postcss)$/;
11
+ const walk = (dir, match) => {
12
+ let entries;
13
+ try {
14
+ entries = fs.readdirSync(dir, { withFileTypes: true });
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const e of entries) {
20
+ const full = path.join(dir, e.name);
21
+ if (e.isDirectory()) {
22
+ if (e.name !== 'node_modules')
23
+ walk(full, match);
24
+ }
25
+ else if (match.test(e.name)) {
26
+ try {
27
+ const m = fs.statSync(full).mtimeMs;
28
+ if (m > newest)
29
+ newest = m;
30
+ count++;
31
+ }
32
+ catch {
33
+ }
34
+ }
35
+ }
36
+ };
37
+ walk(emailsDir(cfg), /\.(tsx|jsx|css|scss|sass|less|styl|pcss|postcss)$/);
38
+ walk(cfg.clientAbsDir, CSS);
39
+ return `${String(newest)}:${String(count)}`;
40
+ }
41
+ export function listEmails(cfg) {
42
+ const dir = emailsDir(cfg);
43
+ if (!fs.existsSync(dir))
44
+ return [];
45
+ return fs
46
+ .readdirSync(dir)
47
+ .filter((f) => /\.(tsx|jsx)$/.test(f))
48
+ .sort()
49
+ .map((f) => ({
50
+ name: toPascal(path.basename(f).replace(/\.(tsx|jsx)$/, '')),
51
+ file: path.join(dir, f),
52
+ }));
53
+ }
54
+ export async function renderEmailByName(server, cfg, name) {
55
+ const item = listEmails(cfg).find((e) => e.name === name);
56
+ if (!item)
57
+ return null;
58
+ const node = server.moduleGraph.getModuleById(item.file) ??
59
+ (await server.moduleGraph.getModuleByUrl(item.file));
60
+ if (node)
61
+ server.moduleGraph.invalidateModule(node);
62
+ const { renderToStaticMarkup } = await import('react-dom/server');
63
+ return renderEmailFile(server, emailsDir(cfg), path.basename(item.file), renderToStaticMarkup);
64
+ }
65
+ export function previewShellHtml() {
66
+ return `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8" />
70
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
71
+ <title>Email preview · toiljs</title>
72
+ <style>
73
+ :root { color-scheme: dark; }
74
+ * { box-sizing: border-box; }
75
+ body { margin: 0; height: 100vh; display: flex; font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #0c0c11; color: #e7e9f0; }
76
+ #side { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; display: flex; flex-direction: column; background: #101016; }
77
+ .brand { padding: 14px 16px; font-weight: 600; border-bottom: 1px solid #23232e; }
78
+ #list { list-style: none; margin: 0; padding: 6px; overflow: auto; flex: 1; }
79
+ #list li { padding: 8px 10px; border-radius: 8px; cursor: pointer; color: #c8cee0; }
80
+ #list li:hover { background: #181820; }
81
+ #list li.on { background: #1d1d6b33; color: #fff; }
82
+ #list li.muted { color: #6b7080; cursor: default; }
83
+ .hint { padding: 10px 16px; font-size: 12px; color: #6b7080; border-top: 1px solid #23232e; }
84
+ .hint code { color: #9aa1b8; }
85
+ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
86
+ .empty { margin: auto; color: #6b7080; }
87
+ #view { display: flex; flex-direction: column; height: 100%; }
88
+ .bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #23232e; }
89
+ .subj { min-width: 0; flex: 1; display: flex; align-items: baseline; gap: 8px; }
90
+ .subj .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #6b7080; }
91
+ #subject { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
92
+ .actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
93
+ .seg { display: flex; border: 1px solid #2c2c38; border-radius: 8px; overflow: hidden; }
94
+ .seg-btn { background: #15151c; border: 0; color: #8b90a4; font: inherit; padding: 6px 12px; cursor: pointer; }
95
+ .seg-btn.on { background: #2563ff; color: #fff; }
96
+ .btn { background: #15151c; border: 1px solid #2c2c38; color: #c8cee0; font: inherit; padding: 6px 12px; border-radius: 8px; cursor: pointer; }
97
+ .btn:hover { color: #fff; border-color: #3a3a48; }
98
+ .body { display: flex; flex: 1; min-height: 0; }
99
+ .tokens { width: 240px; flex: 0 0 auto; border-right: 1px solid #23232e; padding: 12px; overflow: auto; }
100
+ .field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
101
+ .fname { font-size: 12px; color: #9aa1b8; }
102
+ .field input { background: #0c0c11; border: 1px solid #2c2c38; border-radius: 6px; color: #e7e9f0; font: inherit; padding: 6px 8px; }
103
+ .field input:focus { outline: none; border-color: #2563ff; }
104
+ .muted { color: #6b7080; font-size: 12px; }
105
+ .preview { flex: 1; min-width: 0; background: #f6f7f9; }
106
+ #frame { width: 100%; height: 100%; border: 0; background: #fff; }
107
+ #text { width: 100%; height: 100%; margin: 0; padding: 16px; overflow: auto; background: #0c0c11; color: #c8cee0; white-space: pre-wrap; }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <aside id="side">
112
+ <div class="brand">✉ Emails</div>
113
+ <ul id="list"></ul>
114
+ <div class="hint">Author in <code>emails/*.tsx</code>; this updates live on save.</div>
115
+ </aside>
116
+ <main id="main">
117
+ <div id="empty" class="empty">Select an email to preview.</div>
118
+ <section id="view" hidden>
119
+ <header class="bar">
120
+ <div class="subj"><span class="lbl">Subject</span><span id="subject"></span></div>
121
+ <div class="actions">
122
+ <div class="seg"><button id="tab-html" class="seg-btn on">HTML</button><button id="tab-text" class="seg-btn">Text</button></div>
123
+ <button id="open" class="btn">Open in editor</button>
124
+ </div>
125
+ </header>
126
+ <div class="body">
127
+ <div class="tokens" id="tokens"></div>
128
+ <div class="preview"><iframe id="frame" title="email preview"></iframe><pre id="text" hidden></pre></div>
129
+ </div>
130
+ </section>
131
+ </main>
132
+ <script>
133
+ (function () {
134
+ var BASE = '/__toil/emails';
135
+ var listEl = document.getElementById('list');
136
+ var subjectEl = document.getElementById('subject');
137
+ var frame = document.getElementById('frame');
138
+ var textEl = document.getElementById('text');
139
+ var tokensEl = document.getElementById('tokens');
140
+ var emptyEl = document.getElementById('empty');
141
+ var viewEl = document.getElementById('view');
142
+ var tabHtml = document.getElementById('tab-html');
143
+ var tabText = document.getElementById('tab-text');
144
+ var openBtn = document.getElementById('open');
145
+ var current = null, rendered = null, format = 'html', values = {};
146
+
147
+ function fill(s) {
148
+ return String(s).replace(/\\{\\{\\s*([A-Za-z_$][\\w$]*)\\s*\\}\\}/g, function (m, k) {
149
+ return Object.prototype.hasOwnProperty.call(values, k) ? values[k] : m;
150
+ });
151
+ }
152
+ function paint() {
153
+ if (!rendered) return;
154
+ subjectEl.textContent = fill(rendered.subject);
155
+ if (format === 'html') {
156
+ frame.hidden = false; textEl.hidden = true;
157
+ frame.srcdoc = fill(rendered.html);
158
+ } else {
159
+ frame.hidden = true; textEl.hidden = false;
160
+ textEl.textContent = fill(rendered.text);
161
+ }
162
+ }
163
+ function paintTokens() {
164
+ tokensEl.textContent = '';
165
+ if (!rendered.tokens.length) {
166
+ var none = document.createElement('div');
167
+ none.className = 'muted'; none.textContent = 'No {{tokens}} in this email.';
168
+ tokensEl.appendChild(none); return;
169
+ }
170
+ rendered.tokens.forEach(function (t) {
171
+ var row = document.createElement('label'); row.className = 'field';
172
+ var span = document.createElement('span'); span.className = 'fname'; span.textContent = t;
173
+ var inp = document.createElement('input');
174
+ inp.value = values[t] != null ? values[t] : t;
175
+ values[t] = inp.value;
176
+ inp.addEventListener('input', function () { values[t] = inp.value; paint(); });
177
+ row.appendChild(span); row.appendChild(inp); tokensEl.appendChild(row);
178
+ });
179
+ }
180
+ function setFormat(f) {
181
+ format = f;
182
+ tabHtml.classList.toggle('on', f === 'html');
183
+ tabText.classList.toggle('on', f === 'text');
184
+ paint();
185
+ }
186
+ tabHtml.addEventListener('click', function () { setFormat('html'); });
187
+ tabText.addEventListener('click', function () { setFormat('text'); });
188
+ openBtn.addEventListener('click', function () {
189
+ if (current) fetch('/__toil/open?file=' + encodeURIComponent(current.file)).catch(function () {});
190
+ });
191
+
192
+ function select(item, keep) {
193
+ current = item;
194
+ Array.prototype.forEach.call(listEl.children, function (li) {
195
+ li.classList.toggle('on', li.getAttribute('data-name') === item.name);
196
+ });
197
+ fetch(BASE + '/render?name=' + encodeURIComponent(item.name)).then(function (r) {
198
+ if (!r.ok) throw new Error('render failed');
199
+ return r.json();
200
+ }).then(function (data) {
201
+ rendered = data;
202
+ if (!keep) values = {};
203
+ emptyEl.hidden = true; viewEl.hidden = false;
204
+ paintTokens(); paint();
205
+ }).catch(function () {
206
+ emptyEl.hidden = false; viewEl.hidden = true;
207
+ emptyEl.textContent = 'Could not render ' + item.name + ' (see dev server logs).';
208
+ });
209
+ }
210
+ function buildList(items) {
211
+ listEl.textContent = '';
212
+ if (!items.length) {
213
+ var li = document.createElement('li');
214
+ li.className = 'muted'; li.textContent = 'No emails/*.tsx found.';
215
+ listEl.appendChild(li); return;
216
+ }
217
+ items.forEach(function (it) {
218
+ var li = document.createElement('li');
219
+ li.setAttribute('data-name', it.name);
220
+ li.textContent = it.name;
221
+ li.classList.toggle('on', !!current && it.name === current.name);
222
+ li.addEventListener('click', function () { select(it, false); });
223
+ listEl.appendChild(li);
224
+ });
225
+ }
226
+ function refresh() {
227
+ fetch(BASE + '/list').then(function (r) { return r.json(); }).then(function (items) {
228
+ buildList(items);
229
+ if (!items.length) {
230
+ current = null; rendered = null;
231
+ emptyEl.hidden = false; viewEl.hidden = true;
232
+ emptyEl.textContent = 'No emails/*.tsx found.';
233
+ return;
234
+ }
235
+ var match = current && items.filter(function (it) { return it.name === current.name; })[0];
236
+ select(match || items[0], !!match);
237
+ }).catch(function () {});
238
+ }
239
+ refresh();
240
+ // Live refresh: poll a cheap mtime fingerprint; re-render when it changes.
241
+ var version = null;
242
+ setInterval(function () {
243
+ fetch(BASE + '/version').then(function (r) { return r.text(); }).then(function (v) {
244
+ if (version === null) { version = v; return; }
245
+ if (v !== version) { version = v; refresh(); }
246
+ }).catch(function () {});
247
+ }, 1000);
248
+ })();
249
+ </script>
250
+ </body>
251
+ </html>
252
+ `;
253
+ }
@@ -1,3 +1,4 @@
1
+ import { type ViteDevServer } from 'vite';
1
2
  import type { ResolvedToilConfig } from './config.js';
2
3
  interface EmailModule {
3
4
  default: unknown;
@@ -5,7 +6,7 @@ interface EmailModule {
5
6
  text?: unknown;
6
7
  purpose?: unknown;
7
8
  }
8
- interface RenderedEmail {
9
+ export interface RenderedEmail {
9
10
  name: string;
10
11
  subject: string;
11
12
  html: string;
@@ -15,8 +16,10 @@ interface RenderedEmail {
15
16
  }
16
17
  declare function tokenProps(seen: Set<string>): Record<string, unknown>;
17
18
  declare function htmlToText(html: string): string;
18
- declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string): Promise<RenderedEmail | null>;
19
- declare function toPascal(base: string): string;
19
+ declare function renderModule(name: string, mod: EmailModule, render: (el: unknown) => string, css?: string): Promise<RenderedEmail | null>;
20
+ export declare function collectModuleCss(server: ViteDevServer, moduleId: string): Promise<string>;
21
+ export declare function renderEmailFile(server: ViteDevServer, emailsDir: string, file: string, render: (el: unknown) => string): Promise<RenderedEmail | null>;
22
+ export declare function toPascal(base: string): string;
20
23
  declare function renderModuleSource(rendered: RenderedEmail[]): string;
21
24
  export declare function renderEmails(cfg: ResolvedToilConfig): Promise<void>;
22
25
  export declare const __test: {
@@ -59,13 +59,13 @@ function htmlToText(html) {
59
59
  .replace(/[ \t]*\n[ \t]*/g, '\n')
60
60
  .trim();
61
61
  }
62
- async function renderModule(name, mod, render) {
62
+ async function renderModule(name, mod, render, css = '') {
63
63
  if (typeof mod.default !== 'function')
64
64
  return null;
65
65
  const seen = new Set();
66
66
  const component = mod.default;
67
67
  let html = render(component(tokenProps(seen)));
68
- html = await inlineCss(html);
68
+ html = await inlineCss(css ? `<style>${css}</style>${html}` : html);
69
69
  const subject = typeof mod.subject === 'string' ? mod.subject : name;
70
70
  const text = typeof mod.text === 'string' ? mod.text : htmlToText(html);
71
71
  const purpose = typeof mod.purpose === 'string' && mod.purpose.length > 0
@@ -81,7 +81,51 @@ async function renderModule(name, mod, render) {
81
81
  ].sort();
82
82
  return { name, subject, html, text, tokens, purpose };
83
83
  }
84
- function toPascal(base) {
84
+ const CSS_RE = /\.(css|scss|sass|less|styl|pcss|postcss)(\?|$)/;
85
+ export async function collectModuleCss(server, moduleId) {
86
+ const seen = new Set();
87
+ const cssIds = new Set();
88
+ const visit = (id) => {
89
+ if (seen.has(id))
90
+ return;
91
+ seen.add(id);
92
+ const mod = server.moduleGraph.getModuleById(id);
93
+ if (!mod)
94
+ return;
95
+ for (const dep of mod.importedModules) {
96
+ const depId = dep.id ?? dep.url;
97
+ if (!depId)
98
+ continue;
99
+ if (CSS_RE.test(depId))
100
+ cssIds.add(depId);
101
+ else
102
+ visit(depId);
103
+ }
104
+ };
105
+ visit(moduleId);
106
+ let css = '';
107
+ for (const id of cssIds) {
108
+ const base = id.split('?')[0] ?? id;
109
+ try {
110
+ const mod = (await server.ssrLoadModule(`${base}?inline`));
111
+ if (typeof mod.default === 'string')
112
+ css += mod.default + '\n';
113
+ }
114
+ catch {
115
+ }
116
+ }
117
+ return css;
118
+ }
119
+ export async function renderEmailFile(server, emailsDir, file, render) {
120
+ const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
121
+ const filePath = path.join(emailsDir, file);
122
+ const mod = (await server.ssrLoadModule(filePath));
123
+ const node = server.moduleGraph.getModuleById(filePath) ??
124
+ (await server.moduleGraph.getModuleByUrl(filePath));
125
+ const css = node?.id ? await collectModuleCss(server, node.id) : '';
126
+ return renderModule(name, mod, render, css);
127
+ }
128
+ export function toPascal(base) {
85
129
  return base
86
130
  .split(/[-_\s.]+/)
87
131
  .filter(Boolean)
@@ -171,20 +215,16 @@ export async function renderEmails(cfg) {
171
215
  const rendered = [];
172
216
  try {
173
217
  for (const file of files) {
174
- const name = toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''));
175
- let mod;
176
218
  try {
177
- mod = (await server.ssrLoadModule(path.join(emailsDir, file)));
219
+ const r = await renderEmailFile(server, emailsDir, file, renderToStaticMarkup);
220
+ if (r)
221
+ rendered.push(r);
222
+ else
223
+ warn(`skipped ${file} (no default-exported component)`);
178
224
  }
179
225
  catch (err) {
180
226
  warn(`skipped ${file} (${err instanceof Error ? err.message : String(err)})`);
181
- continue;
182
227
  }
183
- const r = await renderModule(name, mod, renderToStaticMarkup);
184
- if (r)
185
- rendered.push(r);
186
- else
187
- warn(`skipped ${file} (no default-exported component)`);
188
228
  }
189
229
  }
190
230
  finally {
@@ -187,6 +187,18 @@ async function freeLoopbackPort() {
187
187
  });
188
188
  });
189
189
  }
190
+ function printEmailsUrl(cfg, localUrl) {
191
+ if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails')))
192
+ return;
193
+ process.stdout.write(' ' +
194
+ pc.green('✉') +
195
+ ' ' +
196
+ pc.bold('Emails') +
197
+ ': ' +
198
+ pc.cyan(`${localUrl}__toil/emails`) +
199
+ pc.dim(' (preview)') +
200
+ '\n');
201
+ }
190
202
  export async function dev(opts = {}) {
191
203
  const cfg = await loadConfig(opts);
192
204
  const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
@@ -201,6 +213,7 @@ export async function dev(opts = {}) {
201
213
  const server = await createServer(await createViteConfig(cfg));
202
214
  await server.listen();
203
215
  server.printUrls();
216
+ printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
204
217
  return server;
205
218
  }
206
219
  const vitePort = await freeLoopbackPort();
@@ -228,6 +241,7 @@ export async function dev(opts = {}) {
228
241
  pc.cyan(`http://localhost:${pc.bold(String(front.port))}/`) +
229
242
  pc.dim(' (wasm server + vite)') +
230
243
  '\n');
244
+ printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
231
245
  watchServer(cfg, server.watcher);
232
246
  return server;
233
247
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { version as viteVersion } from 'vite';
7
7
  import { AiProvider } from './config.js';
8
+ import { emailsVersion, listEmails, previewShellHtml, renderEmailByName } from './email-preview.js';
8
9
  import { generate } from './generate.js';
9
10
  import { scanRoutes } from './routes.js';
10
11
  async function aiComplete(ai, prompt) {
@@ -191,7 +192,9 @@ export function toilPlugin(cfg) {
191
192
  void (async () => {
192
193
  try {
193
194
  const parsed = JSON.parse(body || '{}');
194
- const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.slice(0, 16000) : '';
195
+ const prompt = typeof parsed.prompt === 'string'
196
+ ? parsed.prompt.slice(0, 16000)
197
+ : '';
195
198
  const text = await aiComplete(ai, prompt);
196
199
  res.setHeader('content-type', 'application/json');
197
200
  res.end(JSON.stringify({ text }));
@@ -200,7 +203,9 @@ export function toilPlugin(cfg) {
200
203
  process.stderr.write(`toil: /__toil/ai failed: ${e instanceof Error ? e.message : String(e)}\n`);
201
204
  res.statusCode = 500;
202
205
  res.setHeader('content-type', 'application/json');
203
- res.end(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
206
+ res.end(JSON.stringify({
207
+ error: 'AI request failed (see dev server logs).',
208
+ }));
204
209
  }
205
210
  })();
206
211
  });
@@ -247,6 +252,63 @@ export function toilPlugin(cfg) {
247
252
  res.end();
248
253
  }
249
254
  });
255
+ server.middlewares.use('/__toil/emails/list', (req, res) => {
256
+ if (isCrossSiteRequest(req.headers)) {
257
+ res.statusCode = 403;
258
+ res.end();
259
+ return;
260
+ }
261
+ res.setHeader('content-type', 'application/json');
262
+ res.end(JSON.stringify(listEmails(cfg)));
263
+ });
264
+ server.middlewares.use('/__toil/emails/render', (req, res) => {
265
+ if (isCrossSiteRequest(req.headers)) {
266
+ res.statusCode = 403;
267
+ res.end();
268
+ return;
269
+ }
270
+ void (async () => {
271
+ try {
272
+ const name = new URL(req.url ?? '', 'http://localhost').searchParams.get('name');
273
+ if (!name) {
274
+ res.statusCode = 400;
275
+ res.end();
276
+ return;
277
+ }
278
+ const rendered = await renderEmailByName(server, cfg, name);
279
+ if (!rendered) {
280
+ res.statusCode = 404;
281
+ res.end();
282
+ return;
283
+ }
284
+ res.setHeader('content-type', 'application/json');
285
+ res.end(JSON.stringify(rendered));
286
+ }
287
+ catch (e) {
288
+ process.stderr.write(`toil: /__toil/emails/render failed: ${e instanceof Error ? e.message : String(e)}\n`);
289
+ res.statusCode = 500;
290
+ res.end();
291
+ }
292
+ })();
293
+ });
294
+ server.middlewares.use('/__toil/emails/version', (req, res) => {
295
+ if (isCrossSiteRequest(req.headers)) {
296
+ res.statusCode = 403;
297
+ res.end();
298
+ return;
299
+ }
300
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
301
+ res.end(emailsVersion(cfg));
302
+ });
303
+ server.middlewares.use('/__toil/emails', (req, res) => {
304
+ if (isCrossSiteRequest(req.headers)) {
305
+ res.statusCode = 403;
306
+ res.end();
307
+ return;
308
+ }
309
+ res.setHeader('content-type', 'text/html; charset=utf-8');
310
+ res.end(previewShellHtml());
311
+ });
250
312
  const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
251
313
  const onChange = (file) => {
252
314
  if (file.replace(/\\/g, '/').startsWith(routesPrefix)) {
@@ -98,6 +98,7 @@ export async function createViteConfig(cfg) {
98
98
  alias: {
99
99
  'toiljs/client': cfg.runtimePath,
100
100
  'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
101
+ client: cfg.clientAbsDir,
101
102
  ...polyfillShimAliases,
102
103
  },
103
104
  dedupe: ['react', 'react-dom'],
package/docs/email.md CHANGED
@@ -109,6 +109,7 @@ export default defineConfig({
109
109
  },
110
110
  });
111
111
  ```
112
+
112
113
  ```bash
113
114
  # .env.secrets (gitignored)
114
115
  TOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx
@@ -137,8 +138,8 @@ class Notify {
137
138
  const status = EmailService.send(
138
139
  'alice@example.com',
139
140
  'Welcome!',
140
- 'Thanks for signing up.', // plain-text body
141
- 'welcome', // purpose tag (dedup / abuse keying)
141
+ 'Thanks for signing up.', // plain-text body
142
+ 'welcome', // purpose tag (dedup / abuse keying)
142
143
  '<h1>Thanks for signing up.</h1>', // optional HTML body
143
144
  );
144
145
  return status == EmailStatus.Sent
@@ -150,16 +151,16 @@ class Notify {
150
151
 
151
152
  `send(to, subject, body, purpose = 'tx', html = '')` returns an **`EmailStatus`**:
152
153
 
153
- | Status | Meaning | Retry? |
154
- | --- | --- | --- |
155
- | `Sent` | Accepted by the provider | — |
156
- | `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |
157
- | `Budget` | The host's per-minute budget is exhausted | yes, later |
158
- | `TryLater` | The mailer was saturated / a queue was full | yes, back off |
159
- | `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |
160
- | `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |
161
- | `Disabled` | This host has no `[email]` capability | no |
162
- | `ProviderError` | The provider rejected it, or transport failed after retries | no |
154
+ | Status | Meaning | Retry? |
155
+ | ----------------- | ----------------------------------------------------------- | ---------------- |
156
+ | `Sent` | Accepted by the provider | — |
157
+ | `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |
158
+ | `Budget` | The host's per-minute budget is exhausted | yes, later |
159
+ | `TryLater` | The mailer was saturated / a queue was full | yes, back off |
160
+ | `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |
161
+ | `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |
162
+ | `Disabled` | This host has no `[email]` capability | no |
163
+ | `ProviderError` | The provider rejected it, or transport failed after retries | no |
163
164
 
164
165
  `purpose` is a short, non-PII tag (`"welcome"`, `"reset"`, …). The mailer folds
165
166
  it into the **dedup** key (identical `(host, recipient, purpose)` within ~30s is
@@ -175,8 +176,8 @@ when the same email is sent with different values:
175
176
 
176
177
  ```ts
177
178
  const welcome = new EmailTemplate(
178
- 'Welcome, {{name}}!', // subject
179
- 'Hi {{name}}, your code is {{code}}.', // plain-text body
179
+ 'Welcome, {{name}}!', // subject
180
+ 'Hi {{name}}, your code is {{code}}.', // plain-text body
180
181
  '<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)
181
182
  );
182
183
 
@@ -208,12 +209,16 @@ export const subject = 'Welcome, {{name}}!';
208
209
 
209
210
  export default function Welcome({ name, code }: { name: string; code: string }) {
210
211
  return (
211
- <table width="100%" style={{ fontFamily: 'Arial, sans-serif' }}>
212
+ <table
213
+ width="100%"
214
+ style={{ fontFamily: 'Arial, sans-serif' }}>
212
215
  <tbody>
213
216
  <tr>
214
217
  <td style={{ padding: '24px' }}>
215
218
  <h1 style={{ color: '#111' }}>Welcome, {name}!</h1>
216
- <p>Your code is <b>{code}</b>.</p>
219
+ <p>
220
+ Your code is <b>{code}</b>.
221
+ </p>
217
222
  </td>
218
223
  </tr>
219
224
  </tbody>
@@ -232,9 +237,12 @@ const status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');
232
237
 
233
238
  Authoring notes:
234
239
 
235
- - **Use inline `style={{ ... }}`.** Email clients strip `<style>`/external CSS;
236
- inline styles render everywhere. A CSS file imported into the component is
237
- inlined for you at build (via `juice`).
240
+ - **Styles must end up inline.** Email clients strip `<style>`/external CSS, so
241
+ write inline `style={{ ... }}`, or import a stylesheet and its rules are
242
+ inlined into element `style="…"` for you at build (a bare CSS import has no
243
+ effect on its own under SSR). Keep email-only styles next to the email, e.g.
244
+ `import './styles/email.css'`, or **reuse existing project CSS** with `import
245
+ 'client/styles/…'` (the `client/*` alias points at your client source).
238
246
  - **Optional exports:** `export const subject` (a token template; defaults to the
239
247
  email name), `export const text` (a plain-text alternative; otherwise derived
240
248
  from the HTML), `export const purpose`.
@@ -246,6 +254,14 @@ Authoring notes:
246
254
  - The generated `server/_emails.ts` is regenerated on `build`/`dev` and should be
247
255
  gitignored.
248
256
 
257
+ ### Preview while you author
258
+
259
+ While `toiljs dev` runs, open **`/__toil/emails`** (the dev banner prints the
260
+ link). It lists every `emails/*.tsx`, renders the selected one exactly as the
261
+ build does (imported `client/*` CSS inlined), lets you fill each `{{token}}` to
262
+ see the result, toggle the HTML and plain-text parts, and open the file in your
263
+ editor. It refreshes live as you edit the template or its CSS.
264
+
249
265
  ## Email verification codes (`TwoFactor`)
250
266
 
251
267
  `TwoFactor` is a **stateless** email-code primitive (2FA, email confirmation,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.45",
4
+ "version": "0.0.46",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -153,7 +153,7 @@
153
153
  "@btc-vision/as-pect-cli": "^8.3.0",
154
154
  "@btc-vision/as-pect-transform": "^8.3.0",
155
155
  "@clack/prompts": "^1.5.0",
156
- "@microsoft/api-extractor": "7.58.8",
156
+ "@microsoft/api-extractor": "7.58.9",
157
157
  "@testing-library/dom": "^10.4.1",
158
158
  "@testing-library/react": "^16.3.2",
159
159
  "@types/node": "^25.9.1",
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.