# Transactional email

Send email over SMTP with `EmailUtils` — plain-text body + HTML
alternative, attachments, and Jinja2 template rendering. Requires the
`[email]` extra (`aiosmtplib` + `jinja2` + `email-validator`).

## Configuration

`EmailSettings` ships the SMTP fields ready to use (`SMTP_HOST`,
`SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_ADDR`,
`SMTP_USE_TLS`, `SMTP_USE_SSL`, `SMTP_TIMEOUT_SECONDS`). Compose it into
your `Settings` and use `email_kwargs()` to build the utility without
mapping field by field:

```python
# src/core/mailer.py
from tempest_fastapi_sdk import EmailUtils

from src.core.settings import settings


mailer = EmailUtils(**settings.email_kwargs())
```

`email_kwargs()` bridges the SMTP names to the `EmailUtils` ones:
`SMTP_USE_TLS` (STARTTLS, port 587) → `use_starttls`; `SMTP_USE_SSL`
(implicit TLS from connect, port 465) → `use_tls`.

!!! tip "STARTTLS vs SSL/TLS — opportunistic by default"
    Port **587** → `SMTP_USE_TLS=true` (default): connect in clear text and
    upgrade via STARTTLS. Port **465** → `SMTP_USE_SSL=true`: connect
    already encrypted. STARTTLS is **opportunistic**: `EmailUtils` only
    upgrades when the server advertises STARTTLS, so a plain server
    (**MailHog on `:1025`**, or `:25`) just works — no more `SMTPException:
    SMTP STARTTLS extension not supported by server.` (since v0.38.1). To
    force plain text without even attempting the upgrade, set
    `SMTP_USE_TLS=false`; the `.env.example` generated by `tempest new`
    with `[email]` already ships that way for MailHog.

## Production: real SMTP and credentials

In production SMTP is **not optional** — you send through a real provider
(Gmail/Workspace, AWS SES, SendGrid, Mailgun, ...) and that needs a
**host, port, username, and password**. The golden rule: those
credentials **always come from the environment** (`.env` / secret manager
/ container variables) and are **never** in the code or committed to Git.

