@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Groundmail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,321 @@
1
- # Temporary Holding Version
1
+ <p align="center"><img src="docs/groundhog.svg" width="360" alt="The Groundhog mascot peeking out of its burrow holding an envelope"></p>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <h1 align="center">Groundhog</h1>
4
+
5
+ <p align="center">Catch every email your app sends while you build. Nothing leaves your laptop.<br>
6
+ An open-source local inbox from <a href="https://groundmail.dev/?utm_source=groundhog&utm_medium=readme&utm_campaign=header">Groundmail</a>.</p>
7
+
8
+ ---
9
+
10
+ Groundhog is a local email inbox for development and tests. Point your app at it and every
11
+ email it sends shows up at `http://localhost:3025`: sign-up codes, password resets, receipts.
12
+
13
+ - **SMTP** on port 1025, and the **HTTP send APIs** of Groundmail, SendGrid, Postmark, Mailgun,
14
+ Resend and Amazon SES. Each provider's official SDK works unmodified: you change its base URL,
15
+ not your code.
16
+ - **A web inbox** with live updates, HTML/text/source/headers, attachments, desktop and mobile
17
+ widths, light and dark preview, and the one-time code and links pulled out for you.
18
+ - **Failure mode**: make the next sends fail with a 429, 422, 401, outage or timeout, in each
19
+ provider's own error format (SMTP answers with the matching reply code).
20
+ - **Webhook simulation**: fire delivered, bounce, complaint, open and click events at your app in
21
+ the payload and signature format of the provider the email came through.
22
+ - **Send to my real inbox**: see a caught email in real Gmail or Outlook, through a free Groundmail account.
23
+ - **For tests and agents**: `waitForEmail()` for Playwright/Vitest, and an MCP server so coding
24
+ agents can read the verification email themselves.
25
+
26
+ Coming from MailHog? Groundhog keeps SMTP on 1025; the inbox moves from 8025 to 3025.
27
+
28
+ ## Start it
29
+
30
+ ```bash
31
+ npx @groundmail/groundhog
32
+ ```
33
+
34
+ Node 20 or newer. It opens the inbox in your browser. Or with Docker:
35
+
36
+ ```bash
37
+ docker run --rm -p 1025:1025 -p 3025:3025 ghcr.io/groundmail/groundhog
38
+ ```
39
+
40
+ ```yaml
41
+ # docker-compose.yml, next to your app
42
+ services:
43
+ groundhog:
44
+ image: ghcr.io/groundmail/groundhog
45
+ ports: ["1025:1025", "3025:3025"]
46
+ ```
47
+
48
+ In Compose, other services reach it at `groundhog:1025` (SMTP) and `http://groundhog:3025` (APIs).
49
+ Images are multi-arch (amd64, arm64).
50
+
51
+ ## Point your app at it
52
+
53
+ Pick the way your app already sends. Any API key, username or password is accepted.
54
+
55
+ ### SMTP (Nodemailer, Laravel, Rails, Django, Phoenix…)
56
+
57
+ ```bash
58
+ SMTP_HOST=localhost
59
+ SMTP_PORT=1025
60
+ ```
61
+
62
+ ```js
63
+ // Nodemailer
64
+ nodemailer.createTransport({ host: 'localhost', port: 1025 })
65
+ ```
66
+
67
+ ```ini
68
+ # Laravel .env
69
+ MAIL_MAILER=smtp
70
+ MAIL_HOST=127.0.0.1
71
+ MAIL_PORT=1025
72
+ MAIL_ENCRYPTION=null
73
+ ```
74
+
75
+ ```ruby
76
+ # Rails: config/environments/development.rb
77
+ config.action_mailer.delivery_method = :smtp
78
+ config.action_mailer.smtp_settings = { address: "localhost", port: 1025 }
79
+ ```
80
+
81
+ ```python
82
+ # Django settings.py
83
+ EMAIL_HOST = "localhost"
84
+ EMAIL_PORT = 1025
85
+ ```
86
+
87
+ ### Groundmail
88
+
89
+ ```js
90
+ import { Groundmail } from 'groundmail'
91
+
92
+ const gm = new Groundmail(process.env.GROUNDMAIL_API_KEY ?? 'gm_test_anything', {
93
+ baseUrl: process.env.GROUNDMAIL_BASE_URL ?? 'http://localhost:3025',
94
+ })
95
+ ```
96
+
97
+ ### SendGrid
98
+
99
+ ```js
100
+ import sgMail from '@sendgrid/mail'
101
+
102
+ sgMail.setApiKey('SG.anything')
103
+ sgMail.client.setDefaultRequest('baseUrl', 'http://localhost:3025/sendgrid')
104
+ ```
105
+
106
+ ### Postmark
107
+
108
+ ```js
109
+ import { ServerClient } from 'postmark'
110
+
111
+ const client = new ServerClient('anything', { requestHost: 'localhost:3025/postmark', useHttps: false })
112
+ ```
113
+
114
+ ### Mailgun
115
+
116
+ ```js
117
+ import Mailgun from 'mailgun.js'
118
+ import FormData from 'form-data'
119
+
120
+ const mg = new Mailgun(FormData).client({ username: 'api', key: 'anything', url: 'http://localhost:3025/mailgun' })
121
+ ```
122
+
123
+ ### Resend
124
+
125
+ ```bash
126
+ RESEND_BASE_URL=http://localhost:3025/resend
127
+ ```
128
+
129
+ ### Amazon SES
130
+
131
+ ```js
132
+ import { SESv2Client } from '@aws-sdk/client-sesv2' // the v1 SESClient works too
133
+
134
+ const ses = new SESv2Client({
135
+ endpoint: 'http://localhost:3025/ses',
136
+ region: 'us-east-1',
137
+ credentials: { accessKeyId: 'x', secretAccessKey: 'x' },
138
+ })
139
+ ```
140
+
141
+ | API | Base URL | Emulated endpoints |
142
+ |---|---|---|
143
+ | Groundmail | `http://localhost:3025` (or `/groundmail`) | `POST /emails`, `POST /emails/batch`, `GET /emails/:id` |
144
+ | SendGrid | `http://localhost:3025/sendgrid` | `POST /v3/mail/send` |
145
+ | Postmark | `localhost:3025/postmark` | `POST /email`, `/email/batch`, `/email/withTemplate`, `/email/batchWithTemplates` |
146
+ | Mailgun | `http://localhost:3025/mailgun` | `POST /v3/:domain/messages`, `/v3/:domain/messages.mime` |
147
+ | Resend | `http://localhost:3025/resend` | `POST /emails`, `POST /emails/batch`, `GET /emails/:id` |
148
+ | Amazon SES | `http://localhost:3025/ses` | v2 `SendEmail` (simple, raw, template), v1 `SendEmail`, `SendRawEmail` |
149
+
150
+ Stored provider templates (a SendGrid `template_id`, a Postmark `TemplateAlias`…) can't be
151
+ rendered locally; Groundhog shows the template name and the data it was sent with.
152
+
153
+ ## Testing
154
+
155
+ `waitForEmail()` waits for an email (one that's already arrived counts) and gives you its code and links.
156
+
157
+ ```bash
158
+ npm install -D @groundmail/groundhog
159
+ ```
160
+
161
+ ```ts
162
+ // Playwright
163
+ import { expect, test } from '@playwright/test'
164
+ import { waitForEmail } from '@groundmail/groundhog'
165
+
166
+ test('magic-link sign-in', async ({ page }) => {
167
+ const to = `ada+${Date.now()}@example.com`
168
+ await page.goto('http://localhost:3000/signin')
169
+ await page.getByLabel('Email').fill(to)
170
+ await page.getByRole('button', { name: 'Email me a link' }).click()
171
+
172
+ const email = await waitForEmail({ to })
173
+ email.code // "482913"
174
+ email.links[0] // "http://localhost:3000/magic?token=…"
175
+
176
+ await page.goto(email.links[0])
177
+ await expect(page.getByRole('heading', { name: `Signed in as ${to}` })).toBeVisible()
178
+ })
179
+ ```
180
+
181
+ The full runnable version is in [examples/playwright](examples/playwright). Helpers:
182
+
183
+ | Helper | What it does |
184
+ |---|---|
185
+ | `waitForEmail({ to, subject, since, timeout })` | Resolves with the first matching email; throws after `timeout` (default 30 s) |
186
+ | `listEmails({ to, subject, q, limit })` | Newest first |
187
+ | `getEmail(id)`, `clearEmails()` | |
188
+ | `extractCode(email)`, `extractLinks(email)`, `extractLink(email, contains?)` | Work on any HTML or text too |
189
+ | `simulateFault({ status, next })`, `clearFaults()` | Failure mode from a test |
190
+ | `fireWebhook({ id, event })` | Fire a simulated webhook |
191
+ | `startGroundhog({ httpPort, smtpPort })` | Run Groundhog inside your test process (`httpPort: 0` picks a free port) |
192
+
193
+ The helpers use `GROUNDHOG_URL` (default `http://localhost:3025`) or a `baseUrl` option.
194
+
195
+ Prefer plain HTTP? The same things are on the JSON API:
196
+
197
+ | Request | |
198
+ |---|---|
199
+ | `GET /api/emails?to=&subject=&q=&limit=` | List (newest first) |
200
+ | `GET /api/emails/latest?to=` | Latest match, or 404 |
201
+ | `GET /api/wait?to=&subject=&timeout=30000` | Long-poll until a match arrives (408 on timeout) |
202
+ | `GET /api/emails/:id` | One email, with `code` and `links` |
203
+ | `GET /api/emails/:id/source` | Raw RFC 5322 source (`?download` for an `.eml`) |
204
+ | `GET /api/emails/:id/attachments/:n` | An attachment |
205
+ | `DELETE /api/emails/:id`, `DELETE /api/emails` | Delete one, or clear the inbox |
206
+ | `POST /api/faults` `{ "status": 429, "next": 3 }` | Failure mode; `DELETE /api/faults` turns it off |
207
+ | `PUT /api/webhooks` `{ "url": "…" }` | Where simulated webhooks go |
208
+ | `POST /api/emails/:id/webhooks` `{ "event": "bounced" }` | Fire one |
209
+ | `GET /api/events` | Server-sent events for everything above |
210
+
211
+ ## Failure mode
212
+
213
+ Most apps have never seen their own error handling run. Turn on failure mode in the inbox
214
+ (**Failure mode** in the header) or from a test, and the next sends fail the way the real
215
+ provider fails:
216
+
217
+ ```bash
218
+ curl -X POST localhost:3025/api/faults -d '{"status":429,"next":3}'
219
+ ```
220
+
221
+ | `status` | HTTP APIs answer | SMTP answers |
222
+ |---|---|---|
223
+ | `429` | the provider's rate-limit error (SES v1: `Throttling`) | `450 4.7.1` |
224
+ | `422` | the provider's invalid-recipient error (400 where the provider uses 400) | `550 5.1.1` at `RCPT TO` |
225
+ | `401` | the provider's bad-key error (403 where the provider uses 403) | `535 5.7.8` |
226
+ | `5xx` | 500/503 in the provider's format | `451 4.3.0` |
227
+ | `timeout` | holds the request 30 s, then drops the connection | holds, then `421` |
228
+
229
+ A banner stays in the inbox while failure mode is on. `next: 0` or `DELETE /api/faults` turns it off.
230
+
231
+ ## Webhook simulation
232
+
233
+ Set your app's webhook URL in an email's **Webhooks** tab (or `PUT /api/webhooks`), then fire
234
+ `delivered`, `bounced`, `complained`, `opened` or `clicked`. The payload is in the format of the
235
+ API the email came through (Groundmail's for SMTP), or pick one. The delivery log shows what
236
+ your app answered.
237
+
238
+ Signatures use fixed local keys, so your verification code runs unchanged in development:
239
+
240
+ | Format | Signature | Local key |
241
+ |---|---|---|
242
+ | Groundmail | `Groundmail-Signature: t=…,v1=…` | `whsec_groundhog_local_dev_secret` |
243
+ | Resend (Svix) | `svix-id`, `svix-timestamp`, `svix-signature` | `whsec_Z3JvdW5kaG9nLWxvY2FsLWRldi13ZWJob29rLWtleSE=` |
244
+ | SendGrid | `X-Twilio-Email-Event-Webhook-Signature` (ECDSA) | public key shown in the Webhooks tab and `GET /api/webhooks` |
245
+ | Mailgun | `signature.signature` in the body | `groundhog-local-mailgun-signing-key` |
246
+ | Postmark | not signed (Postmark uses basic auth) | |
247
+ | Amazon SES | SNS `Notification` envelope | AWS signs SNS with its own certificate, which can't be reproduced locally; skip SNS signature checks in development |
248
+
249
+ Override the keys with `PUT /api/webhooks { "secrets": { "groundmail": "…" } }`. These keys are
250
+ public; never use them outside local development.
251
+
252
+ ## Send to my real inbox
253
+
254
+ Some things only show up in a real mail client. Open an email, click **Send to my real inbox**,
255
+ and connect a free [Groundmail account](https://app.groundmail.dev/signup?utm_source=groundhog&utm_medium=readme&utm_campaign=real_inbox)
256
+ once. The sign-in opens in your browser; there's no key to paste. Copies come from Groundmail's
257
+ sandbox sender, which only delivers to your own account address until you verify a domain, so
258
+ Groundhog can't email real users by mistake. The sign-in is saved in `~/.config/groundhog` and
259
+ only used when you click send.
260
+
261
+ ## AI coding agents (MCP)
262
+
263
+ Agents can finish sign-up and password-reset flows on their own by reading the email:
264
+
265
+ ```bash
266
+ claude mcp add groundhog -- npx -y @groundmail/groundhog mcp
267
+ ```
268
+
269
+ ```json
270
+ // Cursor: .cursor/mcp.json
271
+ { "mcpServers": { "groundhog": { "command": "npx", "args": ["-y", "@groundmail/groundhog", "mcp"] } } }
272
+ ```
273
+
274
+ Tools: `list_emails`, `wait_for_email(to, timeout)`, `get_email(id)`, `get_code(to)`,
275
+ `get_link(to, contains)` and `simulate_fault(status, next)`. The MCP server uses the Groundhog
276
+ running at `GROUNDHOG_URL` (default `http://localhost:3025`), and starts one itself if none is running.
277
+
278
+ ## Options
279
+
280
+ | Flag | Env | Default |
281
+ |---|---|---|
282
+ | `--smtp-port` | `GROUNDHOG_SMTP_PORT` | `1025` |
283
+ | `--http-port` | `GROUNDHOG_HTTP_PORT` | `3025` |
284
+ | `--host` | `GROUNDHOG_HOST` | `localhost` (`0.0.0.0` in Docker) |
285
+ | `--store <file>` | `GROUNDHOG_STORE` | in memory; a JSON file keeps emails across restarts |
286
+ | `--max <n>` | | `1000` emails |
287
+ | `--webhook-url <url>` | `GROUNDHOG_WEBHOOK_URL` | |
288
+ | `--no-open` | `GROUNDHOG_NO_OPEN=1` | opens the browser at a terminal |
289
+ | `--quiet` | | logs each caught email |
290
+
291
+ Groundhog never relays mail. Nothing it catches is sent anywhere unless you click
292
+ **Send to my real inbox**.
293
+
294
+ ## Ready for real inboxes?
295
+
296
+ When it's time to ship, [Groundmail](https://groundmail.dev/?utm_source=groundhog&utm_medium=readme&utm_campaign=go_live)
297
+ sends your app's email for real: an HTTP API and SMTP, with transactional mail on its own servers
298
+ and IPs. The free plan includes 3,000 emails a month. If you use the Groundmail SDK, going live is
299
+ two environment variables:
300
+
301
+ ```bash
302
+ GROUNDMAIL_API_KEY=gm_live_… # and drop GROUNDMAIL_BASE_URL
303
+ ```
304
+
305
+ [Start free on Groundmail →](https://app.groundmail.dev/signup?utm_source=groundhog&utm_medium=readme&utm_campaign=go_live)
306
+
307
+ ## Development
308
+
309
+ ```bash
310
+ npm install
311
+ npm run build
312
+ npm test # Vitest: every provider SDK, SMTP, faults, webhooks, MCP
313
+ npx playwright install chromium
314
+ npm run test:e2e # the inbox UI and the Playwright example
315
+ ```
316
+
317
+ Issues and pull requests are welcome.
318
+
319
+ ## License
320
+
321
+ [MIT](LICENSE)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { parseArgs } from 'node:util';
4
+ import { withUtm } from './groundmail-account.js';
5
+ import { startGroundhog, VERSION } from './server.js';
6
+ const HELP = `Groundhog ${VERSION}: a local inbox for development. Catches every email your app sends; nothing is delivered.
7
+
8
+ Usage
9
+ npx @groundmail/groundhog [options] start the inbox
10
+ npx @groundmail/groundhog mcp MCP server (stdio) for coding agents
11
+
12
+ Options
13
+ --smtp-port <port> SMTP port (default 1025, env GROUNDHOG_SMTP_PORT)
14
+ --http-port <port> web UI + APIs (default 3025, env GROUNDHOG_HTTP_PORT)
15
+ --host <host> listen address (default localhost; 0.0.0.0 in Docker, env GROUNDHOG_HOST)
16
+ --store <file> keep emails in this JSON file across restarts (env GROUNDHOG_STORE)
17
+ --max <n> emails kept in memory (default 1000)
18
+ --webhook-url <url> where simulated webhooks go (env GROUNDHOG_WEBHOOK_URL)
19
+ --no-open don't open the browser
20
+ --quiet don't log each caught email
21
+ -v, --version print the version
22
+ -h, --help this help
23
+
24
+ Point your app at it
25
+ SMTP localhost:1025, any username and password
26
+ Groundmail SDK new Groundmail(key, { baseUrl: 'http://localhost:3025' })
27
+ SendGrid sgMail.client.setDefaultRequest('baseUrl', 'http://localhost:3025/sendgrid')
28
+ Postmark new ServerClient(key, { requestHost: 'localhost:3025/postmark', useHttps: false })
29
+ Mailgun mailgun.client({ username: 'api', key, url: 'http://localhost:3025/mailgun' })
30
+ Resend RESEND_BASE_URL=http://localhost:3025/resend
31
+ Amazon SES new SESv2Client({ endpoint: 'http://localhost:3025/ses' })
32
+
33
+ Docs: https://github.com/Groundmail/groundhog`;
34
+ function openInBrowser(url) {
35
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
36
+ try {
37
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
38
+ child.on('error', () => { });
39
+ child.unref();
40
+ }
41
+ catch {
42
+ // The URL is printed either way.
43
+ }
44
+ }
45
+ function port(value, fallback) {
46
+ if (value === undefined || value === '')
47
+ return fallback;
48
+ const n = Number(value);
49
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
50
+ console.error(`Not a port: ${value}`);
51
+ process.exit(2);
52
+ }
53
+ return n;
54
+ }
55
+ async function main(argv) {
56
+ if (argv[0] === 'mcp') {
57
+ const { runMcp } = await import('./mcp.js');
58
+ await runMcp();
59
+ return;
60
+ }
61
+ const { values, positionals } = parseArgs({
62
+ args: argv,
63
+ allowPositionals: true,
64
+ options: {
65
+ 'smtp-port': { type: 'string' },
66
+ 'http-port': { type: 'string' },
67
+ host: { type: 'string' },
68
+ store: { type: 'string' },
69
+ max: { type: 'string' },
70
+ 'webhook-url': { type: 'string' },
71
+ open: { type: 'boolean', default: true },
72
+ 'no-open': { type: 'boolean' },
73
+ quiet: { type: 'boolean' },
74
+ version: { type: 'boolean', short: 'v' },
75
+ help: { type: 'boolean', short: 'h' },
76
+ },
77
+ });
78
+ if (values.help || positionals[0] === 'help') {
79
+ console.log(HELP);
80
+ return;
81
+ }
82
+ if (values.version) {
83
+ console.log(VERSION);
84
+ return;
85
+ }
86
+ if (positionals.length && positionals[0] !== 'start') {
87
+ console.error(`Unknown command: ${positionals[0]}\n\n${HELP}`);
88
+ process.exit(2);
89
+ }
90
+ const env = process.env;
91
+ const gh = await startGroundhog({
92
+ smtpPort: port(values['smtp-port'] ?? env.GROUNDHOG_SMTP_PORT, 1025),
93
+ httpPort: port(values['http-port'] ?? env.GROUNDHOG_HTTP_PORT, 3025),
94
+ host: values.host ?? env.GROUNDHOG_HOST ?? 'localhost',
95
+ storeFile: values.store ?? env.GROUNDHOG_STORE,
96
+ maxEmails: values.max ? Number(values.max) : undefined,
97
+ webhooks: { url: values['webhook-url'] ?? env.GROUNDHOG_WEBHOOK_URL ?? null },
98
+ openBrowser: openInBrowser,
99
+ log: values.quiet ? undefined : (line) => console.log(` ${new Date().toLocaleTimeString()} ${line}`),
100
+ }).catch((error) => {
101
+ console.error(`\n ${error.message}\n`);
102
+ process.exit(1);
103
+ });
104
+ console.log(`
105
+ Groundhog ${VERSION} is catching email. Nothing leaves this machine.
106
+
107
+ Inbox ${gh.url}
108
+ SMTP localhost:${gh.smtpPort} (any username and password)
109
+ APIs ${gh.url}/<groundmail|sendgrid|postmark|mailgun|resend|ses>
110
+
111
+ From Groundmail: ${withUtm('https://groundmail.dev/', 'cli')}
112
+ `);
113
+ const wantsOpen = values.open !== false && !values['no-open'] && process.stdout.isTTY && !env.CI && !env.GROUNDHOG_NO_OPEN;
114
+ if (wantsOpen)
115
+ openInBrowser(gh.url);
116
+ let closing = false;
117
+ const shutdown = async () => {
118
+ if (closing)
119
+ process.exit(0);
120
+ closing = true;
121
+ await gh.close();
122
+ process.exit(0);
123
+ };
124
+ process.on('SIGINT', shutdown);
125
+ process.on('SIGTERM', shutdown);
126
+ }
127
+ main(process.argv.slice(2)).catch((error) => {
128
+ console.error(error);
129
+ process.exit(1);
130
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Helpers for tests (Playwright, Vitest, Jest…) that talk to a running Groundhog.
3
+ *
4
+ * const email = await waitForEmail({ to: 'ada@example.com' })
5
+ * email.code // "482913"
6
+ * email.links[0] // "http://localhost:3000/verify?token=…"
7
+ */
8
+ import { extractCode, extractLinks } from './extract.js';
9
+ import type { Email, FaultKind, WebhookEvent } from './types.js';
10
+ export type ClientOptions = {
11
+ /** Groundhog's HTTP URL. Default: GROUNDHOG_URL, then http://localhost:3025. */
12
+ baseUrl?: string;
13
+ fetch?: typeof fetch;
14
+ };
15
+ export type EmailQuery = {
16
+ to?: string;
17
+ from?: string;
18
+ subject?: string;
19
+ /** Free-text search over subject, addresses, text and code. */
20
+ q?: string;
21
+ via?: Email['via'];
22
+ /** Only emails caught at or after this time. */
23
+ since?: Date | string;
24
+ };
25
+ export type WaitOptions = EmailQuery & ClientOptions & {
26
+ /** Milliseconds. Default 30 000. */
27
+ timeout?: number;
28
+ };
29
+ /** Wait for an email that matches (one already caught counts). Throws after `timeout`. */
30
+ export declare function waitForEmail(options?: WaitOptions): Promise<Email>;
31
+ export declare function listEmails(query?: EmailQuery & ClientOptions & {
32
+ limit?: number;
33
+ }): Promise<Email[]>;
34
+ export declare function getEmail(id: string, options?: ClientOptions): Promise<Email>;
35
+ export declare function clearEmails(options?: ClientOptions): Promise<void>;
36
+ /** Make the next `next` sends fail. `status`: 429, 422, 401, '5xx' or 'timeout'. */
37
+ export declare function simulateFault(options: ClientOptions & {
38
+ status: 429 | 422 | 401 | 500 | 503 | '5xx' | 'timeout' | FaultKind;
39
+ next?: number;
40
+ timeoutMs?: number;
41
+ }): Promise<void>;
42
+ export declare function clearFaults(options?: ClientOptions): Promise<void>;
43
+ /** Fire a simulated webhook for a caught email at the configured (or given) URL. */
44
+ export declare function fireWebhook(options: ClientOptions & {
45
+ id: string;
46
+ event: WebhookEvent;
47
+ url?: string;
48
+ }): Promise<{
49
+ status: number | null;
50
+ ok: boolean;
51
+ error: string | null;
52
+ }>;
53
+ /** The first link in the email (skipping unsubscribe/asset links), or the first containing `contains`. */
54
+ export declare function extractLink(email: Pick<Email, 'html' | 'text'> | string, contains?: string): string | null;
55
+ export { extractCode, extractLinks };
package/dist/client.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Helpers for tests (Playwright, Vitest, Jest…) that talk to a running Groundhog.
3
+ *
4
+ * const email = await waitForEmail({ to: 'ada@example.com' })
5
+ * email.code // "482913"
6
+ * email.links[0] // "http://localhost:3000/verify?token=…"
7
+ */
8
+ import { extractCode, extractLinks, pickLink } from './extract.js';
9
+ function base(options = {}) {
10
+ const fromEnv = typeof process !== 'undefined' ? process.env?.GROUNDHOG_URL : undefined;
11
+ return (options.baseUrl ?? fromEnv ?? 'http://localhost:3025').replace(/\/+$/, '');
12
+ }
13
+ function queryString(query) {
14
+ const params = new URLSearchParams();
15
+ for (const [key, value] of Object.entries(query)) {
16
+ if (value === undefined || value === null || value === '')
17
+ continue;
18
+ if (!['to', 'from', 'subject', 'q', 'via', 'since', 'timeout', 'limit'].includes(key))
19
+ continue;
20
+ params.set(key, value instanceof Date ? value.toISOString() : String(value));
21
+ }
22
+ const text = params.toString();
23
+ return text ? `?${text}` : '';
24
+ }
25
+ async function request(path, init = {}) {
26
+ const doFetch = init.fetch ?? fetch;
27
+ let response;
28
+ try {
29
+ response = await doFetch(`${base(init)}${path}`, {
30
+ ...init,
31
+ headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
32
+ });
33
+ }
34
+ catch (error) {
35
+ throw new Error(`Could not reach Groundhog at ${base(init)}. Start it with \`npx @groundmail/groundhog\`. (${error.message})`);
36
+ }
37
+ const body = (await response.json().catch(() => ({})));
38
+ return { status: response.status, body };
39
+ }
40
+ /** Wait for an email that matches (one already caught counts). Throws after `timeout`. */
41
+ export async function waitForEmail(options = {}) {
42
+ const timeout = options.timeout ?? 30_000;
43
+ const { status, body } = await request(`/api/wait${queryString({ ...options, timeout })}`, options);
44
+ if (status === 200)
45
+ return body;
46
+ const what = [options.to && `to ${options.to}`, options.subject && `subject "${options.subject}"`].filter(Boolean).join(', ');
47
+ throw new Error(`No email${what ? ` ${what}` : ''} arrived within ${timeout} ms${body.error && status !== 408 ? `: ${body.error}` : ''}`);
48
+ }
49
+ export async function listEmails(query = {}) {
50
+ const { body } = await request(`/api/emails${queryString(query)}`, query);
51
+ return body.data ?? [];
52
+ }
53
+ export async function getEmail(id, options = {}) {
54
+ const { status, body } = await request(`/api/emails/${encodeURIComponent(id)}`, options);
55
+ if (status !== 200)
56
+ throw new Error(body.error ?? `Email ${id} not found`);
57
+ return body;
58
+ }
59
+ export async function clearEmails(options = {}) {
60
+ await request(`/api/emails`, { ...options, method: 'DELETE' });
61
+ }
62
+ /** Make the next `next` sends fail. `status`: 429, 422, 401, '5xx' or 'timeout'. */
63
+ export async function simulateFault(options) {
64
+ const { status, body } = await request(`/api/faults`, {
65
+ ...options,
66
+ method: 'POST',
67
+ body: JSON.stringify({ status: options.status, next: options.next ?? 1, timeoutMs: options.timeoutMs }),
68
+ });
69
+ if (status !== 200)
70
+ throw new Error(body.error ?? 'Could not set the fault');
71
+ }
72
+ export async function clearFaults(options = {}) {
73
+ await request(`/api/faults`, { ...options, method: 'DELETE' });
74
+ }
75
+ /** Fire a simulated webhook for a caught email at the configured (or given) URL. */
76
+ export async function fireWebhook(options) {
77
+ const { status, body } = await request(`/api/emails/${encodeURIComponent(options.id)}/webhooks`, {
78
+ ...options,
79
+ method: 'POST',
80
+ body: JSON.stringify({ event: options.event, url: options.url }),
81
+ });
82
+ if (status !== 200)
83
+ throw new Error(body.error ?? 'Could not fire the webhook');
84
+ return body;
85
+ }
86
+ /** The first link in the email (skipping unsubscribe/asset links), or the first containing `contains`. */
87
+ export function extractLink(email, contains) {
88
+ return pickLink(extractLinks(email), contains);
89
+ }
90
+ export { extractCode, extractLinks };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pull the one-time code and the links out of an email, so a test (or an agent)
3
+ * can finish a sign-in without reading the email itself.
4
+ */
5
+ type EmailLike = {
6
+ html?: string | null;
7
+ text?: string | null;
8
+ subject?: string | null;
9
+ };
10
+ export declare function decodeEntities(value: string): string;
11
+ /** A rough plain-text rendering of HTML, good enough for finding codes. */
12
+ export declare function htmlToText(html: string): string;
13
+ /** Every http(s) link in the email, in the order it appears, without duplicates. */
14
+ export declare function extractLinks(input: EmailLike | string): string[];
15
+ /**
16
+ * The one-time code in the email: digits (4–8, also "123 456" / "123-456"), or a short
17
+ * letters-and-digits code next to a word like "code". `null` when nothing looks like one.
18
+ */
19
+ export declare function extractCode(input: EmailLike | string): string | null;
20
+ /** The first link whose URL contains `contains` (or the first link that isn't an unsubscribe/asset link). */
21
+ export declare function pickLink(links: string[], contains?: string): string | null;
22
+ export {};