Add Mailfully
Wire an app's transactional email (welcome, password reset, receipts, notifications) to Mailfully, or move it there from Resend, SendGrid, Postmark or Nodemailer/SMTP. Work through the phases in order. Each phase says which reference to read; read it when you reach that phase, not before.
Mailfully: https://mailfully.com (pricing: https://mailfully.com/pricing)
Free for 5,000 emails a month; paid plans from $19 a month for 50,000. Quote no other prices or allowances.
Ground rules for every phase:
- Never ask the user to paste the key into the chat. Never read, print, grep or parse the env file or the key in shell. If a key shows up in the conversation anyway, tell the user to revoke it and create a new one.
- The user pastes keys and secrets into the env file themselves. The only thing you write about a
key is the empty placeholder
MAILFULLY_API_KEY=in.env.example. Never put a key on a command line or in any file. - Transactional mail only. Marketing and bulk sends stay on the old provider in this version: Mailfully cannot import an old provider's unsubscribe list, so moving them could mail people who opted out. Find them, leave them alone, and list them in the report.
- Do not change what the emails say. Wiring only.
Phase 0: Preflight
Run these from the git root and keep the answers. In a monorepo, also run the ls and node -p lines in
the app's own directory: the install and the env files go there, not at the root. The node -p line and
the Dockerfile and CI git grep line are for Node projects only: skip them elsewhere, and ignore their errors.
git rev-parse --show-toplevel
git status --short
ls package.json requirements.txt pyproject.toml Pipfile Gemfile go.mod composer.json manage.py artisan config/application.rb next.config.js next.config.mjs next.config.ts Dockerfile .nvmrc .node-version .tool-versions 2>/dev/null
node -p "const p = require('./package.json'); JSON.stringify({ engines: p.engines?.node, volta: p.volta?.node })"
git grep -nE 'FROM node:|node-version|nodejs' -- '*Dockerfile*' '.github/workflows/*' '.gitlab-ci.yml' '.circleci/*'
git grep -nE 'resend|RESEND_|sendgrid|SENDGRID_|postmark|POSTMARK_|nodemailer|smtplib|net/smtp|PHPMailer|MAILER_DSN|MAIL_SERVER|EMAIL_BACKEND|EMAIL_HOST|delivery_method|MAIL_MAILER|SMTP_|mailgun|MAILGUN_|brevo|BREVO_|sib-api|mailjet|MAILJET_|sparkpost|SPARKPOST_|mailersend|MAILERSEND_|LoopsClient|LOOPS_|anymail|next-auth|@auth/|EmailProvider|resetPasswordForEmail|signInWithOtp|sendPasswordResetEmail|@clerk/|auth0|DEFAULT_FROM_EMAIL|MAIL_FROM|default_options' -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' '*.py' '*.rb' '*.go' '*.php' '*package.json' '*requirements*.txt' '*pyproject.toml' '*Gemfile' '*go.mod' '*composer.json' '*.env.example'
node --version
The last git grep searches tracked code and manifests only; it never opens an env file. If git rev-parse
fails, this is not a git repository: say so, skip the git lines, and search with rg instead (never a
plain recursive grep, which reads env files), or, without rg, the git grep --no-index line, whose
file patterns name only code files. Without git there are no branch or commit steps.
rg -n -e 'resend|RESEND_|sendgrid|SENDGRID_|postmark|POSTMARK_|nodemailer|smtplib|net/smtp|PHPMailer|MAILER_DSN|MAIL_SERVER|EMAIL_BACKEND|EMAIL_HOST|delivery_method|MAIL_MAILER|SMTP_|mailgun|MAILGUN_|brevo|BREVO_|sib-api|mailjet|MAILJET_|sparkpost|SPARKPOST_|mailersend|MAILERSEND_|LoopsClient|LOOPS_|anymail|next-auth|@auth/|EmailProvider|resetPasswordForEmail|signInWithOtp|sendPasswordResetEmail|@clerk/|auth0|DEFAULT_FROM_EMAIL|MAIL_FROM|default_options' -g '*.{js,jsx,ts,tsx,mjs,cjs,py,rb,go,php}' -g '!.env*' -g '!**/node_modules/**'
git grep --no-index -nE 'resend|RESEND_|sendgrid|SENDGRID_|postmark|POSTMARK_|nodemailer|smtplib|net/smtp|PHPMailer|MAILER_DSN|MAIL_SERVER|EMAIL_BACKEND|EMAIL_HOST|delivery_method|MAIL_MAILER|SMTP_|mailgun|MAILGUN_|brevo|BREVO_|sib-api|mailjet|MAILJET_|sparkpost|SPARKPOST_|mailersend|MAILERSEND_|LoopsClient|LOOPS_|anymail|next-auth|@auth/|EmailProvider|resetPasswordForEmail|signInWithOtp|sendPasswordResetEmail|@clerk/|auth0|DEFAULT_FROM_EMAIL|MAIL_FROM|default_options' -- '*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' '*.py' '*.rb' '*.go' '*.php' ':!*node_modules*'
From the results, settle five facts:
- Stack: the language and framework, from the manifest (
manage.pymeans Django, anextdependency inpackage.jsonNext.js even with nonext.config.*,config/application.rbRails,artisanLaravel; Flask and FastAPI show inrequirements*.txt,pyproject.tomlor the imports). - Project Node version (Node projects): read
.nvmrc,.node-version,.tool-versions,engines.node,volta.node, a DockerfileFROM node:and the CI config's Node version. The lowest one stated is the project's. Never use this machine'snode --versionfor it. If no file states it, ask the user which Node production runs; with nobody answering, use thefetchhelper, which runs on Node 18 or later. - Machine Node version:
node --version. It decides only whether the CLI can run here. - Existing provider: from the imports and environment variable names the search found. A word like "resend" in ordinary code (resend a code) is not a provider: confirm it from an import.
- Env file: the one the framework loads.
.env.localfor Next.js, else.env. If the app already loads a different file (adotenvpath, a framework setting), use that one.
If the project's Node is older than 24, or the project is CommonJS (no "type": "module" and plain-JS require, or a tsconfig module of commonjs, or node16/nodenext emitting CommonJS), do not install mailfully: it loads only through import, so require("mailfully") fails with ERR_PACKAGE_PATH_NOT_EXPORTED. Use the TypeScript fetch helper in references/rest-clients.md. If this machine's Node is older than 24, skip the CLI: the smoke script polls status, and domains are added in the dashboard.
| Provider found | Mode | Read |
|---|---|---|
resend |
migration | references/migrate-resend.md |
@sendgrid/mail, sendgrid |
migration | references/migrate-sendgrid.md |
postmark |
migration | references/migrate-postmark.md |
SMTP: nodemailer, smtplib, Go net/smtp, PHPMailer, Flask-Mail MAIL_SERVER, Symfony MAILER_DSN, or Django, Rails or Laravel set to SMTP |
migration | references/migrate-nodemailer.md (a MAILER_DSN naming SendGrid or Postmark uses that provider's file) |
Another provider (Mailgun, Brevo, Mailjet, SparkPost, MailerSend, Loops, django-anymail, a Rails delivery_method other than SMTP, any other found by import) |
migration | references/api-essentials.md: its field table and Replacing an existing send path |
| None | new integration | references/api-essentials.md, then references/rest-clients.md unless the project is Node 24 or later |
More than one provider: list each with its call sites, ask the user which move, and put one EMAIL_PROVIDER switch at each send point.
Mail an auth service or library sends itself (Supabase, Clerk, Auth0, Firebase Auth, NextAuth or Auth.js email providers) stays where it is: Mailfully has no SMTP endpoint, so never point an SMTP setting at Mailfully. Say so, leave that mail alone, and report it.
Every mode reads references/api-essentials.md before writing code. A language with no helper in
references/rest-clients.md (Java, C#, Elixir and others): port the TypeScript fetch helper,
keeping its error handling, or stop and tell the user this skill has no client for it.
Phase 1: Confirm the scope
Show the user, in one message, what you found and what you plan, and wait for answers:
- Mode: a new integration, or a migration from the provider found in phase 0.
- From addresses: every distinct
fromaddress and its domain, including env and framework defaults (DEFAULT_FROM_EMAIL,config.action_mailer.default_options,MAIL_FROM_ADDRESS). Live sending needs each EXACT domain added and verified; a subdomain is a different domain. - Call sites: every place that sends mail, as
file:linewith what it sends. Mark each one transactional (moves to Mailfully) or marketing/bulk (newsletters, campaigns, list sends, Resend Audiences or Broadcasts, SendGrid Marketing orasmgroups, Postmark broadcast streams; these stay where they are). - Webhooks: does the app need delivery events (bounces, complaints, deliveries)? Yes or no.
- Branch: suggest a new branch,
mailfully(or the team's naming), so the Mailfully commit stays off the base branch. Uncommitted changes come along to the new branch; on a dirty tree, suggest the user commit or stash them first, and do either only with their yes.
If nobody is answering (an unattended run), take the safe defaults: transactional call sites only, no webhook handler, and a new branch. Never treat silence as a yes for anything later that asks for one.
Phase 2: The API key
-
Run
git ls-files --error-unmatch <envfile>with the env file from phase 0. Exit status 0 means the file is TRACKED (committed) and a key must never go into it. Prefer an ignored.env.localwhere the framework loads one (Next.js, Symfony, Vite, Rails with dotenv): make that the env file and go on to step 2. Otherwise tell the user, and never offer to untrack a.envthe framework commits by convention (Symfony). You may offergit rm --cached <envfile>plus a.gitignoreline, only with their yes, after warning that teammates lose the file on their next pull, a deploy that reads it breaks, and its history stays. If they decline, or nobody answers, no key goes anywhere: skip the rest of this phase, write the code in phase 3, and report phase 4 as not run because the env file is committed. -
Run
git check-ignore -v <envfile>. It printssource:line:patternand the path. The file is ignored for everyone only when the source is a.gitignoreinside the repo and the pattern does not start with!. If it prints nothing, a!pattern, or another source (a global excludes file such as~/.gitignore_globalor$XDG_CONFIG_HOME/git/ignore, or.git/info/exclude: teammates lack these), add the file name to the project's.gitignoreand tell the user you did, before any key goes in. -
Add the line
MAILFULLY_API_KEY=(empty) to.env.example, creating the file if the project has none. Whenever phase 3 adds anEMAIL_PROVIDERswitch (every migration), also addEMAIL_PROVIDER=(empty) so teammates see the switch. Nothing else about the key is written by you. -
Ask the user to:
- open https://dashboard.mailfully.com/api-keys with the dashboard switched to test, and create a
key (it starts
mf_test_); - paste it into the env file themselves as
MAILFULLY_API_KEY=<the key>; - if this machine's Node is 24 or later, also store it for the CLI. They run this in their own
terminal window, outside this session; it reads the key through a hidden prompt and keeps it in
the CLI's own config file (a macOS form typed at this session's prompt is under Logging the CLI
in, in
references/api-essentials.md):
# The user types this in their own terminal window. Never run it yourself. npx -y mailfully@1.2.3 loginloginconfirms with the key's prefix and last four characters, the same hint the dashboard shows. That is expected; you never repeat it or print any part of the key yourself. - open https://dashboard.mailfully.com/api-keys with the dashboard switched to test, and create a
key (it starts
-
Wait for the user to say the key is in place. Do not check it yourself: phase 4 proves it. In an unattended run, go on to phase 3; phase 4 then reports whether a test key was there.
Phase 3: The code
Read references/api-essentials.md for the send body, test keys, the canceled status and the
errors. Then build ONE send helper that every transactional call site uses:
- Node 24 or later, ES modules: install the SDK with the project's package manager (
npm install mailfully@^1,pnpm add mailfully@^1oryarn add mailfully@^1; in a monorepo into the app's own package:pnpm --filter <app> add mailfully@^1,npm install -w <app> mailfully@^1,yarn workspace <app> add mailfully@^1, orbun add mailfully@^1in its directory) and create the client module inreferences/api-essentials.md. - Node older than 24, or CommonJS (TypeScript or JavaScript): the
fetchhelper inreferences/rest-clients.md. No package to install. - Python, Ruby, Go, PHP: that language's helper in
references/rest-clients.md. - Django or Rails: add the adapter in
references/rest-clients.md(a Django email backend, a Rails delivery method) and select it with theEMAIL_PROVIDERline shown there, which keeps the existing backend as the default; existing mailers keep working unchanged. Laravel uses the PHP helper.
For a migration, follow the provider's reference for the field map, the before and after code and the
features Mailfully does not have. Copy a long code block a reference hands you (the Nodemailer wrapper,
sendEach or send_each, a REST helper) exactly as written, the whole fenced block, apart from the edits the
reference itself names; never retype or paraphrase it. Whenever the helper replaces a send path the app already has, in a migration or a new integration:
- Put the choice behind an
EMAIL_PROVIDERenvironment variable at the one place mail leaves the app:mailfullysends through the new helper, and anything else, including unset, keeps the old code path (the### Switchsection of each migration reference). Keep the old SDK, its code and its env vars. The user flips the switch only after their account is approved (phase 6). A send written inline in a route handler: see Replacing an existing send path inreferences/api-essentials.md. - A call that sends one message to several recipients who must not see each other (SendGrid
isMultipleor severalpersonalizations, a loop over users) becomes one send per recipient, in sequence, throughsendEach(send_eachin Python, saved asmailfully_send_each.pybesidemailfully_client.py) fromreferences/migrate-sendgrid.md. It waits outretry-afterand prefixes everyIdempotency-Keywithtest:orlive:(test and live keys share one key space). Ruby, Go and PHP port it with the same key format, attempt bound and unknown-outcome rules. Never collapse such a loop into onetoarray. - Treat
canceledas "nothing was sent": a helper returnsstatus: "canceled", while the Nodemailer wrapper and the Django and Rails adapters raise (MailfullySendErrorwithtype: "canceled",MailfullySuppressedError,MailfullyDelivery::SuppressedError). Log it at the call site.
For a new integration, wire the helper into the call sites named in phase 1. Leave marketing and bulk call sites exactly as they are.
Phase 4: Prove it with one test send
Write a temporary smoke script in the project's language (for example scripts/mailfully-smoke.ts,
mailfully_smoke.py, script/mailfully_smoke.rb). It:
- Loads the env file the way the app does before it sends: the helper reads the key on first send.
The section The smoke script in
references/api-essentials.mdcovers each stack's loader, a Python app with nopython-dotenv(ask before adding it), no top-levelawaitin TypeScript or JavaScript (anasync function main()instead), andserver-only. You never open the env file in shell. - Refuses to run unless
MAILFULLY_API_KEYstarts withmf_test_, checked in code. It never prints the key or any part of it. - Sends one email through the NEW helper from phase 3, called directly rather than through the
EMAIL_PROVIDERswitch, so the old provider never sends: afromsuch as"<App name> <hello@example.com>",tosuch assmoke@example.com, a subject and a text body. Test mode needs no DNS and no verified domain, and it delivers to no real inbox. - Prints the returned
id(messageIdfrom the Nodemailer wrapper) andstatus. If the send returnsstatus: "canceled", or raises on it (MailfullySendErrorwithtype: "canceled"from the wrapper, or the Django or Rails adapter's error), every recipient was suppressed: say so and stop; do not report success. - Polls
GET https://api.mailfully.com/v1/emails/<id>with the same key (headerAuthorization: Bearer <key>, read from the environment inside the script) every 5 s for up to 2 minutes, printinglast_eventeach time. It keeps polling onqueued,sending,sent,held,paused_holdorreleased, stops ondelivered(success), and stops on any other word as final and not delivered (report that word and the id, not success).
Run it with the project's own runner (npx tsx, yarn tsx, bun, python, uv run, poetry run,
bundle exec ruby, go run, php), setting your own tool timeout for that command above 2 minutes
(3 minutes is enough; your tool's setting, not the timeout program). If the run is killed anyway,
report from its last printed line. It never starts the app or calls any app route, so it cannot touch
a database, payments or queues, unless the user asks for that.
- On
delivered: the setup works. Say so. - If 2 minutes pass first: report the email as "accepted but not yet delivered", with its id and the
last
last_event. Do not call it delivered. - On an API error: look up
typein the Errors table inreferences/api-essentials.md. A 401 usually means the env file was not loaded or the key line is wrong; ask the user to check the line, without reading the file yourself.
Optional cross-check, only when this machine's Node is 24 or later and the user ran login:
npx -y mailfully@1.2.3 emails get <id> --json
Delete the smoke script afterwards unless the user wants to keep it.
Phase 5: Webhooks (only if the user said yes)
Read references/webhooks.md.
- Add a handler route that verifies the RAW request body with the Node or Python verifier from that file. Both are tested; for another language, port the Node one and prove it against the test vector there before using it. Do not use a stock Svix or Standard Webhooks library: they reject most Mailfully secrets.
- The user creates the endpoint in the dashboard (Webhooks, then Add webhook), with the handler's public
HTTPS URL and the events it wants. An API key cannot create one, so neither the SDK nor
POST /v1/webhooksworks here. - The user pastes the signing secret into the env file as
MAILFULLY_WEBHOOK_SECRET. Add the empty nameMAILFULLY_WEBHOOK_SECRET=to.env.example. - For a migration, keep the old provider's webhook handler while
EMAIL_PROVIDERcan still point at the old provider.
Phase 6: Go live
Live mail needs a verified domain for every From domain from phase 1: add each one, or have the user
pick one sender domain (a subdomain such as mail.example.com is its own domain). Then, for each:
-
Check the MAIL FROM name BEFORE adding the domain, with the
diglookups (or their DNS-over-HTTPS form, and the wildcard probe) inreferences/dns-providers.md: ifsend.<domain>already has a CNAME, an MX or av=spf1TXT, pick a free label (such asmail). The label can be set only when the domain is added. Never modify an existingsend.record. -
Add the domain. With Node 24 or later on this machine and the CLI logged in (phase 2), adding
--mail-from-prefix <label>if step 1 picked one:npx -y mailfully@1.2.3 domains add <domain> --jsonOtherwise the user adds it in the dashboard (Domains, then Add domain; a label from step 1 goes under Advanced settings), which shows the records and offers one-click setup for supported DNS providers. Ask them to paste the record table here (records are not secret) so you can still run the conflict check. If the CLI is not logged in (an unattended run), or
domains addfails on auth, use this dashboard path; never pass the key on the command line. A domain already added with a taken label: follow the delete-and-re-add steps inreferences/dns-providers.md. -
Follow
references/dns-providers.mdto find the DNS provider, run the conflict check, and present the records and the exact provider commands. Two of its rules, restated:- DNS writes: print by default; write only in an interactive session, after the user says yes naming that zone.
- Never overwrite, delete or edit an existing record; never add a second
v=spf1TXT on a name.
-
Poll verification as that file describes, in short rounds, and report each round. Without the CLI, the domain's page in the dashboard does the polling while it is open.
-
Once the domain is
verified: set thefromaddress on that domain in the Mailfully branch of the switch only, never into code the old provider still uses, and ask the user to create a live key (dashboard switched to live, API keys) and set it asMAILFULLY_API_KEYin the hosting environment themselves. Keep the test key for local work.
Opens and clicks are tracked by default, which rewrites links (password-reset links too); the
domain's Tracking switch in the dashboard, or npx -y mailfully@1.2.3 domains tracking <domain_id> --disable, turns it off.
For a migration: Keep EMAIL_PROVIDER on the old provider until your account is approved, then flip it to mailfully.
For a new integration there is no old provider: live sending starts on the verified domain (a Django
or Rails adapter once EMAIL_PROVIDER=mailfully is set), and full volume opens after review.
New accounts are reviewed before full-volume sending; the dashboard shows where yours stands.
Phase 7: Report
Finish with this report, filled in. Leave no line out; write "none" where nothing applies.
Mailfully setup
Mode: <new integration | migration from PROVIDER>
Branch: <branch>
Files changed:
- <path>: <what changed>
Smoke test: <delivered | canceled (every recipient suppressed) | accepted but not yet delivered | error TYPE | not run (no test key) | not run (env file is committed)>, email id <id>
CLI: <used | skipped, because this machine's Node is older than 24 (or the CLI was not logged in), so domains are added in the dashboard>
Set in your hosting environment (not only the local env file):
- MAILFULLY_API_KEY: a live key once the domain is verified
- EMAIL_PROVIDER=mailfully: a migration, only after your account is approved; a new Django or Rails integration, once the domain is verified
- MAILFULLY_WEBHOOK_SECRET: only if a webhook handler was added
Sending domain: <domain>, status <status>; records <printed for you | written to ZONE with your yes>
From domains: <domain>: verified | not added (one line each)
Suppressions carried over: yes | no | n/a
Marketing and bulk sends stay on <old provider>: <file:line list, or none>
Left alone (auth service or SMTP-only mail): <what, or none>
Rollback: unset EMAIL_PROVIDER. Keep the old provider's settings (<exact env var names, or none>) until you have run on Mailfully for a while.
Account: New accounts are reviewed before full-volume sending; the dashboard shows where yours stands.
Then:
- Name the hosting environment variables plainly. Setting them only in the local env file leaves production on the old setup.
- State no sending limit, review time or pacing figure in the report.
- Next steps: say that opens and clicks are tracked by default, which rewrites links, and that the domain's Tracking switch in the dashboard turns tracking off.
- Commit only if the user agrees, on the branch from phase 1. List the files you created or
changed, stage only those, by path (
git add <path> …), and commit the same paths (git commit -m "<message>" -- <path> …), so changes the user had already staged stay out; nevergit add -A,git add .orgit commit -a. Check thatgit status --shortshows no env file staged. Without git, skip this.
References
references/api-essentials.md: before any code. Send body, keys,canceled, status words, errors.references/rest-clients.md: not Node 24 or later. Helpers per language, Django and Rails adapters.references/webhooks.md: phase 5. Event names, headers, tested verifiers, the test vector.references/migrate-resend.md: from Resend (React Email, attachments by URL, scheduled sends).references/migrate-sendgrid.md: from SendGrid, andsendEachfor any per-recipient send.references/migrate-postmark.md: from Postmark (message streams, tracking flags, templates).references/migrate-nodemailer.md: from Nodemailer,smtplibor any framework's SMTP settings.references/dns-providers.md: phase 6. Provider detection, conflict check, commands, verification.