!!! danger "An SMTP credential is a secret — treat it like a DB password"
    - `SMTP_PASSWORD` **never** lands in the repo. Keep `.env` in
      `.gitignore` and commit only a `.env.example` with fake values.
    - For Gmail/Workspace **do not use the account password** — generate
      an **App Password** (<https://myaccount.google.com/apppasswords>)
      with 2FA enabled. The normal password fails with
      `535 Authentication failed`.
    - `SMTP_FROM_ADDR` must be an address on a domain you control and have
      authenticated (SPF/DKIM/DMARC), otherwise the email is marked spam
      or rejected.

The production services already follow this pattern — declare the SMTP
fields on your `Settings` and read everything from the environment:

```python
# src/core/settings.py
from pydantic import Field
from tempest_fastapi_sdk import BaseAppSettings, EmailSettings


class Settings(BaseAppSettings, EmailSettings):
    """Application settings. SMTP inherited from EmailSettings."""

    FRONTEND_URL: str = Field(
        default="http://localhost:3000",
        description="Frontend base URL (used in email links).",
    )


settings: Settings = Settings()
```

```ini
# .env.example  (committed — fake values)
# the real .env (NOT committed) holds the actual credentials
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your_email@example.com
SMTP_PASSWORD="your_app_password"
SMTP_FROM_ADDR=your_email@example.com
SMTP_USE_TLS=true        # STARTTLS on 587
SMTP_USE_SSL=false       # use true (and PORT=465) for implicit TLS
```

Common providers and their port/TLS combination:

| Provider            | `SMTP_HOST`            | `SMTP_PORT` | `SMTP_USE_TLS` (STARTTLS) | `SMTP_USE_SSL` (implicit TLS) |
| ------------------- | ---------------------- | ----------- | ------------------------- | ----------------------------- |
| Gmail / Workspace   | `smtp.gmail.com`       | `587`       | `true`                    | `false`                       |
| Gmail (direct TLS)  | `smtp.gmail.com`       | `465`       | `false`                   | `true`                        |
| AWS SES             | `email-smtp.<region>.amazonaws.com` | `587` | `true`             | `false`                       |
| SendGrid            | `smtp.sendgrid.net`    | `587`       | `true`                    | `false`                       |
| MailHog (local dev) | `localhost`            | `1025`      | `false`                   | `false`                       |

!!! note "How alofans-api and transport-backend do it"
    These are the two production patterns: **alofans-api** uses **Gmail on
    port 587 (STARTTLS) with an App Password** — `SMTP_USE_TLS=true`.
    **transport-backend** uses **port 465 (implicit TLS)** —
    `SMTP_USE_SSL=true`, `SMTP_PORT=465`. Both read `SMTP_*` from the
    environment and never hardcode the password. Pick STARTTLS (587) or
    SSL (465) per your provider; **do not enable both at once**.

## Send an email

Each `send()` opens a fresh SMTP connection. `to` accepts a string or an
iterable; the `body` (plain text) is always sent and `html` becomes the
multipart alternative when present.

```python
await mailer.send(
    to="ana@example.com",
    subject="Welcome!",
    body="Your account was created.",
    html="<p>Your account was <strong>created</strong>.</p>",
)
```

Optional per-message parameters: `cc`, `bcc`, `attachments`
(`Iterable[Path]`), `reply_to`, and `from_addr` (overrides the default
sender). Any SMTP error is re-raised as
`aiosmtplib.errors.SMTPException` for the caller to handle.

## Jinja2 templates

Pass `template_dir=` at construction and render with `render_template()` —
the Jinja2 environment is built lazily on first call and memoized.
Autoescaping is on for `.html` / `.htm` / `.xml`.

```python
# src/core/mailer.py
mailer = EmailUtils(
    host=settings.SMTP_HOST,
    port=settings.SMTP_PORT,
    from_addr=settings.SMTP_FROM_ADDR,
    template_dir="src/templates/emails",
)

html: str = mailer.render_template(
    "welcome.html",
    {"user_name": "Ana", "app_url": "https://app.example.com"},
)
await mailer.send(
    to="ana@example.com",
    subject="Welcome!",
    body="Welcome, Ana!",
    html=html,
)
```

!!! info "Bundled auth-flow templates"
    The bundled auth flow (`make_auth_router`) already sends activation
    and password-reset emails using built-in templates
    (`activation.html`, `password_reset.html`). Drop same-named files in
    your `template_dir` to override them. See [Auth flow](auth-flow.md).

## Example: password reset

A service that sends a reset link with a short-lived JWT. Note that
`request_reset` returns silently for an unregistered email — this avoids
account enumeration.

```python
# src/services/password_reset.py
from datetime import timedelta

from tempest_fastapi_sdk import EmailUtils, JWTUtils

from src.db.repositories import UserRepository


class PasswordResetService:
    def __init__(
        self,
        repo: UserRepository,
        tokens: JWTUtils,
        mailer: EmailUtils,
    ) -> None:
        self.repo: UserRepository = repo
        self.tokens: JWTUtils = tokens
        self.mailer: EmailUtils = mailer

    async def request_reset(self, email: str) -> None:
        """Send a reset link to ``email`` (silent if it does not exist)."""
        user = await self.repo.get_or_none({"email": email})
        if user is None:
            return
        token: str = self.tokens.encode(
            {"sub": str(user.id), "purpose": "password_reset"},
            ttl=timedelta(minutes=15),
        )
        reset_url: str = f"https://app.example.com/reset-password?token={token}"
        await self.mailer.send(
            to=user.email,
            subject="Reset your password",
            body=f"Open to reset: {reset_url}",
            html=f'<p>Click <a href="{reset_url}">here</a> to reset.</p>',
        )
```

## Recap

- Install `[email]` and compose `EmailSettings` into your `Settings`.
- One `EmailUtils` instance per app; `send()` is async and opens a connection per call.
- The text `body` is required; `html` is the optional multipart alternative.
- `render_template()` (with `template_dir`) builds the HTML from Jinja2.
- The bundled auth flow already sends activation/reset with overridable templates.
