@groundmail/groundhog 0.0.0-stage → 0.1.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +320 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +130 -0
  5. package/dist/client.d.ts +55 -0
  6. package/dist/client.js +90 -0
  7. package/dist/extract.d.ts +22 -0
  8. package/dist/extract.js +116 -0
  9. package/dist/faults.d.ts +24 -0
  10. package/dist/faults.js +64 -0
  11. package/dist/groundmail-account.d.ts +50 -0
  12. package/dist/groundmail-account.js +233 -0
  13. package/dist/http.d.ts +58 -0
  14. package/dist/http.js +152 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +2 -0
  17. package/dist/mcp.d.ts +11 -0
  18. package/dist/mcp.js +156 -0
  19. package/dist/mime.d.ts +52 -0
  20. package/dist/mime.js +182 -0
  21. package/dist/providers/common.d.ts +14 -0
  22. package/dist/providers/common.js +68 -0
  23. package/dist/providers/groundmail.d.ts +31 -0
  24. package/dist/providers/groundmail.js +169 -0
  25. package/dist/providers/index.d.ts +12 -0
  26. package/dist/providers/index.js +10 -0
  27. package/dist/providers/mailgun.d.ts +3 -0
  28. package/dist/providers/mailgun.js +150 -0
  29. package/dist/providers/postmark.d.ts +3 -0
  30. package/dist/providers/postmark.js +147 -0
  31. package/dist/providers/resend.d.ts +8 -0
  32. package/dist/providers/resend.js +121 -0
  33. package/dist/providers/sendgrid.d.ts +3 -0
  34. package/dist/providers/sendgrid.js +128 -0
  35. package/dist/providers/ses.d.ts +3 -0
  36. package/dist/providers/ses.js +244 -0
  37. package/dist/providers/types.d.ts +35 -0
  38. package/dist/providers/types.js +1 -0
  39. package/dist/server.d.ts +38 -0
  40. package/dist/server.js +450 -0
  41. package/dist/signing.d.ts +25 -0
  42. package/dist/signing.js +47 -0
  43. package/dist/smtp.d.ts +13 -0
  44. package/dist/smtp.js +70 -0
  45. package/dist/store.d.ts +35 -0
  46. package/dist/store.js +171 -0
  47. package/dist/types.d.ts +48 -0
  48. package/dist/types.js +12 -0
  49. package/dist/ui/app.css +214 -0
  50. package/dist/ui/app.js +621 -0
  51. package/dist/ui/favicon.svg +1 -0
  52. package/dist/ui/index.html +108 -0
  53. package/dist/webhooks.d.ts +72 -0
  54. package/dist/webhooks.js +102 -0
  55. package/package.json +88 -4
