A self-hosted mass-mailing web app — the open-source cousin of Mailmeteor / Lemlist. Send personalized cold-outreach campaigns from your own Gmail account(s), with warmup, follow-ups, open/click tracking, reply detection, and one-click unsubscribe.
Runs entirely on free tiers: Vercel (app), Supabase (database + storage + cron), Gmail SMTP (sending).
- Campaigns — Upload recipients via Google Sheets or Excel/CSV, write a Markdown body with
{{merge_tags}}, preview per-recipient, send on a schedule. - Autopilot — Per-day schedule (e.g. Mon-Fri 9-5), daily cap, min gap between sends, timezone-aware.
- Multiple senders — Attach different Gmail accounts per campaign; each has its own app password (encrypted at rest).
- Warmup — 14-day automatic ramp (10 → 400/day) for new Gmail accounts to build sender reputation.
- Follow-ups — Multi-step sequences, threaded as
Re:replies to the original message. Auto-stops when the recipient replies. - Open / click tracking — Invisible pixel + link rewrites. See per-recipient timeline with device + referrer.
- Reply detection — IMAP polling flips recipients to
repliedand captures the full reply body. - Unsubscribe — One-click link with HMAC tokens, added to
List-Unsubscribeheader for Gmail's one-click feature. - Attachments — Up to 5 files per campaign (PDF, DOC, ZIP, etc.).
- Deliverability hints — Built-in spam-word detection before send.
- Command palette (
⌘K) — Global search across campaigns, recipients, replies, senders.
- Next.js 15 (App Router) + TypeScript + Tailwind
- Supabase — Postgres, Storage,
pg_cron+pg_netfor scheduled ticks - nodemailer (SMTP send) + imapflow / mailparser (reply polling)
- iron-session — cookie auth (single-password login)
- tiptap + tiptap-markdown — WYSIWYG body editor
- Node.js ≥ 20 (download)
- A Supabase project (free tier — supabase.com)
- A Gmail account with 2-Step Verification enabled (for the app password)
- A Vercel account (free — vercel.com) if you want production deploy
- Git
No Google Cloud API key needed. No paid tier needed for anything.
git clone https://github.com/<you>/emailsvia.git
cd emailsvia
npm install- Go to app.supabase.com → New project
- Name it anything (e.g.
emailsvia), pick a region close to you, generate a strong DB password (save it somewhere) - Wait ~2 minutes for the project to spin up
- In your Supabase project → SQL Editor → New query
- Paste the full contents of
supabase/schema.sql - Click Run. You should see "Success. No rows returned."
- This creates all tables, indexes, RLS policies, and the
attachmentsstorage bucket.
App passwords are 16-character alternate passwords that let nodemailer sign in without hitting Google's OAuth flow.
- Enable 2-Step Verification on your Google account if not already: myaccount.google.com/security
- Go to myaccount.google.com/apppasswords
- App name:
EmailsVia→ Create - Copy the 16-char password (format like
abcd efgh ijkl mnop— spaces are OK, app strips them)
Copy .env.example to .env.local:
cp .env.example .env.localThen fill in .env.local:
| Variable | Where to get it |
|---|---|
SUPABASE_URL |
Supabase → Project Settings → API → Project URL |
SUPABASE_ANON_KEY |
Supabase → Project Settings → API → anon/public key |
SUPABASE_SERVICE_ROLE_KEY |
Supabase → Project Settings → API → service_role key (keep secret!) |
GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET |
Google Cloud Console → OAuth client (used by Supabase sign-in + sender Gmail OAuth) |
ENCRYPTION_SECRET |
Run node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" → paste output. Used for AES-GCM encryption of sender creds + HMAC-signed tracking tokens |
CRON_SECRET |
Same as above — different random hex string. Bearer token for /api/tick, /api/check-replies, /api/cron/refresh-tokens |
APP_URL |
http://localhost:3000 for local dev. Change to your Vercel URL in production |
Optional (paid features go silently no-op without these):
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY / NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PRICE_* |
Billing — without these the dev billing page falls back to a "switch plan in dev mode" shortcut |
ANTHROPIC_API_KEY |
AI reply triage + AI personalization (Growth/Scale only) |
POSTMARK_SERVER_TOKEN + POSTMARK_FROM_EMAIL |
Sender-revoked + payment-failed notification emails |
NEXT_PUBLIC_SENTRY_DSN |
Server + client error reporting |
ADMIN_USER_IDS |
Comma-separated auth.users.id values that can access /app/admin |
npm run devOpen http://localhost:3000 → click Get Started → sign in with Google (or email + password). You'll land on the empty campaigns dashboard at /app.
Quick smoke test:
- Senders → Connect Gmail → consent → see "Connected" toast
- New campaign → Fill in name + subject + body, upload a recipient list (CSV or Google Sheets URL)
- Send a test email to yourself before hitting Start
- Hit Run, then click Run tick (dev-mode button) to send the next recipient immediately
The dev server also runs an in-process scheduler that hits /api/tick every minute and /api/check-replies every 5 min — same cadence as production cron — so campaigns auto-progress without manual ticks.
- Push your repo to GitHub (private or public — your choice)
- vercel.com/new → import the repo → leave all defaults
- On the Environment Variables screen, paste every variable from your
.env.local— includingAPP_URL, which should be your production URL. You won't know this URL yet, so either:- Deploy first with a placeholder, then edit
APP_URLto the real Vercel URL and redeploy - Or set a custom domain first
- Deploy first with a placeholder, then edit
- Deploy. Wait ~2 min.
⚠️ Critical:APP_URLin production MUST point to your live Vercel URL (e.g.https://emailsvia-xyz.vercel.app). If it'shttp://localhost:3000, every email you send will have broken tracking pixels, broken unsubscribe links, and broken click-tracked URLs — recipients will see 404s.
The app needs a minute-by-minute cron to send emails and a 5-min cron to poll for replies. Vercel's free tier only allows daily crons, so we run them inside Supabase using pg_cron + pg_net.
- Supabase → Database → Extensions → enable
pg_cronandpg_net(toggle on if not already) - Supabase → SQL Editor → paste + edit the values in
supabase/cron.sql:- Set the
app_urlrow to your production URL (e.g.https://emailsvia-xyz.vercel.app) - Set the
cron_secretrow to yourCRON_SECRETvalue
- Set the
- Run the file
This creates a public.cron_config table that stores URL + secret, and two scheduled jobs that read from it. To change URL or rotate the secret later, just update the table — no need to recreate the jobs.
In Supabase SQL Editor:
-- Should show 2 active rows
select jobname, schedule, active from cron.job where jobname like 'mail-automation%';
-- Wait 90 seconds, then check HTTP responses — should see status_code = 200
select id, status_code, created
from net._http_response
where created > now() - interval '3 minutes'
order by created desc limit 5;If you see 404 DEPLOYMENT_NOT_FOUND → app_url is wrong. If 401 → cron_secret doesn't match CRON_SECRET env var on Vercel.
Reply detection is automatic — the /api/check-replies cron runs every 5 min, polls each connected sender's inbox (Gmail API for OAuth senders, IMAP for app-password senders), correlates inbound messages back to your campaigns, and flips matched recipients to replied. AI intent classification (Growth/Scale) runs in the same pass.
- Senders → Connect Gmail (OAuth) or Use app password (legacy fallback) — every campaign requires at least one sender attached
- New campaign
- Recipients: either paste a Google Sheets URL (sharing: Anyone with link → Viewer) or upload
.csv/.xlsx. First row = column headers. Must includeName,Company,Emailcolumns. Any other column becomes a{{merge_tag}}. - Subject: e.g.
Hi {{Name}} — quick question about {{Company}} - Body: Markdown. Use the toolbar for bold/italic/lists/links. Paste from Gmail/Docs/Word keeps formatting.
- Autopilot: pick days + hours + daily cap + gap
- Follow-ups (optional): add steps with delay in days
- Send test to yourself first
- Recipients: either paste a Google Sheets URL (sharing: Anyone with link → Viewer) or upload
- Hit Start campaign — status flips to
running, pg_cron takes over
Things like the production URL or cron secret can rotate without touching cron jobs:
-- Change deployed URL (new Vercel deployment, custom domain, etc.)
update public.cron_config set value = 'https://newurl.vercel.app' where key = 'app_url';
-- Rotate cron secret (also update the CRON_SECRET env var on Vercel + redeploy)
update public.cron_config set value = 'new-random-string' where key = 'cron_secret';Nothing sending — campaign stuck at "running"
-- Are the cron jobs active?
select jobname, schedule, active from cron.job where jobname like 'mail-automation%';
-- Recent HTTP responses
select id, status_code, created, content::text
from net._http_response
where created > now() - interval '5 minutes'
order by created desc limit 10;status_code = 200→ everything fine, check campaign's schedule + warmup cap401→ secret mismatch (CRON_SECRETon Vercel vscron_config.cron_secret)404 DEPLOYMENT_NOT_FOUND→app_urlincron_configis wrong- No rows →
pg_cronorpg_netextension disabled, or cron jobs never installed
"outside_window" in tick response — your schedule has today's day disabled or current time is outside the window. Edit the campaign's Autopilot.
SMTP auth errors — the Gmail app password was revoked or typed wrong. Regenerate and update the sender.
Open/click tracking not working in production — APP_URL env var is still http://localhost:3000. Set it to production URL and redeploy.
pgaudit "stack is not empty" in Supabase SQL editor — open a fresh SQL editor tab; the previous session left pgaudit in a bad state.
src/
├── app/
│ ├── api/
│ │ ├── tick/ # Main send loop (hit by pg_cron every minute)
│ │ ├── check-replies/ # IMAP reply polling (every 5 min)
│ │ ├── campaigns/ # CRUD + recipient import + activity
│ │ ├── senders/ # Gmail sender management
│ │ ├── t/o/, t/c/ # Tracking pixel + click redirect
│ │ └── u/[token] # Unsubscribe
│ ├── campaigns/[id]/ # Campaign detail + edit pages
│ └── senders/ # Sender management UI
├── components/ # React components (CampaignForm, BodyEditor, etc.)
├── lib/
│ ├── supabase.ts # DB client + types
│ ├── mail.ts # nodemailer SMTP
│ ├── replies.ts # IMAP polling
│ ├── warmup.ts # 14-day ramp logic
│ ├── template.ts # {{merge_tag}} + Markdown → HTML
│ ├── crypto.ts # App-password encryption
│ └── tokens.ts # HMAC signing (unsub / tracking)
└── ...
supabase/
├── schema.sql # Tables, indexes, RLS, storage bucket
└── cron.sql # pg_cron + pg_net setup (run ONCE after deploy)
- Auth is Supabase Auth (email + password and Google OAuth). Sessions are JWT cookies managed by
@supabase/ssr— no homegrown session crypto. - User-facing API handlers go through
supabaseUser()(anon key + cookie) so RLS engages. The service-role client (supabaseAdmin()) bypasses RLS and is reserved for cron + tracking pixels + Stripe webhook. - Sender Gmail credentials (app passwords, OAuth refresh tokens) are encrypted at rest with AES-GCM using
ENCRYPTION_SECRET. Never commit.env.local. - HMAC-signed tokens (unsubscribe, tracking pixel, click redirect, OAuth state) prevent tampering. Cron + webhook signatures use
crypto.timingSafeEqualfor constant-time comparison. - Reply HTML rendered with
isomorphic-dompurify— inbound mail is attacker-controlled. - A
/auth/callbackopen-redirect protection rejects protocol-relative + non-slashnextvalues.
MIT — do what you want. Just don't send spam.