Browser-local boundary planner
Turn four observed states into one next action
No .env value, SMTP username, SMTP password, recipient, reset URL, token, message, provider ID or credential is requested or transmitted.
- Boundary
- Next action
- Exit condition
- Safety
The six boundaries
- 1. Interpolation
- Compose can read values from a project
.envfile while resolving${VARIABLE}expressions. - 2. Service export
- Only variables declared through the service
environmentorenv_fileconfiguration are placed in that container. - 3. Authentication contract
- No auth, username with an empty password, and username plus password are different states. The application must model the selected state deliberately.
- 4. Running process
- The created app container must actually contain the mode-specific key names expected by the deployed application revision.
- 5. Send path
- The password-reset handler must call the mailer and receive either a bounded failure or provider acceptance.
- 6. Completion
- Receiver visibility and successful reset-link consumption remain separate from SMTP configuration.
Do not paste .env output, SMTP values, a recipient address, reset URL, token, provider message identifier, private key, seed phrase or wallet signature into a public issue or diagnostic.
An empty password is a state, not automatically a missing secret
A local sink such as Mailpit commonly expects no SMTP authentication. In that mode, leave both username and password absent and make the client skip AUTH. A username with an intentionally empty password is a different contract: the client may attempt AUTH PLAIN or AUTH LOGIN, and the server must explicitly support it.
The configuration loader must therefore accept an absent encrypted secret without dereferencing it. It should pass an empty credential state to the transport, which then decides whether to authenticate. A dummy password is not a durable fix: it can hide a nil-handling defect and can cause a later client to attempt authentication when the sink expected none.
ZITADEL source boundary
In ZITADEL v2.54.8, AddSMTPConfig stores a nil password when the submitted password is empty. The notification loader then calls crypto.DecryptString unconditionally, while that revision's encryption check dereferences the value before checking it. The SMTP channel itself already skips authentication when both username and password are empty. That combination explains the panic reported in issue 12488 without involving DNS, Mailpit delivery, or the recipient mailbox.
Upstream PR 11193, merged as commit c172adf, changed the loader to decrypt only when the password exists and added cases for authenticated, username-only, and unauthenticated SMTP. Release v4.9.1 contains that guard; current v4.16.1 models SMTP authentication as optional configuration. A deployment pinned to v2.54.8 should reproduce on a supported release or backport the nil guard and tests rather than rely on a dummy password.
This source finding identifies an application configuration defect. It does not prove that a later SMTP connection, envelope, provider, receiver, or reset-link step will succeed.
What the source proves in Riffado
At Riffado revision 731c728314a685a488d0f03ab21828f244f60028, .env.example declares optional SMTP_* variables and the mail code reads those names from process.env. The official docker-compose.yml app service enumerates its environment but does not include the SMTP keys. That is enough to test the service-export boundary before changing DNS, the receiver mailbox, or the reset page.
The same issue also reports a reset-page failure. Keep that as a later client/server and token-consumption investigation: a missing email and a crashing link page are two independent defects.
This is source evidence for that revision, not a claim about every deployment. A local override file, another Compose file, a manually supplied environment, or a later revision can change the effective model.
Prove key presence without printing values
Run each check from the directory and Compose-file set used for the actual deployment. The first command lists interpolation key names only; the second lists key names exported by the resolved app service.
docker compose config --environment | cut -d= -f1 | sort
docker compose config --format json \
| jq -r '.services.app.environment | keys[]' \
| sort
Then check boolean presence inside the running application process. This command does not print any value. For no-auth SMTP, SMTP_USER and SMTP_PASSWORD should be false; for authenticated SMTP, their expected state depends on the selected contract.
docker compose exec app node -e \
"console.log(Object.fromEntries(
['SMTP_HOST','SMTP_PORT','SMTP_SECURE','SMTP_USER','SMTP_PASSWORD','SMTP_FROM']
.map(k => [k, Boolean(process.env[k])])
))"
docker compose config --environment without the key-only filter can expose interpolation values. Keep raw output private and never attach it to a public report.
Pass only the mode-specific variables into the app service
When the resolved service omits the keys for authenticated SMTP, add explicit mappings to the existing app.environment block. Keep the actual values in a protected, untracked environment source:
services:
app:
environment:
SMTP_HOST: ${SMTP_HOST:?set SMTP_HOST}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:?set SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD:?set SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM:?set SMTP_FROM}
SMTP_MARKETING_FROM: ${SMTP_MARKETING_FROM:-}
SMTP_REPLY_TO: ${SMTP_REPLY_TO:-}
EMAIL_SEND_RATE_PER_SECOND: ${EMAIL_SEND_RATE_PER_SECOND:-1}
For an unauthenticated local sink, omit the username and password mappings instead of inventing credentials:
services:
app:
environment:
SMTP_HOST: ${SMTP_HOST:-mailpit}
SMTP_PORT: ${SMTP_PORT:-1025}
SMTP_SECURE: "false"
SMTP_FROM: ${SMTP_FROM:-noreply@example.test}
An env_file can also populate a container, but an explicit list limits accidental export. Docker recommends secrets rather than environment variables for sensitive values; adopting Compose secrets also requires the application to read file-backed values. Do not assume that support exists without changing and testing the application.
Recreate, then send once
docker compose up -d --force-recreate app
A plain docker compose restart does not apply changed environment configuration. After recreation, repeat the presence-only check, trigger one password reset for a controlled account, and correlate its UTC timestamp to one application result and one provider event.
- If the app still reports SMTP not configured, verify the mode-specific required values, optional-secret behavior, and exact names read by the deployed revision.
- If the transport fails, retain only the bounded status or exception and fix that named boundary.
- If the provider accepts the message, follow that exact event through delivery, delay, bounce, suppression, or receiver acceptance.
- If the message is visible, test the newest reset link once as a separate route and token-use proof.
Read the four-section version 1.1.0 sample first. The complete 14-section Web3 Email Authentication Operations Kit costs exactly 1 native USDC on Base Mainnet only when it is useful to you.
The production-ready proof
Call the mail path ready only when the resolved service includes the expected keys, the current app process confirms presence without revealing values, the application records one reset-send attempt, and the provider or receiver records the same message instance. Record reset-link completion separately.
This independent guide is not affiliated with Riffado or Docker. The effective Compose model, running container, application logs, provider events, and controlled receiver remain the sources of truth for their respective boundaries.
Primary references
- Riffado issue 241: self-hosted Docker deployment report
- Riffado revision: SMTP keys in .env.example
- Riffado revision: app service environment in docker-compose.yml
- Riffado revision: SMTP environment reader
- Riffado revision: SMTP readiness and transport
- ZITADEL issue 12488: nil password panic on an unauthenticated SMTP sink
- ZITADEL PR 11193: allow SMTP configuration without a password
- ZITADEL commit: nil guard and no-password SMTP tests
- ZITADEL v4.9.1 release containing the no-password guard
- Docker: Compose variable interpolation
- Docker: Set environment variables in a container
- Docker: compose up and force recreation
- Docker: compose restart configuration boundary