Skip to content
latest
Data and Services / latest

Email

Email is authored as templ under email/ and compiled to Go that renders inline, email-safe styles. pkg/email owns the message types, the Mailpit and SMTP providers, and the sender interfaces. email/ is the only templ home besides the Inertia root document.

Author a template

An email is a struct plus a render() templ method. It implements email.Transformer so the same source produces both bodies:

 1type VerifyEmail struct {
 2    VerificationCode string
 3}
 4
 5var _ pkgemail.Transformer = (*VerifyEmail)(nil)
 6
 7func (v VerifyEmail) ToHTML() (string, error) {
 8    var buf bytes.Buffer
 9    if err := v.render().Render(context.Background(), &buf); err != nil {
10        return "", err
11    }
12
13    return buf.String(), nil
14}
15
16func (v VerifyEmail) ToText() (string, error) {
17    html, err := v.ToHTML()
18    if err != nil {
19        return "", err
20    }
21
22    return pkgemail.HTMLToText(html)
23}
24
25templ (v VerifyEmail) render() {
26    @baseLayout("Verify Your Email Address", "Complete your registration by verifying your email address.") {
27        <h1>Verify your email address</h1>
28        <p>Enter the code below to finish creating your account.</p>
29        <p>{ v.VerificationCode }</p>
30    }
31}

The scaffold's email/base_layout.templ provides the shared shell. Style templates with Tailwind utilities from css/email.css; the compiler turns them into inline styles that email clients accept.

Compile

1andurel sync email

The command compiles the Tailwind classes used by the templates, rewrites the templ output with inline styles, and writes the *_templ.go files. Authored .templ files are never changed. andurel run and andurel build perform the same compilation when both email/ and css/email.css exist.

Do not edit *_templ.go; it is derived. andurel verify reports generated-code drift in its generated step.

Compose a message

email.Compose renders a template into a TransactionalData addressed to one recipient:

1message, err := email.Compose(
2    appemail.VerifyEmail{VerificationCode: code},
3    user.Email,
4    i.defaultSenderSignature,
5    "Verify Your Email Address",
6)
7if err != nil {
8    return fmt.Errorf("render verification email: %w", err)
9}

The signature comes from DEFAULT_SENDER_SIGNATURE, which defaults to noreply@ plus the primary host without its port.

Send through the queue

Queue generic mail by inserting storage.SendTransactionalEmailArgs on a storage.InsertQueue. Inside storage.AtomicOps, use the bound queue so the job commits with the data (the snippet assumes a service method whose data holds the registration input and a WelcomeEmail template from the email pattern):

 1err := storage.AtomicOps(ctx, i.transactor, func(ctx context.Context, tx storage.Tx) error {
 2    users, queue := i.users.InTx(tx), i.queue.InTx(tx)
 3
 4    created, err := users.Create(ctx, i.pepper, domains.CreateUserData{
 5        Email: data.Email,
 6        PasswordPair: domains.PasswordPair{
 7            Password:        data.Password,
 8            ConfirmPassword: data.ConfirmPassword,
 9        },
10    })
11    if err != nil {
12        return err
13    }
14
15    message, err := email.Compose(
16        appemail.WelcomeEmail{UserName: created.Email},
17        created.Email,
18        i.defaultSenderSignature,
19        "Welcome",
20    )
21    if err != nil {
22        return err
23    }
24
25    _, err = queue.Insert(ctx, storage.SendTransactionalEmailArgs{Data: message}, nil)
26
27    return err
28})

storage.AddEmailWorkers registers the generic transactional and marketing workers in queue/workers.go.

Mail that carries a secret (a verification code or a reset link) must not be rendered at enqueue time: job arguments persist in river_job. Enqueue a job with the user id and let its worker mint the secret and compose the email. The scaffold's SendVerificationEmailWorker does exactly that and calls storage.SendTransactional at the end.

Providers

EMAIL_PROVIDER selects the transport:

Provider Use
mailpit Local development. Captures mail; refused in production.
smtp Production relay. Configure SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, and SMTP_TLS.
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=apikey
SMTP_PASSWORD=secret
SMTP_TLS=starttls

SMTP_TLS accepts starttls (default, usually port 587), tls (implicit TLS, usually port 465), or none for a local relay. Amazon SES uses this driver: point SMTP_HOST at the SES SMTP endpoint for the sending identity's region, keep port 587 with starttls (or 465 with tls), and use SES SMTP credentials rather than an IAM access key. There is no aws-ses extension in v2. Start Mailpit locally with:

1andurel tool mailpit

Its web UI defaults to http://localhost:8025.

The scaffold constructs providers in runtime.newEmailSenders. To add another vendor, implement email.TransactionalSender and email.MarketingSender in a package under internal/ and select it there; there is no extension command to install one.

Retries and marketing

storage.SendTransactional delivers a message and cancels the job on errors a retry cannot fix. Provider errors are classified with email.IsRetryable and the email.ValidationError, email.TemporaryError, and email.PermanentError types, so River retries temporarily failing sends and drops permanent ones.

Marketing mail uses email.MarketingData, which requires an UnsubscribeURL; pkg/email returns email.ErrUnsubscribeURLRequired when it is missing. storage.SendMarketingEmailArgs and its worker are registered by storage.AddEmailWorkers alongside the transactional workers.