The backend sends transactional email over SMTP. Today that is three messages — the two welcome emails a new tenant's administrator can receive (operator-provisioned and self-serve), and the signup verification code that precedes the self-serve one — but the plumbing is general, and adding another template is a pair of files and a call.
How it fits together
| Piece | Where |
|---|---|
MailService (port, never throws) | backend/src/main/java/com/motorph/payroll/mail/MailService.java |
SmtpMailService (the only implementation) | .../mail/SmtpMailService.java |
MailTemplateRenderer | .../mail/MailTemplateRenderer.java |
MailProperties (motorph.mail.*) | .../mail/MailProperties.java |
MailMetrics | .../metrics/MailMetrics.java |
| Templates | backend/src/main/resources/mail/ |
Spring's own spring.mail.* describes how to reach a relay. motorph.mail.* describes what this
product puts in an envelope. They change for different reasons: swapping Mailpit for a real relay
touches only the former, a new client domain only the latter.
Local development
docker compose up runs Mailpit, a catcher that accepts anything and forwards nothing. Read
what the app sent at http://localhost:8025.
Nothing it receives leaves the machine, which is the point: these emails carry one-time passwords,
and development must not be able to post those to a real address by accident. Only the web UI is
published; SMTP stays on the compose network as motorph_payroll_mailpit:1025.
Running the backend outside Docker (./dev.sh), MAIL_HOST=localhost is correct — inside a
container it is not, because a container's localhost is itself.
Configuration
| Variable | Default (compose) | Notes |
|---|---|---|
MAIL_ENABLED | true | false logs each message and drops it. Defaults to false in application.yml so a fresh clone and mvn test boot with nothing on port 1025. |
MAIL_HOST | motorph_payroll_mailpit | Compose service name, not a container name. |
MAIL_PORT | 1025 | |
MAIL_SMTP_AUTH | false | true for a real relay. |
MAIL_SMTP_STARTTLS | false | true for a real relay. |
MAIL_USERNAME / MAIL_PASSWORD | empty | Relay credentials. |
MAIL_FROM | [email protected] | Must be a domain whose SPF/DKIM you control, or mail is filed as spam. |
MAIL_FROM_NAME | MotorPH Enterprise | |
MAIL_BASE_URL | http://localhost:5173 | Origin links in emails point at. Per-deployment. |
Two configurations are refused at startup rather than accepted quietly, both in
SmtpMailService.validate():
MAIL_ENABLED=truewith an emptyMAIL_HOST. Boot's autoconfiguration triggers on the property being present, so an empty value still builds a sender — one whose every send fails at connect time and is then swallowed by design. That is permanent silent mail loss.MAIL_ENABLED=truewith no sender bean at all.
MAIL_ENABLED=false with no host is fine, and is how CI and the test suite run.
Two settings deliberately not used
spring.mail.test-connection— opens SMTP during startup, which would make a fresh clone, CI and the e2e suite depend on a reachable mail server just to boot.management.health.mail.enabledis set tofalse. The indicator opens an SMTP connection on every health read and reportsDOWNwhen the relay is unreachable. Several runbooks treat/actuator/healthas authoritative, and a stopped mail catcher must not make a healthy deployment look broken.
Templates
Every mail is two files under backend/src/main/resources/mail/:
tenant-welcome.html tenant-welcome.txt <- operator path: carries the temporary password
tenant-welcome-self-serve.html tenant-welcome-self-serve.txt <- self-serve: username + trial end, nothing secret
signup-verification.html signup-verification.txt <- the six-digit code, before anything exists
layout.html layout.txt <- shared wrapper, receives the rendered body
Both bodies are required. A plain-text alternative is what text-only clients, screen readers and most spam filters actually read, and writing it by hand keeps the two saying the same thing.
Substitution is {{token}} replacement, not a template engine — the emails are short and few, and
Thymeleaf would bring a resolver, a dialect and a caching story for the sake of string
interpolation. Three rules keep it safe:
- Values are HTML-escaped in the
.htmlbody and left alone in the.txtbody. Company and person names are free text, so an unescaped value injects markup. - A token the model does not supply is an error, not an empty string — it fails in
MailTemplateRendererTestinstead of mailing a customer a literal{{username}}. - Never write a placeholder inside a comment. Substitution does not know what a comment is, so
it inserts a second invisible copy of the whole message — and a body containing
-->would then break out of the comment. This actually happened;includesTheBodyOnlyOncepins it.
Adding one
- Write
mail/<name>.htmlandmail/<name>.txt. - Call
mailService.send(new MailMessage(to, subject, "<name>", model)). - Add a case to
MailTemplateRendererTest— it renders the real templates, so a token renamed in one place and not the other fails there.
Failure is silent by design
MailService never throws. Every caller is finishing work that has already committed, and an
exception escaping the send would turn a completed action into a 5xx. In the provisioning case it
would also destroy a one-time password permanently, since it is never stored in plaintext.
There is no outbox and no retry, so the counter is the only signal a send was attempted.
MailMetrics registers it as motorph.mail.sends; Micrometer's Prometheus registry renders that as
motorph_mail_sends_total{template,outcome}, which is the name to use in a query or an alert:
rate(motorph_mail_sends_total{outcome="failed"}[15m]) > 0
A rising failed count is how an operator learns customers stopped receiving their credentials.
Production
Mailpit is a development tool. Production stacks do not run it and need a real relay
(Brevo, Resend, SES): set MAIL_HOST, MAIL_SMTP_AUTH=true, MAIL_SMTP_STARTTLS=true and the
credentials, and align SPF/DKIM/DMARC on the MAIL_FROM domain.
Note that Hetzner blocks outbound port 25, so self-hosted direct sending is not an option there —
a relay on 587 is required. docker-compose.client.template.yml deliberately does not include
Mailpit.
Known limitation: the password travels in the body
The welcome email carries a temporary password. That is a deliberate step down from ADR-0004's posture, which exists so a password is never recoverable — mailed, it comes to rest in a mailbox indefinitely and transits every intermediate relay.
It is still an improvement on an operator reading it off a screen and relaying it by hand, and
mustChangePassword means it buys exactly one sign-in. The intended replacement is a single-use,
expiring invite token: the email carries a link, the owner chooses the password, and nothing
secret is ever at rest in a mailbox.
This applies only to the operator path now. Self-serve signup already works the better way
round: the person chooses their own password in the form, and the email it sends carries a
six-digit verification code and nothing secret at all — see
self-serve-signup.md. SignupVerificationService is most of the machinery
an operator invite token would need; what it does not have is persistence, because a code that
lives fifteen minutes does not warrant a table and an invite that lives days would.
Until then, note that in development every such password is readable by anyone who can reach Mailpit's UI on port 8025, which is unauthenticated.