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.
- package/CHANGELOG.md +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +6 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/email/config.js +13 -7
- package/build/devserver/email/console.d.ts +4 -0
- package/build/devserver/email/console.js +199 -2
- package/build/devserver/email/providers.js +1 -1
- package/build/devserver/runtime/host.js +3 -0
- package/package.json +1 -1
- package/src/cli/index.ts +13 -0
- package/src/devserver/email/config.ts +20 -8
- package/src/devserver/email/console.ts +231 -17
- package/src/devserver/email/providers.ts +2 -1
- package/src/devserver/runtime/host.ts +5 -0
- package/test/devserver-email.test.ts +18 -0
- package/test/email-console.test.ts +31 -1
|
@@ -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
|
|
81
|
-
for (const l of
|
|
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
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* or
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
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', () => {
|