Self-hostable broadcast email tool. Manage contact lists, design emails with a visual editor, send campaigns through Resend or Amazon SES, and track opens and clicks. No third-party email marketing platform required.
Sponsored by Autter. Built and maintained as an open source project for the community.
- Contact Management - Import contacts via CSV/XLSX upload, organize into lists, track subscription status
- Visual Email Editor - Drag-and-drop block editor with live preview, mobile/desktop toggle, and merge tag support
- Asset Library - Upload images and files once, browse and reuse them across campaigns from a central library
- Saved Templates Library - Save any campaign as a reusable template, browse with live thumbnail previews, and start new campaigns from any template
- Multiple Email Providers - Send through Resend or Amazon SES with encrypted credential storage
- Campaign Sending - Queue-based sending via pg-boss, with scheduling, cancellation, and per-provider rate limiting
- Open and Click Tracking - Tracking pixel for opens, link wrapping for clicks, per-campaign analytics with charts
- Unsubscribe Handling - One-click unsubscribe with List-Unsubscribe header support
- Double Opt-In - Per-list toggle that requires new contacts to confirm via emailed link before they receive campaigns
- Signup Forms - Build forms in the dashboard, share a hosted page or embed a JS snippet on any site, with optional double opt-in
- REST API - Full API with Bearer token auth for programmatic access to lists, contacts, and campaign data
- Self-Contained - Postgres for everything (data, queue, migrations). No Redis, no external queue. Optional MinIO/S3 for file uploads
- Docker Ready - Single
docker compose upfor local development, production-ready Dockerfile included
git clone https://github.com/your-org/hedwig-mail.git
cd hedwig-mail
cp .env.example .env.localGenerate required secrets:
# Generate NEXTAUTH_SECRET
openssl rand -base64 32
# Generate ENCRYPTION_KEY
openssl rand -hex 32Edit .env.local with the generated values, then:
docker compose up -dThis starts the app, worker, Postgres, and MinIO. Open http://localhost:3000 and log in with the credentials from your .env.local.
Run database migrations:
docker compose exec app node -r tsx/cjs lib/db/migrate.ts- Node.js 20+
- PostgreSQL 16+
- MinIO (optional, for file uploads)
# Install dependencies
npm install
# Copy environment file and fill in values
cp .env.example .env.local
# Create the database
createdb emailtool
# Run migrations
npm run db:migrate
# Start the dev server
npm run dev
# In a separate terminal, start the background worker
npm run workerThe app runs at http://localhost:3000.
If you want contact file uploads to work locally:
docker run -d \
--name minio \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data --console-address ":9001"Then create the bucket:
# Using the MinIO console at http://localhost:9001
# Or using the mc CLI:
mc alias set local http://localhost:9000 minioadmin minioadmin
mc mb local/emailtool| Command | Description |
|---|---|
npm run dev |
Start Next.js dev server |
npm run build |
Production build |
npm run start |
Start production server |
npm run worker |
Start background job worker |
npm run db:generate |
Generate Drizzle migration files |
npm run db:migrate |
Run database migrations |
npm run db:studio |
Open Drizzle Studio (database browser) |
npm run lint |
Run ESLint |
| Variable | Description | Default |
|---|---|---|
APP_URL |
Public URL of the app (used in email links) | http://localhost:3000 |
APP_NAME |
Name shown in unsubscribe pages | hedwig-mail |
NEXTAUTH_URL |
NextAuth callback URL | http://localhost:3000 |
NEXTAUTH_SECRET |
JWT signing secret | (required) |
ADMIN_EMAIL |
Login email | admin@example.com |
ADMIN_PASSWORD |
Login password | (required) |
DATABASE_URL |
PostgreSQL connection string | (required) |
S3_ENDPOINT |
S3-compatible endpoint URL | http://localhost:9000 |
S3_REGION |
S3 region | us-east-1 |
S3_ACCESS_KEY_ID |
S3 access key | (required) |
S3_SECRET_ACCESS_KEY |
S3 secret key | (required) |
S3_BUCKET |
S3 bucket name | emailtool |
S3_FORCE_PATH_STYLE |
Use path-style URLs (required for MinIO) | true |
ENCRYPTION_KEY |
32-byte hex key for encrypting provider credentials | (required) |
WORKER_CONCURRENCY |
Number of concurrent email send jobs | 5 |
RESEND_EMAILS_PER_SECOND |
Resend throttle per worker process | 2 |
EMAIL_CHECKER_BASE_URL |
External checker API public base URL (used for /v1/check_email) |
(required) |
EMAIL_CHECKER_API_SECRET |
Checker auth secret sent as x-api-secret |
(required) |
EMAIL_CHECKER_TIMEOUT_MS |
Timeout for checker API calls in milliseconds | 30000 |
HIBP_API_KEY |
Optional Have I Been Pwned key forwarded to checker requests | |
CONFIRMATION_FROM_EMAIL |
Sender address for double opt-in confirmation emails. Required when any list has double opt-in enabled. Sender name uses APP_NAME. |
(required if double opt-in is used) |
RESEND_WEBHOOK_SECRET |
Resend webhook signing secret (optional) |
- Go to Settings > Providers in the dashboard
- Click "Add Provider", select "Resend"
- Enter your Resend API key
- Click "Validate Connection" to verify
- Go to Settings > Providers in the dashboard
- Click "Add Provider", select "SES"
- Enter your IAM Access Key ID, Secret Access Key, and region
- Click "Validate Connection" to verify
- Make sure your sending domain or email is verified in SES
Webhooks let hedwig-mail track bounces and complaints reported by the email provider.
Resend:
- Go to the Resend dashboard > Webhooks
- Add a webhook pointing to
https://your-domain.com/api/webhooks/resend - Select the
email.bouncedandemail.complainedevents - Copy the signing secret to
RESEND_WEBHOOK_SECRETin your env
Amazon SES:
- In AWS SNS, create a topic
- Add an HTTPS subscription pointing to
https://your-domain.com/api/webhooks/ses - The endpoint will auto-confirm the subscription
- In SES, configure a Configuration Set to publish bounce and complaint notifications to the SNS topic
Each list has a per-list double opt-in toggle. When enabled, any new contact added to the list (via the dashboard, CSV upload, or REST API) is created with status = pending and immediately receives a confirmation email. The contact is excluded from campaigns until they click the link, after which their status flips to active.
Enabling per list:
- On creation: check "Require double opt-in" in the New List dialog.
- On an existing list: open the list detail page and use the "Double opt-in" switch in the header. You can flip it on or off at any time.
Toggle behavior:
- Turning it on affects new contacts only. Existing
activecontacts are not retroactively flipped topending. - Turning it off affects new contacts only. Existing
pendingcontacts stay pending until they confirm or are manually edited.
Required setup:
- Set
CONFIRMATION_FROM_EMAILin your env to a verified sender address (e.g.noreply@yourdomain.com). - Configure at least one email provider and mark it as the default in Settings > Providers. Confirmation emails are sent through the default provider.
- Make sure
APP_URLis publicly reachable: the confirmation link in the email points atAPP_URL/confirm/<token>.
What the recipient sees: a minimal page at /confirm/<token> showing the list name and email address, with a single "Confirm Subscription" button. After clicking, the page shows a success message and the token is consumed.
Send-time gating: campaign sends already filter on status = active, so pending contacts are automatically excluded with no extra configuration.
Forms turn your lists into something people can self-subscribe to. Each form is tied to a single list. Submissions create a contact in that list, respecting the global suppression list.
- Go to Forms in the sidebar and click New Form.
- Pick a name and a target list.
- In the builder, configure fields (email is required, plus any combination of text, checkbox, and select fields), set a success message or a redirect URL, and toggle double opt-in if you want a confirmation step.
- Save.
Each form has two surfaces:
- Hosted page:
${APP_URL}/form/<form-id>. Share the link directly or link from a navigation menu. - JS embed snippet: drop the snippet into any HTML page. The form renders inline and posts back to your hedwig-mail instance.
<script
src="https://mail.example.com/api/public/forms/FORM_ID/embed.js"
data-form-id="FORM_ID"
async
></script>The embed renders the form via DOM injection so it inherits your site's styling. For maximum CSS isolation, add data-mode="iframe" and the snippet renders the hosted page inside an iframe instead.
When double opt-in is enabled, a submission creates a contact with status pending and queues a confirmation email through the chosen provider. The contact is not eligible for campaign sends until they click the link, which transitions them to active via ${APP_URL}/confirm/<token>.
The confirmation email is authored with the same block editor used for campaigns. Use the merge tag {{confirm_url}} in a button block. Tracking pixels and the unsubscribe footer are deliberately omitted from confirmation emails, since the contact has not opted in yet.
- Submitted emails are checked against the global suppression list before any contact is created. Suppressed emails get the same success response so the form can't be used to probe membership.
- Each form is rate-limited per source IP.
- A hidden honeypot field rejects bots silently.
- Submissions use
application/x-www-form-urlencodedso the embed avoids CORS preflights from third-party origins.
For production, switch from MinIO to AWS S3 by changing three variables:
S3_ENDPOINT= # Remove this line (uses AWS default)
S3_FORCE_PATH_STYLE= # Remove this line
S3_ACCESS_KEY_ID=AKIA... # Your AWS access key
S3_SECRET_ACCESS_KEY=... # Your AWS secret key
S3_REGION=us-east-1 # Your bucket's region
S3_BUCKET=your-bucket # Your S3 bucket nameAll API endpoints are under /api/v1/ and require a Bearer token. Create API keys in Settings > API Keys.
curl -H "Authorization: Bearer your-api-key" \
http://localhost:3000/api/v1/listsAll responses use the envelope format: { data, meta, error }.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/lists |
List all lists |
POST |
/api/v1/lists |
Create a list |
GET |
/api/v1/lists/:listId/contacts |
List contacts (paginated) |
POST |
/api/v1/lists/:listId/contacts |
Create a contact |
POST |
/api/v1/lists/:listId/contacts/bulk |
Bulk upsert contacts (max 1000) |
PUT |
/api/v1/lists/:listId/contacts/:id |
Update a contact |
DELETE |
/api/v1/lists/:listId/contacts/:id |
Delete a contact |
GET |
/api/v1/campaigns |
List all campaigns |
GET |
/api/v1/campaigns/:id |
Get campaign detail |
GET |
/api/v1/campaigns/:id/stats |
Get campaign analytics |
List endpoints support page (default 1) and limit (default 50, max 200) query parameters.
curl -H "Authorization: Bearer your-api-key" \
"http://localhost:3000/api/v1/lists/LIST_ID/contacts?page=2&limit=100"The contacts endpoint supports status and search query parameters:
# Filter by status
curl -H "Authorization: Bearer ..." \
"http://localhost:3000/api/v1/lists/LIST_ID/contacts?status=active"
# Search by email
curl -H "Authorization: Bearer ..." \
"http://localhost:3000/api/v1/lists/LIST_ID/contacts?search=john"curl -X POST \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "email": "alice@example.com", "firstName": "Alice" },
{ "email": "bob@example.com", "firstName": "Bob", "metadata": { "company": "Acme" } }
]
}' \
http://localhost:3000/api/v1/lists/LIST_ID/contacts/bulkBuild and run with the included Dockerfile:
docker build -t hedwig-mail .
docker run -p 3000:3000 --env-file .env hedwig-mailRun the worker as a separate container:
docker run --env-file .env hedwig-mail node -r tsx/cjs worker-entry.tsRun migrations before first start:
docker run --env-file .env hedwig-mail node -r tsx/cjs lib/db/migrate.ts- Connect your GitHub repository
- Railway auto-detects the Dockerfile
- Set all environment variables in the Railway dashboard
- Add a PostgreSQL plugin and update
DATABASE_URL - For the worker, create a second service from the same repo with start command:
node -r tsx/cjs worker-entry.ts - For S3, use an external service (AWS S3, Cloudflare R2, etc.)
For a full beginner-friendly setup with web + worker + postgres + minio, use RAILWAY_DEPLOYMENT.md.
If outbound SMTP is blocked on your plan, deploy the checker service on Fly and point this app to it: external checker on Fly guide.
- Create a new Web Service from your repository
- Render auto-detects the Dockerfile
- Set environment variables in the dashboard
- Add a Render PostgreSQL database and update
DATABASE_URL - Create a Background Worker service for the worker process with start command:
node -r tsx/cjs worker-entry.ts
Create a fly.toml:
app = "hedwig-mail"
primary_region = "iad"
[build]
[http_service]
internal_port = 3000
force_https = true
[env]
NODE_ENV = "production"
PORT = "3000"fly launch
fly secrets set DATABASE_URL=... NEXTAUTH_SECRET=... ENCRYPTION_KEY=...
fly deployFor the worker, create a separate Fly app or use a Fly Machine with the worker command.
Vercel can host the Next.js app but has limitations:
- No persistent worker. Serverless functions have a max execution time. You cannot run the pg-boss worker on Vercel.
- Workaround: Host the worker separately (Railway, Render, a VPS, or a long-running container). The worker only needs
DATABASE_URLand the same env vars. - Serverless function timeout may affect large campaign sends via the API. Use the worker for all sending.
Set APP_URL to your production domain (e.g., https://mail.yourdomain.com). This URL is embedded into tracking pixels and unsubscribe links in sent emails. Do not change it after sending campaigns, or tracking links in already-sent emails will break.
Next.js App (port 3000)
- Dashboard UI (React, Tailwind, shadcn/ui)
- Internal API routes (/api/internal/*)
- Public API routes (/api/v1/*)
- Tracking routes (/t/*, /r/*)
- Webhook receivers (/api/webhooks/*)
Background Worker (worker.ts)
- Processes email send jobs from pg-boss queue
- Handles rate limiting per provider
- Checks for campaign cancellation
PostgreSQL
- Application data (lists, contacts, campaigns)
- Job queue (pg-boss tables)
- Migrations (Drizzle)
S3/MinIO
- CSV/XLSX upload storage
Found a vulnerability? Please do not file a public issue. See SECURITY.md for the private reporting process.
For operators, the production hardening checklist lives in SETUP.md.
Pull requests welcome. See CONTRIBUTING.md for local setup, branch naming, and the PR checklist.
hedwig-mail is sponsored by Autter. Autter funds development and maintenance, but the project is open source under MIT and runs entirely on infrastructure you control.
MIT

