Lunogram

Sending mail

How Lunogram sends its own transactional mail — the SMTP and webhook channels, and overriding the messages

Lunogram sends four messages of its own:

MessageSent whenTemplate keykind
Reset your passwordsomebody asks for a reset linkpassword_resetpassword.reset
You already have an accountsomebody registers an address that is already takenaccount_existsaccount.exists
Your password was changeda password is changed or resetpassword_changedpassword.changed
You have been inviteda project invitation is createdproject_inviteproject.invite

These are the platform's mail, not a project's — the first admin registers before any project exists, so they cannot depend on a project's configured email provider.

This is separate from the email your projects send. Campaign and journey mail goes through a project's configured provider (Resend, Mailgun, SendGrid, SES…) with templates edited in the console. Nothing on this page affects it.


Choosing a channel

mail:
  channel: smtp        # or: webhook

There is no third value, and no fallback that writes messages to the log instead of sending them. A deployment offering password logins with nowhere to send mail cannot reset a password or deliver an invitation, so it is refused at boot rather than at the first request.

In development, docker compose up runs Mailpit and points the platform at it. Every message is readable at http://localhost:8025; nothing leaves the machine.

SMTP

mail:
  channel: smtp
  from:
    address: no-reply@example.com
    name: Example
  smtp:
    host: smtp.example.com
    port: 587
    username: lunogram
    password: "${SMTP_PASSWORD}"
    tls: starttls

tls is starttls (upgrade a plaintext connection, and fail if the server does not offer it), implicit (dial TLS directly, usually port 465), or none. none exists for a relay on a trusted local network — and for Mailpit — and nowhere else.

STARTTLS does not fall back to plaintext when a server stops offering it. A server that used to and now does not is either misconfigured or being stripped, and continuing would hand it your SMTP password in the clear.

Webhook

The webhook channel posts the rendered message to an endpoint you configure. It is how you put your own system in the path, and how you reach a provider that only speaks HTTP — Resend, Postmark, SendGrid, Mailgun and SES all accept a send over HTTP, and a body template is enough to produce any of their request shapes.

mail:
  channel: webhook
  from:
    address: no-reply@example.com
  webhook:
    url: https://api.example.com/send
    auth:
      type: api_key
      config:
        in: header
        name: Authorization
        value: "Bearer ${MAIL_API_KEY}"

It shares the guarded transport the outbound hooks use, so the SSRF policy, retry behaviour and credential types documented under Webhooks apply here too. The URL must be https and must resolve to a public address, and there is no relaxation of either for the mail endpoint — a receiver on your own network is reached over SMTP instead.

The request body is a JSONNet template evaluated against the message. The default produces the shape Mailpit's send API accepts:

function(ctx) {
  From: { Email: ctx.from.address, Name: ctx.from.name },
  To: [{ Email: ctx.message.to }],
  Subject: ctx.message.subject,
  HTML: ctx.message.html,
  Text: ctx.message.text,
}

Set mail.webhook.body to your own — inline, file:// or base64:// — to produce a different shape. The context is:

Field
ctx.kindwhich message this is, from the table above
ctx.from.address, ctx.from.namethe configured sender
ctx.message.tothe recipient
ctx.message.subject, .html, .textthe rendered message
ctx.message.action_urlthe link the message carries, empty when it carries none

A receiver that wants to render its own mail can ignore the rendered fields and use ctx.kind and ctx.message.action_url.


Overriding the messages

Every message ships a default, so the templates block is optional and so is every field in it — overriding a subject does not mean restating the body.

mail:
  templates:
    password_reset:
      subject: Reset your Example password
      body:
        - Someone asked to reset the password on your account. Choose a new one to continue.
      action_label: Choose a new password
      footer: This link expires in {{ .ExpiresIn }} and can be used once.

Each value is a template evaluated against the message context:

.Kindwhich message this is
.ProductNamemail.product_name
.Recipientthe address being written to
.ActionURLthe link, empty when there is none
.BaseURLthe console's public origin
.ExpiresInhow long the link lasts, written out — "one hour", "24 hours"
.ProjectNamethe project somebody was invited to — the invitation only
.InviterNamewho sent the invitation, their name when their account carries one and their address otherwise — the invitation only

Every value can also be given as file:// or base64://, which is usually what you want once a body is more than a line or two. See Configuration for when to use which, and for what happens when a reference is broken.

The layout

The four messages share one layout, so a rendering fix happens in one place rather than four. Override it to change the framing of all of them:

mail:
  templates:
    layout:
      html: file://mail/layout.html
      text: file://mail/layout.txt

The layout receives the message context above plus the rendered copy — .Subject, .Heading, .Body (a list of paragraphs), .ActionLabel and .Footer. A deployment that genuinely needs a different frame per message can branch on .Kind inside its layout.

The copy is escaped when it is rendered into the HTML layout, so a value that reaches it from a request cannot inject markup into somebody's mailbox. Markup you want in the message belongs in the layout, which is the template allowed to emit it.

password_changed carries no link. It is a notice, and a notice that asks you to click something teaches the exact reflex phishing relies on. An action_label set on it has nothing to point at.

The invitation's link points at the console's invites page, not at a token. An invitation is claimed by signing in with the address it was sent to, so a link that granted access on its own would hand the project to whoever the mail was forwarded to. See Members.

On this page