Mailer
The mailer renders typed email templates, serializes them, and hands them to the scheduler as one-time tasks. SMTP delivery happens asynchronously from the request that triggered the email; if delivery fails, the scheduler retries.
In dev, an SMTP testing server (Mailpit) catches everything in a web UI so you can see what your users would see without sending real mail.
The flow
Section titled “The flow”service: mailer.send(to, template, lang) │ ▼ serialize(template.render(ctx, lang)) → SerializedMail │ ▼ schedulerClient.schedule(sendMailTask.instance(uuid, serialized, at)) │ │ (returned to caller as IO[Boolean]) ▼ scheduled_task row inserted ⋮ scheduler picks it up (async) │ ▼ sendMailTask.execution(task) → smtpSender.send(payload) │ ▼ SMTP delivery (or retry)The send call returns once the row is in the queue. Delivery is the scheduler’s problem. This means email sending is “at-least-once” with retries, not “synchronous and confirmed” — design accordingly.
Pieces
Section titled “Pieces”| File | What it does |
|---|---|
Mailer |
Public API. send / sendInSession / sendTransactionally. Owns sendMailTask. |
EmailTemplate |
Trait with one method: render(ctx, lang): RenderedMail. Templates live in feature modules. |
RenderedMail / MailBody |
The intermediate output: subject, text/html body, optional inline attachments. |
SerializedMail / SerializedMailBody |
Wire format the scheduler row stores. HTML tags get rendered to a string at serialization. |
SmtpSender |
Wraps Jakarta Mail. Talks to the configured SMTP host. |
MailerConfig |
host / port / credentials / from address / TLS — pureconfig-loaded from mailer.*. |
MailPreview / MailPreviewRouter |
Dev-mode /admin/mail-previews UI for rendering templates without sending. |
Templates
Section titled “Templates”Templates are plain classes that take their data in the constructor and produce a RenderedMail. The bodies are scalatags — Scala-native HTML DSL, type-checked, no string concatenation:
class OutbidEmailTemplate( wineName: WineName, newBidAmount: Price, currency: Currency) extends EmailTemplate { def render(ctx: MailContext, lang: Language): RenderedMail = lang match { case Language.En => RenderedMail( subject = s"You've been outbid on $wineName", body = MailBody.Html( html( head(tag("style")(raw(""" body { … } """))), body( div(cls := "container")( p("New high bid on ", span(cls := "highlight")(wineName.toString), "."), a(cls := "button", href := ctx.baseUrl.toString)("Place a New Bid") ) ) ) ) ) }}
object OutbidEmailTemplate { val preview: MailPreview = MailPreview("outbid-notification", OutbidEmailTemplate(WineName("Château Margaux 2015"), Price(BigDecimal(350)), Currency.getInstance("EUR")))}Three things to know:
- Subject and body together.
RenderedMailis the unit of templating. Don’t render subject and body separately — the subject is part of the template’s responsibility. MailBodyhas three shapes:Text,Html, andBoth. Most templates useHtml.Bothis for ensuring text-only mail clients still get readable content.ctx: MailContextcarries application-level data every template needs: currently justbaseUrl(for absolute links). Add fields here if you have data that’s universally useful across templates.
The companion’s preview: MailPreview is what makes the template show up in the dev preview UI. Always provide one — see “Previews” below.
Look and feel
Section titled “Look and feel”Templates don’t carry their own CSS. EmailLayout (in madrileno.utils.mailer) owns the shell — canvas, card, wordmark header, heading, CTA button, footer — so every email looks the same and a restyle happens in one place:
EmailLayout.page( "You've been outbid!", p("Someone has placed a higher bid on ", EmailLayout.highlight(wineName.toString), "."), EmailLayout.cta(ctx.baseUrl.toString, "Place a New Bid"))Its palette mirrors the reference frontend’s design tokens (src/styles/tailwind.css), resolved to hex — email clients support neither oklch nor CSS variables. Rebrand by editing the constants at the top of EmailLayout; keep them in step with the frontend’s tokens so mail and app stay recognisably the same product. The emails are deliberately light-only (color-scheme: light) rather than mirroring the frontend’s dark mode, since client dark-mode handling is inconsistent.
Sending
Section titled “Sending”Mailer exposes three send variants, mirroring the DB / DBInTransaction distinction:
class Mailer(...) { def send(...): IO[Boolean] // own session, fire-and-forget def sendInSession(...): DB[Boolean] // join an existing session def sendTransactionally(...): DBInTransaction[Boolean] // join an existing tx}Use sendTransactionally when the email enqueue must be atomic with the database state change that triggered it. The auction notifyOutbid flow does this — the email gets queued in the same transaction as the bid row write, so a transaction rollback also rolls back the email:
transactor.inTransaction { for { _ <- bidRepository.save(newBid) _ <- mailer.sendTransactionally( to = List(previousHighest.bidder.email), template = OutbidEmailTemplate(auction.wineName, newBid.amount, auction.currency), lang = Language.En ) } yield ()}Optional parameters:
at: Option[Instant]— schedule for a specific time.in: Option[FiniteDuration]— schedule relative to now.from: Option[String]— overridesmailer.from-address.cc: List[String],bcc: List[String],replyTo: Option[String]— standard SMTP headers.attachments: List[Attachment]— files attached to the email.
If both at and in are provided, at wins.
How it actually delivers
Section titled “How it actually delivers”Mailer constructs one OneTimeTask[SerializedMail] named "send-mail" and registers it via oneTimeTasks: List[OneTimeTask[?]]. Every send call schedules a fresh instance:
val sendMailTask: OneTimeTask[SerializedMail] = Task.oneTime(TaskDescriptor[SerializedMail]("send-mail")) { task => logger.info(s"Sending email to ${task.payload.to.mkString(", ")}: ${task.payload.subject}") *> smtpSender.send(task.payload) }Failures bubble up to the scheduler, which retries with exponential backoff. After max-retries (configurable, no cap by default) the row is dropped with an error log. See scheduler.md.
The serialized payload includes the rendered HTML and all attachments base64-encoded into the row. Don’t put 50 MB attachments in emails — the row holds the whole thing in jsonb until delivery.
Configuration
Section titled “Configuration”mailer { host = ${MAILER_HOST} # required port = 587 username = ${?MAILER_USERNAME} # optional (omit for unauth dev SMTP) password = ${?MAILER_PASSWORD} from-address = "noreply@example.com" # default sender from-name = "App" # display name tls = true # STARTTLS}In dev (docker compose up -d), Mailpit runs on localhost:51025 (SMTP) with no auth and TLS off. The default .env matches.
For production, point at SES / SendGrid / your provider’s SMTP relay. The mailer doesn’t ship API-based delivery (e.g., SES native API) — if you need that, drop a different SmtpSender implementation in.
Previews
Section titled “Previews”In dev (app.environment = "dev"), /admin/mail-previews lists every MailPreview from every module’s mailPreviews: List[MailPreview], with its rendered HTML and text shown side-by-side. Edit a template, refresh the page, see the result without sending.
To register a preview, add it to your module’s mailPreviews:
override abstract def mailPreviews: List[MailPreview] = super.mailPreviews :+ OutbidEmailTemplate.preview :+ AuctionClosedEmailTemplate.sellerPreviewThe preview value usually lives on the template’s companion as a val preview: MailPreview constructed with realistic example data. Make the example data realistic — long names, currencies with awkward symbols, edge-case prices — so the preview catches layout bugs.
Internationalisation
Section titled “Internationalisation”Language is a Scala 3 enum. Right now it’s case En. Adding more languages means three things:
- Adding a case (
case De,case Fr). - Pattern-matching exhaustively on it in every
EmailTemplate.render. Compiler tells you what you forgot. - Threading the user’s preferred language from somewhere (User row, request header) into the
mailer.send(... , lang = …)call.
The pattern-match-on-enum approach scales to a handful of languages. If you have dozens, you’d want a typesafe message-bundle library — out of scope for the template.
Testing
Section titled “Testing”TestMailpit (mixed into specs that need real SMTP) starts Mailpit in a testcontainer and exposes its host/port. MailerConfig for the test points there; the test sends a real email through the mailer + scheduler path, then queries Mailpit’s HTTP API to read what landed.
For unit tests of templates themselves, just call template.render(MailContext(testBaseUrl), Language.En) and assert on the RenderedMail.
Where to look next
Section titled “Where to look next”- scheduler.md —
sendMailTaskis aOneTimeTask; everything in that doc applies (retries, idempotency, the admin UI). - http.md — the dev-only
/admin/mail-previewsis one of the routes ApplicationLoader gates behindappConfig.environment == Environment.Dev.