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):

  • EmbeddedFnMailer.new(config) inside your application process.
  • Remote — the fn-mailer-server binary 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-ctl manages 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/burst defaults 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)

  1. SPF — TXT record including purelymail's SPF (see their setup docs).
  2. DKIM — enable domain signing in the purelymail dashboard and publish the DKIM records they show.
  3. DMARC — publish a _dmarc TXT policy (start with p=none; rua=...).
  4. Both text and HTML bodies — HTML-only mail scores worse.
  5. 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.
  6. 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

Repository

funcnode-mail

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 7 hours ago
  • July 10, 2026
License

Links
Synced at

Fri, 10 Jul 2026 09:45:27 GMT

Languages