@groundmail/groundhog 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +130 -0
- package/dist/client.d.ts +55 -0
- package/dist/client.js +90 -0
- package/dist/extract.d.ts +22 -0
- package/dist/extract.js +116 -0
- package/dist/faults.d.ts +24 -0
- package/dist/faults.js +64 -0
- package/dist/groundmail-account.d.ts +50 -0
- package/dist/groundmail-account.js +233 -0
- package/dist/http.d.ts +58 -0
- package/dist/http.js +152 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +156 -0
- package/dist/mime.d.ts +52 -0
- package/dist/mime.js +182 -0
- package/dist/providers/common.d.ts +14 -0
- package/dist/providers/common.js +68 -0
- package/dist/providers/groundmail.d.ts +31 -0
- package/dist/providers/groundmail.js +169 -0
- package/dist/providers/index.d.ts +12 -0
- package/dist/providers/index.js +10 -0
- package/dist/providers/mailgun.d.ts +3 -0
- package/dist/providers/mailgun.js +150 -0
- package/dist/providers/postmark.d.ts +3 -0
- package/dist/providers/postmark.js +147 -0
- package/dist/providers/resend.d.ts +8 -0
- package/dist/providers/resend.js +121 -0
- package/dist/providers/sendgrid.d.ts +3 -0
- package/dist/providers/sendgrid.js +128 -0
- package/dist/providers/ses.d.ts +3 -0
- package/dist/providers/ses.js +244 -0
- package/dist/providers/types.d.ts +35 -0
- package/dist/providers/types.js +1 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.js +450 -0
- package/dist/signing.d.ts +25 -0
- package/dist/signing.js +47 -0
- package/dist/smtp.d.ts +13 -0
- package/dist/smtp.js +70 -0
- package/dist/store.d.ts +35 -0
- package/dist/store.js +171 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +12 -0
- package/dist/ui/app.css +214 -0
- package/dist/ui/app.js +621 -0
- package/dist/ui/favicon.svg +1 -0
- package/dist/ui/index.html +108 -0
- package/dist/webhooks.d.ts +72 -0
- package/dist/webhooks.js +102 -0
- 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,324 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
+
Releases are automatic: bump `version` in `package.json` and merge to `main`. The release
|
|
318
|
+
workflow publishes to npm (with provenance), tags the version, and pushes the Docker image.
|
|
319
|
+
|
|
320
|
+
Issues and pull requests are welcome.
|
|
321
|
+
|
|
322
|
+
## License
|
|
323
|
+
|
|
324
|
+
[MIT](LICENSE)
|
package/dist/cli.d.ts
ADDED
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
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -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 {};
|