toiljs 0.0.97 → 0.0.99

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.
@@ -40,6 +40,203 @@ export function emailAction(parsed) {
40
40
  return { kind: 'code', value: code[1] };
41
41
  return null;
42
42
  }
43
+ const NAMED = {
44
+ black: [0, 0, 0], white: [235, 235, 235], red: [229, 57, 53], green: [67, 160, 71],
45
+ blue: [30, 136, 229], yellow: [253, 216, 53], orange: [251, 140, 0], purple: [142, 36, 170],
46
+ magenta: [216, 27, 96], pink: [233, 30, 99], cyan: [0, 172, 193], teal: [0, 137, 123],
47
+ indigo: [57, 73, 171], gray: [128, 128, 128], grey: [128, 128, 128], silver: [192, 192, 192],
48
+ gold: [255, 193, 7], brown: [121, 85, 72], navy: [26, 35, 126], lime: [124, 179, 66],
49
+ };
50
+ export function parseColor(value) {
51
+ const s = value.trim().toLowerCase();
52
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
53
+ if (hex !== null) {
54
+ const h = hex[1];
55
+ if (h.length === 3) {
56
+ return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];
57
+ }
58
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
59
+ }
60
+ const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/.exec(s);
61
+ if (rgb !== null) {
62
+ return [Math.min(255, +rgb[1]), Math.min(255, +rgb[2]), Math.min(255, +rgb[3])];
63
+ }
64
+ return NAMED[s];
65
+ }
66
+ function styleFromTag(name, attrs) {
67
+ const d = {};
68
+ if (name === 'b' || name === 'strong' || /^h[1-6]$/.test(name))
69
+ d.bold = true;
70
+ if (name === 'i' || name === 'em')
71
+ d.italic = true;
72
+ if (name === 'u' || name === 'ins')
73
+ d.underline = true;
74
+ if (name === 'a') {
75
+ d.underline = true;
76
+ d.color = NAMED.cyan;
77
+ }
78
+ if (name === 'code' || name === 'kbd' || name === 'pre' || name === 'samp')
79
+ d.color = NAMED.gray;
80
+ const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(attrs);
81
+ if (styleAttr !== null) {
82
+ const css = styleAttr[1].toLowerCase();
83
+ const colorM = /(?:^|;)\s*color\s*:\s*([^;]+)/.exec(css);
84
+ if (colorM !== null) {
85
+ const c = parseColor(colorM[1]);
86
+ if (c !== undefined)
87
+ d.color = c;
88
+ }
89
+ if (/font-weight\s*:\s*(?:bold|bolder|[6-9]00)/.test(css))
90
+ d.bold = true;
91
+ if (/font-style\s*:\s*italic/.test(css))
92
+ d.italic = true;
93
+ if (/text-decoration[^;]*:\s*[^;]*underline/.test(css))
94
+ d.underline = true;
95
+ }
96
+ if (name === 'font') {
97
+ const fc = /\bcolor\s*=\s*["']([^"']+)["']/i.exec(attrs);
98
+ if (fc !== null) {
99
+ const c = parseColor(fc[1]);
100
+ if (c !== undefined)
101
+ d.color = c;
102
+ }
103
+ }
104
+ return d;
105
+ }
106
+ function compose(stack) {
107
+ const out = {};
108
+ for (const s of stack) {
109
+ if (s.bold)
110
+ out.bold = true;
111
+ if (s.italic)
112
+ out.italic = true;
113
+ if (s.underline)
114
+ out.underline = true;
115
+ if (s.color !== undefined)
116
+ out.color = s.color;
117
+ }
118
+ return out;
119
+ }
120
+ const BLOCK = new Set([
121
+ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'table', 'ul', 'ol',
122
+ 'blockquote', 'section', 'header', 'footer', 'br', 'hr', 'article', 'main', 'nav', 'aside',
123
+ ]);
124
+ function htmlToParagraphs(html) {
125
+ const paras = [];
126
+ let cur = [];
127
+ let pendingSpace = false;
128
+ const flush = () => {
129
+ if (cur.length > 0) {
130
+ paras.push(cur);
131
+ cur = [];
132
+ }
133
+ pendingSpace = false;
134
+ };
135
+ const stack = [];
136
+ const hrefs = [];
137
+ const re = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>|([^<]+)/g;
138
+ let m;
139
+ while ((m = re.exec(html)) !== null) {
140
+ const raw = m[4];
141
+ if (raw !== undefined) {
142
+ const norm = decodeEntities(raw).replace(/\s+/g, ' ').trim();
143
+ if (norm.length === 0) {
144
+ if (/\s/.test(raw))
145
+ pendingSpace = true;
146
+ continue;
147
+ }
148
+ const leading = /^\s/.test(raw) || pendingSpace;
149
+ const st = compose(stack);
150
+ const words = norm.split(' ');
151
+ for (let i = 0; i < words.length; i++) {
152
+ cur.push({ text: words[i], style: st, spaceBefore: cur.length > 0 && (i > 0 || leading) });
153
+ }
154
+ pendingSpace = /\s$/.test(raw);
155
+ continue;
156
+ }
157
+ const closing = m[1] === '/';
158
+ const name = m[2].toLowerCase();
159
+ const attrs = m[3] ?? '';
160
+ if (BLOCK.has(name)) {
161
+ flush();
162
+ if (/^h[1-6]$/.test(name)) {
163
+ if (closing)
164
+ stack.pop();
165
+ else
166
+ stack.push(styleFromTag(name, attrs));
167
+ }
168
+ continue;
169
+ }
170
+ if (closing) {
171
+ stack.pop();
172
+ if (name === 'a') {
173
+ const href = hrefs.pop();
174
+ if (href) {
175
+ cur.push({
176
+ text: `(${href})`,
177
+ style: { ...compose(stack), color: NAMED.gray },
178
+ spaceBefore: cur.length > 0,
179
+ });
180
+ }
181
+ }
182
+ }
183
+ else {
184
+ stack.push(styleFromTag(name, attrs));
185
+ if (name === 'a') {
186
+ const hm = /\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs);
187
+ hrefs.push(hm !== null ? hm[1] : null);
188
+ }
189
+ }
190
+ }
191
+ flush();
192
+ return paras;
193
+ }
194
+ function paint(style, s) {
195
+ if (!pc.isColorSupported)
196
+ return s;
197
+ let open = '';
198
+ if (style.bold)
199
+ open += '\x1b[1m';
200
+ if (style.italic)
201
+ open += '\x1b[3m';
202
+ if (style.underline)
203
+ open += '\x1b[4m';
204
+ if (style.color !== undefined)
205
+ open += `\x1b[38;2;${style.color[0]};${style.color[1]};${style.color[2]}m`;
206
+ return open === '' ? s : `${open}${s}\x1b[0m`;
207
+ }
208
+ function wrapWords(words, width) {
209
+ const lines = [];
210
+ let line = [];
211
+ let len = 0;
212
+ for (const w of words) {
213
+ const sep = line.length > 0 && w.spaceBefore ? 1 : 0;
214
+ if (line.length > 0 && len + sep + w.text.length > width) {
215
+ lines.push(line);
216
+ line = [w];
217
+ len = w.text.length;
218
+ }
219
+ else {
220
+ line.push(w);
221
+ len += sep + w.text.length;
222
+ }
223
+ }
224
+ if (line.length > 0)
225
+ lines.push(line);
226
+ return lines;
227
+ }
228
+ export function renderHtmlBody(html, width = WIDTH) {
229
+ const paras = htmlToParagraphs(html);
230
+ const out = [];
231
+ for (let p = 0; p < paras.length; p++) {
232
+ if (p > 0)
233
+ out.push('');
234
+ for (const line of wrapWords(paras[p], width)) {
235
+ out.push(line.map((w, i) => (i > 0 && w.spaceBefore ? ' ' : '') + paint(w.style, w.text)).join(''));
236
+ }
237
+ }
238
+ return out;
239
+ }
43
240
  function wrap(text, width) {
44
241
  const out = [];
45
242
  for (const para of text.split('\n')) {
@@ -77,8 +274,8 @@ export function renderEmailConsole(parsed, status) {
77
274
  lines.push(bar + pc.dim('To: ') + parsed.to);
78
275
  lines.push(bar + pc.dim('Subject: ') + parsed.subject);
79
276
  lines.push(pc.dim(' │'));
80
- const bodyText = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
81
- for (const l of wrap(bodyText, WIDTH))
277
+ const bodyLines = parsed.html.length > 0 ? renderHtmlBody(parsed.html, WIDTH) : wrap(parsed.body, WIDTH);
278
+ for (const l of bodyLines)
82
279
  lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
83
280
  const action = emailAction(parsed);
84
281
  if (action !== null) {
@@ -59,7 +59,7 @@ export async function sendSmtp(cfg, msg) {
59
59
  host: smtp.host,
60
60
  port: smtp.port,
61
61
  secure: smtp.port === 465,
62
- auth: { user: smtp.user, pass: cfg.apiKey },
62
+ auth: cfg.apiKey.length > 0 ? { user: smtp.user, pass: cfg.apiKey } : undefined,
63
63
  connectionTimeout: POST_TIMEOUT_MS,
64
64
  greetingTimeout: POST_TIMEOUT_MS,
65
65
  });
@@ -112,6 +112,9 @@ function devEmailSend(ref, reqPtr, reqLen) {
112
112
  const { status, parsed } = svc.prepare(raw);
113
113
  if (parsed === null) {
114
114
  process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
115
+ const skipped = parseEmailBlob(raw);
116
+ if (skipped !== null)
117
+ previewDevEmail(skipped, EmailStatus[status]);
115
118
  return status;
116
119
  }
117
120
  recordSentEmail(parsed);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.97",
4
+ "version": "0.0.99",
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": {
package/src/cli/index.ts CHANGED
@@ -15,6 +15,19 @@ import { runUpdate } from './update.js';
15
15
  import { type Preprocessor, PREPROCESSORS } from './features.js';
16
16
  import { accent, banner, bold, danger, dim, success, version } from './ui.js';
17
17
 
18
+ // Node 22+/26 prints an ExperimentalWarning the first time any code touches the
19
+ // built-in `localStorage` global (some browser-oriented deps do when they are
20
+ // evaluated server-side during the dev SSR pass). It is harmless (localStorage
21
+ // works fine in the real browser) but noisy on every `toiljs dev`/`build`, so drop
22
+ // exactly that one warning here, before anything runs. Every other warning still
23
+ // prints. Installed on the CLI process, ahead of the compiler/dev work.
24
+ const __origEmitWarning = process.emitWarning.bind(process) as (...a: unknown[]) => void;
25
+ process.emitWarning = ((warning: unknown, ...rest: unknown[]): void => {
26
+ const msg = typeof warning === 'string' ? warning : (warning as { message?: string })?.message;
27
+ if (typeof msg === 'string' && msg.includes('localStorage is not available')) return;
28
+ __origEmitWarning(warning, ...rest);
29
+ }) as typeof process.emitWarning;
30
+
18
31
  interface Flags {
19
32
  root?: string;
20
33
  port?: number;
@@ -80,16 +80,20 @@ export function resolveEmailConfig(
80
80
  if (!validFrom(from)) {
81
81
  return { config: null, warning: 'email `from` is not a valid address (CRLF or no `@`)' };
82
82
  }
83
- if (apiKey === undefined) {
84
- return {
85
- config: null,
86
- warning: 'email config present but TOIL_EMAIL_API_KEY is not set (in .env.secrets)',
87
- };
88
- }
89
-
83
+ // The API-key requirement is PER PROVIDER: Resend authenticates with a key, and
84
+ // Gmail SMTP needs an app password (also carried in the key), but a plain SMTP
85
+ // relay (a local dev catch-all like Mailpit/MailHog on 127.0.0.1, or an internal
86
+ // open relay) needs NO credential. Requiring a key for plain SMTP silently
87
+ // disabled a perfectly good local-SMTP dev setup.
90
88
  let provider: ResolvedProvider;
91
89
  let smtp: ResolvedSmtp | undefined;
92
90
  if (providerId === 'resend') {
91
+ if (apiKey === undefined) {
92
+ return {
93
+ config: null,
94
+ warning: 'provider `resend` requires TOIL_EMAIL_API_KEY (in .env.secrets)',
95
+ };
96
+ }
93
97
  provider = 'resend';
94
98
  } else if (providerId === 'gmail' || providerId === 'smtp') {
95
99
  provider = 'smtp';
@@ -101,6 +105,14 @@ export function resolveEmailConfig(
101
105
  if (!host) {
102
106
  return { config: null, warning: 'provider `smtp` requires TOIL_EMAIL_SMTP_HOST' };
103
107
  }
108
+ // Gmail always needs an app password; plain SMTP may be auth-less (the send
109
+ // path omits `auth` when the key is empty). See providers.ts sendSmtp.
110
+ if (isGmail && apiKey === undefined) {
111
+ return {
112
+ config: null,
113
+ warning: 'provider `gmail` requires TOIL_EMAIL_API_KEY (a Gmail app password)',
114
+ };
115
+ }
104
116
  const port = parseInt0(envOf(reserved, 'SMTP_PORT'), c.smtp?.port ?? 0) || 587;
105
117
  const user = envOf(reserved, 'SMTP_USER') ?? c.smtp?.user?.trim() ?? from;
106
118
  smtp = { host, port, user };
@@ -115,7 +127,7 @@ export function resolveEmailConfig(
115
127
  config: {
116
128
  provider,
117
129
  from,
118
- apiKey,
130
+ apiKey: apiKey ?? '',
119
131
  maxPerMin: parseInt0(envOf(reserved, 'MAX_PER_MIN'), c.maxPerMin ?? 60),
120
132
  maxPerDay: parseInt0(envOf(reserved, 'MAX_PER_DAY'), c.maxPerDay ?? 0),
121
133
  maxPerRecipientPerHour: parseInt0(
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * DEV EMAIL EMULATOR: renders an outgoing email as an ASCII-drawn box in the
3
3
  * terminal so confirm / reset / 2FA flows are testable locally WITHOUT a real
4
- * inbox (the whole point of dev). It converts the HTML body to readable text,
5
- * lays it out under a left-barred box, and highlights the ACTION (the one-time
6
- * link, which carries its token in the `#token=` fragment and so is directly
7
- * clickable, or the numeric 2FA code). Dev-only; the production edge never renders
8
- * or logs token material.
4
+ * inbox (the whole point of dev). It converts the HTML body to readable text WITH
5
+ * terminal color + font styling (bold / italic / underline / colored text taken
6
+ * from the HTML), lays it out under a left-barred box, and highlights the ACTION
7
+ * (the one-time link, which carries its token in the `#token=` fragment and so is
8
+ * directly clickable, or the numeric 2FA code). Dev-only; the production edge never
9
+ * renders or logs token material.
9
10
  *
10
11
  * The box uses a LEFT bar plus full top/bottom rules (no right border), so colored
11
12
  * spans never break column alignment and long URLs stay on one unbroken, clickable
12
- * line.
13
+ * line. Styling is emitted only when the terminal supports color (a piped/CI run
14
+ * gets clean plain text).
13
15
  */
14
16
  import pc from 'picocolors';
15
17
 
@@ -31,9 +33,10 @@ function decodeEntities(s: string): string {
31
33
  }
32
34
 
33
35
  /**
34
- * A plain-text rendering of an HTML email body: anchors become `text (url)` so
35
- * the URL stays visible + clickable, block elements become line breaks, remaining
36
- * tags are stripped, entities decoded, and whitespace collapsed.
36
+ * A plain-text (no color) rendering of an HTML email body: anchors become
37
+ * `text (url)`, block elements become line breaks, remaining tags are stripped,
38
+ * entities decoded, whitespace collapsed. Kept for the action-extraction fallback
39
+ * and any caller that wants text without escape codes.
37
40
  */
38
41
  export function htmlToText(html: string): string {
39
42
  let s = html;
@@ -45,13 +48,10 @@ export function htmlToText(html: string): string {
45
48
  return t.length > 0 && t !== href ? `${t} (${href})` : href;
46
49
  },
47
50
  );
48
- // block-level tags -> newlines (open OR close), <br> -> newline
49
51
  s = s.replace(/<br\s*\/?>/gi, '\n');
50
52
  s = s.replace(/<\/?(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|section|header|footer)\b[^>]*>/gi, '\n');
51
- // strip whatever tags remain
52
53
  s = s.replace(/<[^>]+>/g, '');
53
54
  s = decodeEntities(s);
54
- // collapse intra-line whitespace, trim each line, cap blank runs at one
55
55
  s = s.replace(/[ \t]+/g, ' ');
56
56
  s = s
57
57
  .split('\n')
@@ -72,8 +72,220 @@ export function emailAction(parsed: ParsedEmail): { kind: 'link' | 'code'; value
72
72
  return null;
73
73
  }
74
74
 
75
- /** Word-wrap to `width`; words longer than `width` (URLs, tokens) overflow rather
76
- * than being hard-split, so they stay intact and clickable. */
75
+ // --- HTML -> STYLED terminal spans -----------------------------------------------------------------
76
+
77
+ type Rgb = readonly [number, number, number];
78
+ interface Style {
79
+ bold?: boolean;
80
+ italic?: boolean;
81
+ underline?: boolean;
82
+ color?: Rgb;
83
+ }
84
+ interface Word {
85
+ readonly text: string;
86
+ readonly style: Style;
87
+ /** Whether whitespace separated this word from the previous one in the source
88
+ * (so `<b>bob</b>,` renders as `bob,` not `bob ,`). */
89
+ readonly spaceBefore: boolean;
90
+ }
91
+
92
+ /** A small palette of CSS color names our templates (and most emails) use. */
93
+ const NAMED: Record<string, Rgb> = {
94
+ black: [0, 0, 0], white: [235, 235, 235], red: [229, 57, 53], green: [67, 160, 71],
95
+ blue: [30, 136, 229], yellow: [253, 216, 53], orange: [251, 140, 0], purple: [142, 36, 170],
96
+ magenta: [216, 27, 96], pink: [233, 30, 99], cyan: [0, 172, 193], teal: [0, 137, 123],
97
+ indigo: [57, 73, 171], gray: [128, 128, 128], grey: [128, 128, 128], silver: [192, 192, 192],
98
+ gold: [255, 193, 7], brown: [121, 85, 72], navy: [26, 35, 126], lime: [124, 179, 66],
99
+ };
100
+
101
+ /** Parse a CSS color value (`#rgb`, `#rrggbb`, `rgb(...)`, or a named color) to RGB. */
102
+ export function parseColor(value: string): Rgb | undefined {
103
+ const s = value.trim().toLowerCase();
104
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
105
+ if (hex !== null) {
106
+ const h = hex[1];
107
+ if (h.length === 3) {
108
+ return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];
109
+ }
110
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
111
+ }
112
+ const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/.exec(s);
113
+ if (rgb !== null) {
114
+ return [Math.min(255, +rgb[1]), Math.min(255, +rgb[2]), Math.min(255, +rgb[3])];
115
+ }
116
+ return NAMED[s];
117
+ }
118
+
119
+ /** The style delta a tag (name + raw attributes) contributes. */
120
+ function styleFromTag(name: string, attrs: string): Style {
121
+ const d: Style = {};
122
+ if (name === 'b' || name === 'strong' || /^h[1-6]$/.test(name)) d.bold = true;
123
+ if (name === 'i' || name === 'em') d.italic = true;
124
+ if (name === 'u' || name === 'ins') d.underline = true;
125
+ if (name === 'a') {
126
+ d.underline = true;
127
+ d.color = NAMED.cyan; // links stand out
128
+ }
129
+ if (name === 'code' || name === 'kbd' || name === 'pre' || name === 'samp') d.color = NAMED.gray;
130
+ const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(attrs);
131
+ if (styleAttr !== null) {
132
+ const css = styleAttr[1].toLowerCase();
133
+ const colorM = /(?:^|;)\s*color\s*:\s*([^;]+)/.exec(css);
134
+ if (colorM !== null) {
135
+ const c = parseColor(colorM[1]);
136
+ if (c !== undefined) d.color = c;
137
+ }
138
+ if (/font-weight\s*:\s*(?:bold|bolder|[6-9]00)/.test(css)) d.bold = true;
139
+ if (/font-style\s*:\s*italic/.test(css)) d.italic = true;
140
+ if (/text-decoration[^;]*:\s*[^;]*underline/.test(css)) d.underline = true;
141
+ }
142
+ if (name === 'font') {
143
+ const fc = /\bcolor\s*=\s*["']([^"']+)["']/i.exec(attrs);
144
+ if (fc !== null) {
145
+ const c = parseColor(fc[1]);
146
+ if (c !== undefined) d.color = c;
147
+ }
148
+ }
149
+ return d;
150
+ }
151
+
152
+ /** Flatten a style stack (last color / any true flag wins). */
153
+ function compose(stack: readonly Style[]): Style {
154
+ const out: Style = {};
155
+ for (const s of stack) {
156
+ if (s.bold) out.bold = true;
157
+ if (s.italic) out.italic = true;
158
+ if (s.underline) out.underline = true;
159
+ if (s.color !== undefined) out.color = s.color;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ const BLOCK = new Set([
165
+ 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'table', 'ul', 'ol',
166
+ 'blockquote', 'section', 'header', 'footer', 'br', 'hr', 'article', 'main', 'nav', 'aside',
167
+ ]);
168
+
169
+ /** Parse an HTML body into paragraphs (split on block elements) of styled words,
170
+ * preserving whether each word was space-separated from the previous one. */
171
+ function htmlToParagraphs(html: string): Word[][] {
172
+ const paras: Word[][] = [];
173
+ let cur: Word[] = [];
174
+ let pendingSpace = false; // a space boundary carried across tag boundaries
175
+ const flush = (): void => {
176
+ if (cur.length > 0) {
177
+ paras.push(cur);
178
+ cur = [];
179
+ }
180
+ pendingSpace = false;
181
+ };
182
+ const stack: Style[] = [];
183
+ const hrefs: (string | null)[] = [];
184
+ const re = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>|([^<]+)/g;
185
+ let m: RegExpExecArray | null;
186
+ while ((m = re.exec(html)) !== null) {
187
+ const raw = m[4];
188
+ if (raw !== undefined) {
189
+ const norm = decodeEntities(raw).replace(/\s+/g, ' ').trim();
190
+ if (norm.length === 0) {
191
+ if (/\s/.test(raw)) pendingSpace = true;
192
+ continue;
193
+ }
194
+ const leading = /^\s/.test(raw) || pendingSpace;
195
+ const st = compose(stack);
196
+ const words = norm.split(' ');
197
+ for (let i = 0; i < words.length; i++) {
198
+ cur.push({ text: words[i], style: st, spaceBefore: cur.length > 0 && (i > 0 || leading) });
199
+ }
200
+ pendingSpace = /\s$/.test(raw);
201
+ continue;
202
+ }
203
+ const closing = m[1] === '/';
204
+ const name = m[2].toLowerCase();
205
+ const attrs = m[3] ?? '';
206
+ if (BLOCK.has(name)) {
207
+ flush();
208
+ if (/^h[1-6]$/.test(name)) {
209
+ if (closing) stack.pop();
210
+ else stack.push(styleFromTag(name, attrs));
211
+ }
212
+ continue;
213
+ }
214
+ if (closing) {
215
+ stack.pop();
216
+ if (name === 'a') {
217
+ const href = hrefs.pop();
218
+ if (href) {
219
+ cur.push({
220
+ text: `(${href})`,
221
+ style: { ...compose(stack), color: NAMED.gray },
222
+ spaceBefore: cur.length > 0,
223
+ });
224
+ }
225
+ }
226
+ } else {
227
+ stack.push(styleFromTag(name, attrs));
228
+ if (name === 'a') {
229
+ const hm = /\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs);
230
+ hrefs.push(hm !== null ? hm[1] : null);
231
+ }
232
+ }
233
+ }
234
+ flush();
235
+ return paras;
236
+ }
237
+
238
+ /** Apply a style to a string as terminal escapes (no-op when color is unsupported). */
239
+ function paint(style: Style, s: string): string {
240
+ if (!pc.isColorSupported) return s;
241
+ let open = '';
242
+ if (style.bold) open += '\x1b[1m';
243
+ if (style.italic) open += '\x1b[3m';
244
+ if (style.underline) open += '\x1b[4m';
245
+ if (style.color !== undefined) open += `\x1b[38;2;${style.color[0]};${style.color[1]};${style.color[2]}m`;
246
+ return open === '' ? s : `${open}${s}\x1b[0m`;
247
+ }
248
+
249
+ /** Word-wrap styled words to `width` VISIBLE columns; over-long words (URLs) overflow.
250
+ * Honors each word's `spaceBefore` so attached punctuation is not pushed off. */
251
+ function wrapWords(words: readonly Word[], width: number): Word[][] {
252
+ const lines: Word[][] = [];
253
+ let line: Word[] = [];
254
+ let len = 0;
255
+ for (const w of words) {
256
+ const sep = line.length > 0 && w.spaceBefore ? 1 : 0;
257
+ if (line.length > 0 && len + sep + w.text.length > width) {
258
+ lines.push(line);
259
+ line = [w];
260
+ len = w.text.length;
261
+ } else {
262
+ line.push(w);
263
+ len += sep + w.text.length;
264
+ }
265
+ }
266
+ if (line.length > 0) lines.push(line);
267
+ return lines;
268
+ }
269
+
270
+ /**
271
+ * Render an HTML body to styled terminal lines (visible width <= `width`). Empty
272
+ * strings mark blank separator lines between paragraphs. Exported for tests.
273
+ */
274
+ export function renderHtmlBody(html: string, width: number = WIDTH): string[] {
275
+ const paras = htmlToParagraphs(html);
276
+ const out: string[] = [];
277
+ for (let p = 0; p < paras.length; p++) {
278
+ if (p > 0) out.push(''); // one blank line between paragraphs
279
+ for (const line of wrapWords(paras[p], width)) {
280
+ out.push(
281
+ line.map((w, i) => (i > 0 && w.spaceBefore ? ' ' : '') + paint(w.style, w.text)).join(''),
282
+ );
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** Word-wrap PLAIN text (the fallback when there is no HTML body). */
77
289
  function wrap(text: string, width: number): string[] {
78
290
  const out: string[] = [];
79
291
  for (const para of text.split('\n')) {
@@ -97,7 +309,8 @@ function wrap(text: string, width: number): string[] {
97
309
 
98
310
  /**
99
311
  * The full ASCII box for one dev email. `status` is a short label for the header
100
- * (e.g. `not sent - no provider`, `sent`, `deduped`).
312
+ * (e.g. `not sent, no provider`, `sent`, `deduped`). The body is rendered from the
313
+ * HTML with color + font styling when present, else from the plain-text body.
101
314
  */
102
315
  export function renderEmailConsole(parsed: ParsedEmail, status: string): string {
103
316
  const bar = pc.dim(' │ ');
@@ -116,8 +329,9 @@ export function renderEmailConsole(parsed: ParsedEmail, status: string): string
116
329
  lines.push(bar + pc.dim('Subject: ') + parsed.subject);
117
330
  lines.push(pc.dim(' │'));
118
331
 
119
- const bodyText = parsed.body.length > 0 ? parsed.body : htmlToText(parsed.html);
120
- for (const l of wrap(bodyText, WIDTH)) lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
332
+ const bodyLines =
333
+ parsed.html.length > 0 ? renderHtmlBody(parsed.html, WIDTH) : wrap(parsed.body, WIDTH);
334
+ for (const l of bodyLines) lines.push(l.length === 0 ? pc.dim(' │') : bar + l);
121
335
 
122
336
  const action = emailAction(parsed);
123
337
  if (action !== null) {
@@ -91,7 +91,8 @@ export async function sendSmtp(
91
91
  host: smtp.host,
92
92
  port: smtp.port,
93
93
  secure: smtp.port === 465, // 465 = implicit TLS; else STARTTLS
94
- auth: { user: smtp.user, pass: cfg.apiKey },
94
+ // Auth only when there is a credential; a local/auth-less relay takes none.
95
+ auth: cfg.apiKey.length > 0 ? { user: smtp.user, pass: cfg.apiKey } : undefined,
95
96
  connectionTimeout: POST_TIMEOUT_MS,
96
97
  greetingTimeout: POST_TIMEOUT_MS,
97
98
  });
@@ -228,6 +228,11 @@ function devEmailSend(ref: MemoryRef, reqPtr: number, reqLen: number): number {
228
228
  const { status, parsed } = svc.prepare(raw);
229
229
  if (parsed === null) {
230
230
  process.stdout.write(` ✉ dev email_send -> ${EmailStatus[status]}\n`);
231
+ // The real send was skipped (bad recipient / deduped / budget / cap), but in
232
+ // DEV still draw the email so its link/code stays testable. Not recorded to
233
+ // the test seam (that tracks accepted sends only).
234
+ const skipped = parseEmailBlob(raw);
235
+ if (skipped !== null) previewDevEmail(skipped, EmailStatus[status]);
231
236
  return status;
232
237
  }
233
238
  recordSentEmail(parsed); // dev test seam
@@ -154,6 +154,24 @@ describe('config resolution', () => {
154
154
  resolveEmailConfig({ from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_PROVIDER: 'mailgun' }))
155
155
  .warning,
156
156
  ).toMatch(/unknown/);
157
+ // gmail still needs an app password
158
+ expect(
159
+ resolveEmailConfig({ provider: 'gmail', from: 'me@gmail.com' }, reserved({})).warning,
160
+ ).toMatch(/gmail/);
161
+ });
162
+
163
+ it('plain SMTP needs NO api key (a local / auth-less relay)', () => {
164
+ const { config, warning } = resolveEmailConfig(
165
+ { provider: 'smtp', from: 'dev@dacely.local' },
166
+ reserved({ TOIL_EMAIL_SMTP_HOST: '127.0.0.1' }),
167
+ );
168
+ expect(warning).toBeNull();
169
+ expect(config).toMatchObject({
170
+ provider: 'smtp',
171
+ from: 'dev@dacely.local',
172
+ apiKey: '', // no credential -> the send path omits SMTP auth
173
+ smtp: { host: '127.0.0.1', port: 587 },
174
+ });
157
175
  });
158
176
 
159
177
  it('TOIL_EMAIL_ENABLED=false disables silently', () => {