funcnode-mail
funcnode-mail (FnMailer)
Mail sending for Crystal web services: several mailboxes of one domain, persistent file spool, retries with backoff, rate limiting, bulk campaigns, IMAP bounce monitoring, suppression list, Crystal and HTTP callbacks — as an embedded library or as a standalone server with a binary protocol.
Fully thread-safe; works under -Dpreview_mt.
Two deployment modes, one interface
Both implement Funcnode::Mail::MailerApi (+ AdminApi via #admin):
- Embedded —
FnMailer.new(config)inside your application process. - Remote — the
fn-mailer-serverbinary on a mail host +Client.new(...)in the application. Protocol: TCP (optional TLS), MessagePack frames, request multiplexing, server-pushed events, token auth (separate send and admin tokens).fn-mailer-ctlmanages the server from the terminal.
Switching between modes is one initialization line.
Quick start (embedded)
require "funcnode-mail"
alias Mail = Funcnode::Mail
# Missing file -> a commented template is created and ConfigError is raised:
# fill in credentials and restart. Invalid config also raises — the app
# must not start with broken mail settings.
mailer = Mail::FnMailer.new("config/mail.yml")
send_id = mailer.send(Mail::Message.new(
from: "no-reply", # mailbox key from the config
to: Mail::Address.new("user@example.com"),
subject: "Confirm your email",
text_body: "Follow the link: ...",
idempotency_key: "confirm:42")) # repeated send returns the same id
mailer.send_sync(message) # block until the SMTP server accepts
mailer.send(message, reliability: Mail::Reliability::Guaranteed)
mailer.send_bulk(messages, title: "March newsletter") # campaign with throttle
mailer.status(send_id) # DeliveryStatus
mailer.on_event { |event| ... } # queued/accepted/failed/bounced/...
mailer.admin.health # AdminApi: stats, pause, dead-letter...
mailer.close # graceful drain at shutdown
See examples/ for runnable programs and the generated config template for every setting with comments.
Reliability tiers
| Tier | Behavior |
|---|---|
fire_and_forget |
one attempt, no retries |
tracked (default) |
persisted, events, retries with backoff up to max_attempts |
guaranteed |
like tracked, but attempts exhausted → dead-letter (manual retry_dead) |
Priorities are automatic: 1 recipient — transactional, ≤100 — group, >100 / send_bulk — bulk. Transactional mail never waits behind a campaign.
What "delivered" means
SMTP can only confirm that the receiving server accepted a message (state accepted). Real mailbox delivery failures come back later as bounce mail — the bounce monitor polls the sending mailbox over IMAP, matches DSN reports to sends by the X-FnMailer-Id header, flips them to bounced and puts hard-bounced recipients on the suppression list automatically.
Remote mode
On the mail host:
bin/fn-mailer-server config.yml # config must contain the server: section
In the application (pulls no SMTP/IMAP/spool code):
require "funcnode-mail/client"
mailer = Funcnode::Mail::Client.new("10.0.0.5", 9925, token: "SEND_TOKEN")
Operations:
bin/fn-mailer-ctl --host 10.0.0.5 --token ADMIN_TOKEN health
bin/fn-mailer-ctl ... stats | campaigns | queue | dead | suppression | config
bin/fn-mailer-ctl ... pause [mailbox] | resume | cancel-campaign <id>
bin/fn-mailer-ctl ... retry-dead | drop <id> | suppress <email> | send-test <mailbox> <to>
bin/fn-mailer-ctl ... watch # live event stream
Purelymail notes
- External sending is limited to ~3000 messages/day (~300 at once) per account; the
daily_budget/burstdefaults leave headroom. Contact their support to raise limits, or plug a dedicated bulk provider in later — the transport abstraction keeps application code unchanged. - Purelymail has no send API and no delivery webhooks: sending is SMTP (
smtp.purelymail.com:465, smtps), bounce detection is IMAP (imap.purelymail.com:993).
Deliverability checklist (before sending real mail)
- SPF — TXT record including purelymail's SPF (see their setup docs).
- DKIM — enable domain signing in the purelymail dashboard and publish the DKIM records they show.
- DMARC — publish a
_dmarcTXT policy (start withp=none; rua=...). - Both text and HTML bodies — HTML-only mail scores worse.
- Bulk mail: set
bulk.list_unsubscribe— Gmail/Yahoo require List-Unsubscribe + one-click (RFC 8058) from bulk senders; the library adds the headers automatically for campaigns. - Never mail suppressed addresses — the library enforces this; keep the suppression list intact between deployments (it lives in the spool).
Storage
Persistence is a directory spool (spool.path): queued messages with bodies and attachments, dead letters, sent-mail metadata (cleaned after sent_retention_days), suppression list, daily counters, campaigns. All writes are atomic (tmp + rename); after a crash, in-flight messages return to the queue on startup. The spool belongs to one process — when scaling to several instances give each its own spool and a share of daily_budget. The Store interface is abstract: a database/S3 adapter can be added without touching the rest of the library.
Development
crystal spec # + -Dpreview_mt, --no-debug variants
crystal tool format
shards build # fn-mailer-server, fn-mailer-ctl
Windows note: shards install needs Developer Mode (symlinks). The dependencies are arcage/crystal-email and crystal-community/msgpack-crystal.
Installation
dependencies:
funcnode-mail:
github: your-github-user/funcnode-mail
Contributors
- Vasily Buter - creator and maintainer
funcnode-mail
- 0
- 0
- 0
- 0
- 2
- about 7 hours ago
- July 10, 2026
Fri, 10 Jul 2026 09:45:27 GMT