package/dist/server.js ADDED
@@ -0,0 +1,450 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { extname, join, normalize } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { FAULT_LABELS, Faults, parseFaultKind } from './faults.js';
6
+ import { GroundmailAccount, withUtm } from './groundmail-account.js';
7
+ import { HttpError, RequestContext, Router } from './http.js';
8
+ import { ingestApiSend } from './mime.js';
9
+ import { HTTP_PROVIDERS, PROVIDER_MODULES, WEBHOOK_FORMATS } from './providers/index.js';
10
+ import { createSmtpServer, SMTP_FAULT_REPLIES } from './smtp.js';
11
+ import { EmailStore, publicEmail } from './store.js';
12
+ import { PROVIDER_LABELS, WEBHOOK_EVENTS } from './types.js';
13
+ import { formatFor, verificationKeys, Webhooks } from './webhooks.js';
14
+ export const VERSION = (() => {
15
+ try {
16
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
17
+ return pkg.version;
18
+ }
19
+ catch {
20
+ return '0.0.0';
21
+ }
22
+ })();
23
+ const UI_DIR = fileURLToPath(new URL('./ui/', import.meta.url));
24
+ const MIME = {
25
+ '.html': 'text/html; charset=utf-8',
26
+ '.js': 'text/javascript; charset=utf-8',
27
+ '.css': 'text/css; charset=utf-8',
28
+ '.svg': 'image/svg+xml',
29
+ '.png': 'image/png',
30
+ '.ico': 'image/x-icon',
31
+ '.json': 'application/json',
32
+ };
33
+ function summary(email) {
34
+ const { html: _html, text, headers: _headers, ...rest } = email;
35
+ return { ...rest, preview: (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 160) };
36
+ }
37
+ function filterFrom(ctx) {
38
+ return {
39
+ to: ctx.query('to'),
40
+ from: ctx.query('from'),
41
+ subject: ctx.query('subject'),
42
+ q: ctx.query('q') ?? ctx.query('query'),
43
+ via: ctx.query('via'),
44
+ since: ctx.query('since'),
45
+ };
46
+ }
47
+ /** Point cid: images at the attachment URL so inline images render. */
48
+ function htmlForFrame(email, scheme) {
49
+ let html = email.html ?? `<pre style="white-space:pre-wrap;font:14px/1.5 ui-monospace,Menlo,monospace">${(email.text ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c])}</pre>`;
50
+ for (const attachment of email.attachments) {
51
+ if (!attachment.contentId)
52
+ continue;
53
+ const url = `/api/emails/${email.id}/attachments/${attachment.id}`;
54
+ html = html.split(`cid:${attachment.contentId}`).join(url);
55
+ }
56
+ const base = '<base target="_blank">';
57
+ const darkStyle = scheme === 'dark' ? '<meta name="color-scheme" content="dark"><style>:root{color-scheme:dark}</style>' : '<meta name="color-scheme" content="light">';
58
+ if (/<head[^>]*>/i.test(html))
59
+ return html.replace(/<head([^>]*)>/i, `<head$1>${base}${darkStyle}`);
60
+ if (/<html[^>]*>/i.test(html))
61
+ return html.replace(/<html([^>]*)>/i, `<html$1><head><meta charset="utf-8">${base}${darkStyle}</head>`);
62
+ return `<!doctype html><html><head><meta charset="utf-8">${base}${darkStyle}</head><body>${html}</body></html>`;
63
+ }
64
+ async function sleep(ms) {
65
+ await new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+ const SAMPLE_CODE = () => String(Math.floor(100000 + Math.random() * 900000));
68
+ export async function startGroundhog(options = {}) {
69
+ const log = options.log ?? (() => { });
70
+ const store = new EmailStore({ max: options.maxEmails, file: options.storeFile });
71
+ await store.load();
72
+ const faults = new Faults(options.faultTimeoutMs ?? 30_000);
73
+ let baseUrl = 'http://localhost:3025';
74
+ const webhooks = new Webhooks(options.webhooks ?? {}, () => baseUrl);
75
+ const account = new GroundmailAccount({ apiUrl: options.groundmailApiUrl, openBrowser: options.openBrowser });
76
+ const sse = new Set();
77
+ const broadcast = (event, data) => {
78
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
79
+ for (const client of sse)
80
+ client.res.write(payload);
81
+ };
82
+ const ingest = (email) => {
83
+ store.add(email);
84
+ log(`caught ${email.id} via ${email.via}: "${email.subject}" → ${email.to.map((a) => a.address).join(', ') || '(no to)'}`);
85
+ if (webhooks.settings.autoDelivered && webhooks.settings.url) {
86
+ void webhooks.fire(email, 'delivered').catch(() => { });
87
+ }
88
+ return email;
89
+ };
90
+ store.on('email', (email) => broadcast('email', summary(publicEmail(email))));
91
+ store.on('delete', (id) => broadcast('delete', { id }));
92
+ store.on('clear', () => broadcast('clear', {}));
93
+ faults.on('change', (state) => broadcast('faults', state));
94
+ webhooks.on('delivery', (entry) => broadcast('webhook', entry));
95
+ webhooks.on('settings', (settings) => broadcast('webhook-settings', settings));
96
+ account.on('change', (status) => broadcast('account', status));
97
+ const hub = {
98
+ ingest,
99
+ get: (id) => store.get(id),
100
+ async guard(ctx, provider) {
101
+ const kind = faults.take();
102
+ if (!kind)
103
+ return false;
104
+ log(`simulated ${FAULT_LABELS[kind].label.toLowerCase()} on ${PROVIDER_LABELS[provider.id]} ${ctx.method} ${ctx.url.pathname}`);
105
+ broadcast('fault-fired', { kind, provider: provider.id, path: ctx.url.pathname });
106
+ if (kind === 'timeout') {
107
+ await sleep(faults.get().timeoutMs);
108
+ ctx.req.socket.destroy();
109
+ return true;
110
+ }
111
+ ctx.send(provider.fail(kind, ctx));
112
+ return true;
113
+ },
114
+ };
115
+ const router = new Router();
116
+ for (const provider of HTTP_PROVIDERS)
117
+ provider.register?.(router, hub);
118
+ // ---- Testing API ---------------------------------------------------------
119
+ router.get('/api/health', () => ({
120
+ status: 200,
121
+ body: { ok: true, name: 'groundhog', version: VERSION, smtpPort, httpPort, emails: store.count() },
122
+ }));
123
+ router.get('/api/info', () => ({
124
+ status: 200,
125
+ body: {
126
+ version: VERSION,
127
+ smtpPort,
128
+ httpPort,
129
+ url: baseUrl,
130
+ providers: Object.fromEntries(HTTP_PROVIDERS.map((p) => [p.id, { label: PROVIDER_LABELS[p.id], events: p.eventNames }])),
131
+ faultReplies: { smtp: SMTP_FAULT_REPLIES },
132
+ links: {
133
+ groundmail: withUtm('https://groundmail.dev/', 'header'),
134
+ signup: withUtm('https://app.groundmail.dev/signup', 'go_live'),
135
+ docs: withUtm('https://docs.groundmail.dev/', 'go_live'),
136
+ repo: 'https://github.com/Groundmail/groundhog',
137
+ },
138
+ },
139
+ }));
140
+ router.get('/api/emails', (ctx) => {
141
+ const limit = Math.min(Number(ctx.query('limit')) || 100, 1000);
142
+ const emails = store.list(filterFrom(ctx), limit).map(publicEmail);
143
+ const brief = ctx.query('summary') === '1' || ctx.query('summary') === 'true';
144
+ return { status: 200, body: { data: brief ? emails.map(summary) : emails, total: store.count() } };
145
+ });
146
+ router.delete('/api/emails', () => {
147
+ store.clear();
148
+ return { status: 200, body: { deleted: true } };
149
+ });
150
+ router.get('/api/emails/latest', (ctx) => {
151
+ const email = store.latest(filterFrom(ctx));
152
+ if (!email)
153
+ return { status: 404, body: { error: 'No email matches' } };
154
+ return { status: 200, body: publicEmail(email) };
155
+ });
156
+ router.post('/api/emails/sample', async () => {
157
+ const code = SAMPLE_CODE();
158
+ const email = await ingestApiSend({
159
+ from: { name: 'Acme', address: 'auth@acme.test' },
160
+ to: [{ name: 'Ada', address: 'ada@example.com' }],
161
+ subject: 'Your Acme sign-in code',
162
+ html: `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,Segoe UI,Roboto,sans-serif"><div style="max-width:480px;margin:0 auto;background:#fff;border-radius:10px;padding:32px;color:#111827"><div style="font-size:13px;font-weight:700;letter-spacing:.4px;color:#6b7280">ACME</div><h1 style="font-size:22px;margin:12px 0">Your sign-in code</h1><p style="color:#374151;line-height:1.5">Enter this code to finish signing in. It expires in 10 minutes.</p><div style="font:700 30px ui-monospace,Menlo,monospace;letter-spacing:8px;text-align:center;padding:14px;background:#f3f4f6;border-radius:8px">${code}</div><p style="margin-top:20px"><a href="http://localhost:3000/login?code=${code}" style="background:#4f46e5;color:#fff;padding:12px 20px;border-radius:6px;text-decoration:none;display:inline-block">Sign in</a></p><p style="font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:14px">A sample email from Groundhog. Nothing was sent anywhere.</p></div></body></html>`,
163
+ text: `Your sign-in code\n\nEnter this code to finish signing in. It expires in 10 minutes.\n\n${code}\n\nSign in: http://localhost:3000/login?code=${code}`,
164
+ }, { via: 'smtp', tags: ['sample'] });
165
+ ingest(email);
166
+ return { status: 201, body: publicEmail(email) };
167
+ });
168
+ router.get('/api/emails/:id', (ctx) => {
169
+ const email = store.get(ctx.params.id);
170
+ if (!email)
171
+ throw new HttpError(404, 'Email not found');
172
+ return { status: 200, body: publicEmail(email) };
173
+ });
174
+ router.delete('/api/emails/:id', (ctx) => {
175
+ if (!store.delete(ctx.params.id))
176
+ throw new HttpError(404, 'Email not found');
177
+ return { status: 200, body: { deleted: true } };
178
+ });
179
+ router.get('/api/emails/:id/source', (ctx) => {
180
+ const email = store.get(ctx.params.id);
181
+ if (!email)
182
+ throw new HttpError(404, 'Email not found');
183
+ const download = ctx.query('download') != null;
184
+ return {
185
+ status: 200,
186
+ headers: {
187
+ 'Content-Type': download ? 'message/rfc822' : 'text/plain; charset=utf-8',
188
+ ...(download ? { 'Content-Disposition': `attachment; filename="${email.id}.eml"` } : {}),
189
+ },
190
+ body: email.raw,
191
+ };
192
+ });
193
+ router.get('/api/emails/:id/html', (ctx) => {
194
+ const email = store.get(ctx.params.id);
195
+ if (!email)
196
+ throw new HttpError(404, 'Email not found');
197
+ return {
198
+ status: 200,
199
+ headers: {
200
+ 'Content-Type': 'text/html; charset=utf-8',
201
+ 'Content-Security-Policy': "sandbox allow-popups allow-popups-to-escape-sandbox; default-src 'none'; img-src * data: blob:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:",
202
+ 'X-Content-Type-Options': 'nosniff',
203
+ },
204
+ body: htmlForFrame(email, ctx.query('scheme')),
205
+ };
206
+ });
207
+ router.get('/api/emails/:id/attachments/:aid', (ctx) => {
208
+ const email = store.get(ctx.params.id);
209
+ const info = email?.attachments.find((a) => a.id === ctx.params.aid);
210
+ const content = info && email?.attachmentContent.get(info.id);
211
+ if (!email || !info || !content)
212
+ throw new HttpError(404, 'Attachment not found');
213
+ const inline = ctx.query('download') == null && /^image\//.test(info.contentType);
214
+ return {
215
+ status: 200,
216
+ headers: {
217
+ 'Content-Type': info.contentType,
218
+ 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${info.filename.replace(/"/g, '')}"`,
219
+ 'X-Content-Type-Options': 'nosniff',
220
+ 'Content-Security-Policy': "sandbox; default-src 'none'",
221
+ },
222
+ body: content,
223
+ };
224
+ });
225
+ router.get('/api/wait', async (ctx) => {
226
+ const timeout = Math.min(Math.max(Number(ctx.query('timeout')) || 30_000, 0), 300_000);
227
+ const controller = new AbortController();
228
+ ctx.req.on('close', () => controller.abort());
229
+ const email = await store.waitFor(filterFrom(ctx), timeout, controller.signal);
230
+ if (!email)
231
+ return { status: 408, body: { error: `No matching email within ${timeout} ms` } };
232
+ return { status: 200, body: publicEmail(email) };
233
+ });
234
+ // ---- Failure simulation --------------------------------------------------
235
+ router.get('/api/faults', () => ({ status: 200, body: faults.get() }));
236
+ router.post('/api/faults', async (ctx) => {
237
+ const body = await ctx.json();
238
+ const kind = parseFaultKind(body.kind ?? body.status ?? ctx.query('status') ?? ctx.query('kind'));
239
+ if (!kind)
240
+ throw new HttpError(400, 'Say which failure: status 429, 422, 401, 5xx or timeout (or kind rate_limit, invalid_recipient, unauthorized, outage, timeout)');
241
+ const next = Number(body.next ?? ctx.query('next') ?? 1);
242
+ if (!Number.isFinite(next) || next < 0)
243
+ throw new HttpError(400, '`next` must be 0 or more');
244
+ const timeoutMs = body.timeoutMs ?? body.timeout_ms;
245
+ return { status: 200, body: faults.set(kind, next, timeoutMs == null ? undefined : Number(timeoutMs)) };
246
+ });
247
+ router.delete('/api/faults', () => ({ status: 200, body: faults.clear() }));
248
+ // ---- Webhook simulation --------------------------------------------------
249
+ const webhookSettingsView = () => ({ ...webhooks.settings, formats: WEBHOOK_FORMATS, events: WEBHOOK_EVENTS, verification: verificationKeys(webhooks.settings) });
250
+ router.get('/api/webhooks', () => ({ status: 200, body: webhookSettingsView() }));
251
+ router.put('/api/webhooks', async (ctx) => {
252
+ try {
253
+ webhooks.update(await ctx.json());
254
+ }
255
+ catch (error) {
256
+ throw new HttpError(400, error.message);
257
+ }
258
+ return { status: 200, body: webhookSettingsView() };
259
+ });
260
+ router.get('/api/webhooks/deliveries', (ctx) => ({ status: 200, body: { data: webhooks.deliveries(ctx.query('email')) } }));
261
+ router.delete('/api/webhooks/deliveries', () => {
262
+ webhooks.clearLog();
263
+ return { status: 200, body: { deleted: true } };
264
+ });
265
+ router.post('/api/emails/:id/webhooks', async (ctx) => {
266
+ const email = store.get(ctx.params.id);
267
+ if (!email)
268
+ throw new HttpError(404, 'Email not found');
269
+ const body = await ctx.json();
270
+ const event = (body.event ?? ctx.query('event') ?? '').replace(/^email\./, '');
271
+ const aliases = { bounce: 'bounced', failed: 'bounced', spamreport: 'complained', complaint: 'complained', open: 'opened', click: 'clicked', delivery: 'delivered' };
272
+ const resolved = WEBHOOK_EVENTS.includes(event) ? event : aliases[event.toLowerCase()];
273
+ if (!resolved)
274
+ throw new HttpError(400, `event must be one of ${WEBHOOK_EVENTS.join(', ')}`);
275
+ try {
276
+ const entry = await webhooks.fire(publicEmail(email), resolved, { url: body.url, format: body.format, link: body.link });
277
+ return { status: 200, body: entry };
278
+ }
279
+ catch (error) {
280
+ throw new HttpError(400, error.message);
281
+ }
282
+ });
283
+ router.get('/api/emails/:id/webhooks/format', (ctx) => {
284
+ const email = store.get(ctx.params.id);
285
+ if (!email)
286
+ throw new HttpError(404, 'Email not found');
287
+ const format = formatFor(email, webhooks.settings.format);
288
+ return { status: 200, body: { format, events: PROVIDER_MODULES[format].eventNames } };
289
+ });
290
+ // ---- Groundmail: send to my real inbox ----------------------------------
291
+ router.get('/api/groundmail', () => ({ status: 200, body: account.status() }));
292
+ router.post('/api/groundmail/connect', async () => {
293
+ try {
294
+ return { status: 200, body: await account.connect() };
295
+ }
296
+ catch (error) {
297
+ throw new HttpError(502, error.message);
298
+ }
299
+ });
300
+ router.post('/api/groundmail/disconnect', async () => ({ status: 200, body: await account.disconnect() }));
301
+ router.post('/api/emails/:id/send-to-inbox', async (ctx) => {
302
+ const email = store.get(ctx.params.id);
303
+ if (!email)
304
+ throw new HttpError(404, 'Email not found');
305
+ const body = await ctx.json();
306
+ try {
307
+ return { status: 200, body: await account.sendCopy(email, body.to) };
308
+ }
309
+ catch (error) {
310
+ throw new HttpError(400, error.message);
311
+ }
312
+ });
313
+ // ---- Live updates --------------------------------------------------------
314
+ router.get('/api/events', (ctx) => {
315
+ ctx.res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
316
+ ctx.res.write(`event: hello\ndata: ${JSON.stringify({ version: VERSION, faults: faults.get(), account: account.status() })}\n\n`);
317
+ sse.add(ctx);
318
+ const ping = setInterval(() => ctx.res.write(': ping\n\n'), 20_000);
319
+ ctx.req.on('close', () => {
320
+ clearInterval(ping);
321
+ sse.delete(ctx);
322
+ });
323
+ });
324
+ // ---- Web UI --------------------------------------------------------------
325
+ const serveFile = (relative) => {
326
+ const path = normalize(join(UI_DIR, relative));
327
+ if (!path.startsWith(UI_DIR))
328
+ throw new HttpError(404, 'Not found');
329
+ try {
330
+ const content = readFileSync(path);
331
+ return { status: 200, headers: { 'Content-Type': MIME[extname(path)] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' }, body: content };
332
+ }
333
+ catch {
334
+ throw new HttpError(404, 'Not found');
335
+ }
336
+ };
337
+ router.get('/', () => serveFile('index.html'));
338
+ router.get('/ui/*', (ctx) => serveFile(ctx.params.rest));
339
+ router.get('/favicon.ico', () => serveFile('favicon.svg'));
340
+ const handle = async (req, res) => {
341
+ const ctx = new RequestContext(req, res);
342
+ res.setHeader('Access-Control-Allow-Origin', '*');
343
+ res.setHeader('Access-Control-Allow-Headers', '*');
344
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
345
+ res.setHeader('Access-Control-Expose-Headers', '*');
346
+ if (ctx.method === 'OPTIONS')
347
+ return ctx.send({ status: 204 });
348
+ try {
349
+ const match = router.match(ctx.method, ctx.url.pathname);
350
+ if (match === 'method')
351
+ throw new HttpError(405, `${ctx.method} is not supported on ${ctx.url.pathname}`);
352
+ if (!match) {
353
+ throw new HttpError(404, `Groundhog has no route for ${ctx.method} ${ctx.url.pathname}. Provider APIs live under /groundmail, /sendgrid, /postmark, /mailgun, /resend and /ses; the testing API under /api.`);
354
+ }
355
+ ctx.params = match.params;
356
+ const result = await match.route.handler(ctx);
357
+ if (result)
358
+ ctx.send(result);
359
+ }
360
+ catch (error) {
361
+ if (error instanceof HttpError)
362
+ return ctx.send({ status: error.status, body: { error: error.message } });
363
+ log(`error on ${ctx.method} ${ctx.url.pathname}: ${error.stack ?? error}`);
364
+ ctx.send({ status: 500, body: { error: error.message } });
365
+ }
366
+ };
367
+ // ---- Listen --------------------------------------------------------------
368
+ const host = options.host ?? 'localhost';
369
+ const hosts = host === 'localhost' ? ['127.0.0.1', '::1'] : [host];
370
+ const httpServers = [];
371
+ const smtpServers = [];
372
+ const listenHttp = (port, address, optional) => new Promise((resolve, reject) => {
373
+ const server = createServer((req, res) => void handle(req, res));
374
+ server.keepAliveTimeout = 5000;
375
+ server.once('error', (error) => {
376
+ if (optional && (error.code === 'EADDRNOTAVAIL' || error.code === 'EAFNOSUPPORT'))
377
+ return resolve(null);
378
+ reject(error);
379
+ });
380
+ server.listen(port, address, () => {
381
+ httpServers.push(server);
382
+ resolve(server.address().port);
383
+ });
384
+ });
385
+ const listenSmtp = (port, address, optional) => new Promise((resolve, reject) => {
386
+ const server = createSmtpServer({ faults, ingest, onError: (error) => log(`smtp error: ${error.message}`) });
387
+ const net = server.server;
388
+ net.once('error', (error) => {
389
+ if (optional && (error.code === 'EADDRNOTAVAIL' || error.code === 'EAFNOSUPPORT'))
390
+ return resolve(null);
391
+ reject(error);
392
+ });
393
+ server.listen(port, address, () => {
394
+ smtpServers.push(server);
395
+ resolve(net.address().port);
396
+ });
397
+ });
398
+ let httpPort = options.httpPort ?? 3025;
399
+ let smtpPort = options.smtpPort ?? 1025;
400
+ try {
401
+ for (const [index, address] of hosts.entries()) {
402
+ const port = await listenHttp(httpPort, address, index > 0);
403
+ if (port != null)
404
+ httpPort = port;
405
+ }
406
+ if (options.smtp !== false) {
407
+ for (const [index, address] of hosts.entries()) {
408
+ const port = await listenSmtp(smtpPort, address, index > 0);
409
+ if (port != null)
410
+ smtpPort = port;
411
+ }
412
+ }
413
+ }
414
+ catch (error) {
415
+ for (const server of httpServers)
416
+ server.close();
417
+ for (const server of smtpServers)
418
+ server.close(() => { });
419
+ const e = error;
420
+ if (e.code === 'EADDRINUSE') {
421
+ throw new Error(`Port ${e.port ?? ''} is already in use. Is Groundhog (or another local inbox) already running? Pick other ports with --http-port and --smtp-port.`);
422
+ }
423
+ throw error;
424
+ }
425
+ const displayHost = host === 'localhost' || host === '0.0.0.0' || host === '::' ? 'localhost' : host.includes(':') ? `[${host}]` : host;
426
+ baseUrl = `http://${displayHost}:${httpPort}`;
427
+ return {
428
+ url: baseUrl,
429
+ smtpPort,
430
+ httpPort,
431
+ host,
432
+ store,
433
+ faults,
434
+ webhooks,
435
+ account,
436
+ async close() {
437
+ for (const client of sse)
438
+ client.res.end();
439
+ sse.clear();
440
+ store.saveNow();
441
+ await Promise.all([
442
+ ...httpServers.map((server) => new Promise((resolve) => {
443
+ server.close(() => resolve());
444
+ server.closeAllConnections?.();
445
+ })),
446
+ ...smtpServers.map((server) => new Promise((resolve) => server.close(() => resolve()))),
447
+ ]);
448
+ },
449
+ };
450
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Local signing keys for simulated webhooks. They are fixed, published dev keys so
3
+ * an app's verification code can be configured once and keep working across restarts.
4
+ * Never use them for anything but local development.
5
+ */
6
+ import { type KeyObject } from 'node:crypto';
7
+ export type WebhookSecrets = {
8
+ /** Groundmail: `Groundmail-Signature: t=…,v1=…` (HMAC-SHA256 over `t.body`). */
9
+ groundmail: string;
10
+ /** Resend (Svix): `whsec_…`; HMAC-SHA256 over `id.timestamp.body` with the base64 part as key. */
11
+ resend: string;
12
+ /** Mailgun: HTTP webhook signing key; HMAC-SHA256 over `timestamp + token`. */
13
+ mailgun: string;
14
+ };
15
+ export declare const DEFAULT_SECRETS: WebhookSecrets;
16
+ export declare function groundmailSignature(secret: string, body: string, timestamp: number): string;
17
+ export declare function svixSignature(secret: string, id: string, timestamp: number, body: string): string;
18
+ export declare function mailgunSignature(signingKey: string, timestamp: string, token: string): string;
19
+ /** SendGrid signs its Event Webhook with ECDSA P-256; this is Groundhog's fixed local key pair. */
20
+ export declare function sendgridKeyPair(): {
21
+ privateKey: KeyObject;
22
+ publicKeyBase64: string;
23
+ };
24
+ /** `X-Twilio-Email-Event-Webhook-Signature`: base64 DER ECDSA-SHA256 over `timestamp + body`. */
25
+ export declare function sendgridSignature(timestamp: string, body: string): string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Local signing keys for simulated webhooks. They are fixed, published dev keys so
3
+ * an app's verification code can be configured once and keep working across restarts.
4
+ * Never use them for anything but local development.
5
+ */
6
+ import { createECDH, createHash, createHmac, createPrivateKey, createPublicKey, sign as cryptoSign } from 'node:crypto';
7
+ export const DEFAULT_SECRETS = {
8
+ groundmail: 'whsec_groundhog_local_dev_secret',
9
+ resend: `whsec_${Buffer.from('groundhog-local-dev-webhook-key!').toString('base64')}`,
10
+ mailgun: 'groundhog-local-mailgun-signing-key',
11
+ };
12
+ export function groundmailSignature(secret, body, timestamp) {
13
+ const v1 = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
14
+ return `t=${timestamp},v1=${v1}`;
15
+ }
16
+ export function svixSignature(secret, id, timestamp, body) {
17
+ const key = Buffer.from(secret.startsWith('whsec_') ? secret.slice(6) : secret, 'base64');
18
+ return `v1,${createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64')}`;
19
+ }
20
+ export function mailgunSignature(signingKey, timestamp, token) {
21
+ return createHmac('sha256', signingKey).update(`${timestamp}${token}`).digest('hex');
22
+ }
23
+ let sendgridKey = null;
24
+ /** SendGrid signs its Event Webhook with ECDSA P-256; this is Groundhog's fixed local key pair. */
25
+ export function sendgridKeyPair() {
26
+ if (sendgridKey)
27
+ return sendgridKey;
28
+ const d = createHash('sha256').update('groundhog-local-sendgrid-event-webhook-key').digest();
29
+ const ecdh = createECDH('prime256v1');
30
+ ecdh.setPrivateKey(d);
31
+ const pub = ecdh.getPublicKey();
32
+ const jwk = {
33
+ kty: 'EC',
34
+ crv: 'P-256',
35
+ d: d.toString('base64url'),
36
+ x: pub.subarray(1, 33).toString('base64url'),
37
+ y: pub.subarray(33, 65).toString('base64url'),
38
+ };
39
+ const privateKey = createPrivateKey({ key: jwk, format: 'jwk' });
40
+ const publicKeyBase64 = createPublicKey(privateKey).export({ type: 'spki', format: 'der' }).toString('base64');
41
+ sendgridKey = { privateKey, publicKeyBase64 };
42
+ return sendgridKey;
43
+ }
44
+ /** `X-Twilio-Email-Event-Webhook-Signature`: base64 DER ECDSA-SHA256 over `timestamp + body`. */
45
+ export function sendgridSignature(timestamp, body) {
46
+ return cryptoSign('sha256', Buffer.from(timestamp + body), sendgridKeyPair().privateKey).toString('base64');
47
+ }
package/dist/smtp.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { SMTPServer } from 'smtp-server';
2
+ import type { Faults } from './faults.js';
3
+ import type { FaultKind, StoredEmail } from './types.js';
4
+ /** The SMTP reply a real server gives for each simulated failure. */
5
+ export declare const SMTP_FAULT_REPLIES: Record<Exclude<FaultKind, 'timeout'>, {
6
+ code: number;
7
+ message: string;
8
+ }>;
9
+ export declare function createSmtpServer(options: {
10
+ faults: Faults;
11
+ ingest: (email: StoredEmail) => void;
12
+ onError?: (error: Error) => void;
13
+ }): SMTPServer;
package/dist/smtp.js ADDED
@@ -0,0 +1,70 @@
1
+ import { SMTPServer } from 'smtp-server';
2
+ import { parseEmail } from './mime.js';
3
+ function smtpError(code, message) {
4
+ const error = new Error(message);
5
+ error.responseCode = code;
6
+ return error;
7
+ }
8
+ /** The SMTP reply a real server gives for each simulated failure. */
9
+ export const SMTP_FAULT_REPLIES = {
10
+ rate_limit: { code: 450, message: '4.7.1 Rate limit exceeded, try again later' },
11
+ invalid_recipient: { code: 550, message: '5.1.1 Recipient address rejected: user unknown' },
12
+ unauthorized: { code: 535, message: '5.7.8 Authentication credentials invalid' },
13
+ outage: { code: 451, message: '4.3.0 Temporary server failure, try again later' },
14
+ };
15
+ export function createSmtpServer(options) {
16
+ const { faults, ingest } = options;
17
+ const server = new SMTPServer({
18
+ name: 'groundhog.local',
19
+ banner: 'Groundhog local inbox. Nothing is delivered.',
20
+ authOptional: true,
21
+ disabledCommands: ['STARTTLS'],
22
+ allowInsecureAuth: true,
23
+ size: 50 * 1024 * 1024,
24
+ logger: false,
25
+ onAuth(_auth, _session, callback) {
26
+ callback(null, { user: 'groundhog' });
27
+ },
28
+ onMailFrom(_address, session, callback) {
29
+ const fault = faults.take();
30
+ session.groundhogFault = fault;
31
+ if (!fault || fault === 'invalid_recipient')
32
+ return callback();
33
+ if (fault === 'timeout') {
34
+ const delay = faults.get().timeoutMs;
35
+ setTimeout(() => callback(smtpError(421, '4.4.2 Connection timed out')), delay).unref?.();
36
+ return;
37
+ }
38
+ const reply = SMTP_FAULT_REPLIES[fault];
39
+ callback(smtpError(reply.code, reply.message));
40
+ },
41
+ onRcptTo(_address, session, callback) {
42
+ if (session.groundhogFault === 'invalid_recipient') {
43
+ const reply = SMTP_FAULT_REPLIES.invalid_recipient;
44
+ return callback(smtpError(reply.code, reply.message));
45
+ }
46
+ callback();
47
+ },
48
+ onData(stream, session, callback) {
49
+ const chunks = [];
50
+ stream.on('data', (chunk) => chunks.push(chunk));
51
+ stream.on('end', async () => {
52
+ if (stream.sizeExceeded) {
53
+ return callback(smtpError(552, '5.3.4 Message too big'));
54
+ }
55
+ try {
56
+ const envelopeTo = session.envelope.rcptTo.map((rcpt) => ({ name: '', address: rcpt.address }));
57
+ const email = await parseEmail(Buffer.concat(chunks), { via: 'smtp', envelopeTo });
58
+ ingest(email);
59
+ callback(null, `2.0.0 Caught as ${email.id}. Nothing was delivered.`);
60
+ }
61
+ catch (error) {
62
+ options.onError?.(error);
63
+ callback(smtpError(451, '4.3.0 Could not read the message'));
64
+ }
65
+ });
66
+ },
67
+ });
68
+ server.on('error', (error) => options.onError?.(error));
69
+ return server;
70
+ }
@@ -0,0 +1,35 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { Email, StoredEmail } from './types.js';
3
+ export type EmailFilter = {
4
+ to?: string;
5
+ from?: string;
6
+ subject?: string;
7
+ q?: string;
8
+ via?: string;
9
+ since?: string | Date;
10
+ };
11
+ export declare function publicEmail(email: StoredEmail): Email;
12
+ export declare function matches(email: Email, filter: EmailFilter): boolean;
13
+ /** In-memory inbox, newest first. Optionally saved to a JSON file. */
14
+ export declare class EmailStore extends EventEmitter {
15
+ private readonly options;
16
+ private emails;
17
+ private saveTimer;
18
+ constructor(options?: {
19
+ max?: number;
20
+ file?: string;
21
+ });
22
+ get max(): number;
23
+ load(): Promise<void>;
24
+ private scheduleSave;
25
+ saveNow(): void;
26
+ add(email: StoredEmail): StoredEmail;
27
+ list(filter?: EmailFilter, limit?: number): StoredEmail[];
28
+ count(): number;
29
+ get(id: string): StoredEmail | undefined;
30
+ latest(filter?: EmailFilter): StoredEmail | undefined;
31
+ delete(id: string): boolean;
32
+ clear(): void;
33
+ /** Resolve with the first email (already caught after `since`, or arriving later) that matches. */
34
+ waitFor(filter: EmailFilter, timeoutMs: number, signal?: AbortSignal): Promise<StoredEmail | null>;
35
+ }