Outbound Email Contract
Pharos sends transactional email through a framework-owned SMTP client implemented in the native runtime.
Purpose
Outbound email configuration lives in shared runtime config with optional app-local overrides. The sections below cover precedence, supported SMTP settings, verification-link origin rules, and delivery logging and persistence behavior.
Runtime ownership
Pharos owns:
- SMTP connection setup
- RFC 822 message assembly
- TLS mode selection
- auth with the configured relay
- bounded error handling
- delivery status persistence for framework-owned flows
Apps own:
- sender overrides when needed
- recipient selection through framework flows
- app-owned outbound email templates under
templates/email/*.yaml - user-facing template variables where the framework exposes them
Config keys
Shared runtime config from PHAROS_CONFIG
The shared runtime config file selected by PHAROS_CONFIG may define server-default email settings:
# outbound email is disabled by default until SMTP settings are uncommented
# uncomment these values and restart the Pharos runtime after configuration
# all SMTP keys below are explicit; Pharos does not alias them to other config keys
# smtp_server=<your_primary_smtp_server>
# smtp_encryption=STARTTLS
# smtp_port=587
# smtp_mail_from=noreply@example.com
# smtp_login=<your_primary_smtp_login>
# smtp_pass=PHAROS_SMTP_PASS
# smtp_timeout_seconds=15
# smtp_max_message_bytes=1048576
# optional secondary relay. this relay is inactive until smtp2_* values are set.
# smtp2_server=<your_secondary_smtp_server>
# smtp2_encryption=STARTTLS
# smtp2_port=587
# smtp2_mail_from=noreply@example.com
# smtp2_login=<your_secondary_smtp_login>
# smtp2_pass=PHAROS_SMTP_PASS
# smtp2_timeout_seconds=15
# smtp2_max_message_bytes=1048576
# supported SMTP mode pairs for both relays:
# - smtp_encryption=STARTTLS requires smtp_port=587
# - smtp_encryption=SSL requires smtp_port=465
# - smtp2_encryption=STARTTLS requires smtp2_port=587
# - smtp2_encryption=SSL requires smtp2_port=465
base_url=app.example.com
Supported framework-level keys:
Primary relay:
smtp_server=<relay host>smtp_port=587|465smtp_mail_from=<sender address>required when outbound email is enabledsmtp_login=<smtp username>smtp_pass=<environment variable name>smtp_encryption=STARTTLS|SSLsmtp_timeout_seconds=<positive integer>smtp_max_message_bytes=<positive integer>
Optional secondary relay:
smtp2_server=<relay host>smtp2_port=587|465smtp2_mail_from=<sender address>smtp2_login=<smtp username>smtp2_pass=<environment variable name>smtp2_encryption=STARTTLS|SSLsmtp2_timeout_seconds=<positive integer>smtp2_max_message_bytes=<positive integer>
Shared URL origin:
base_url=<host or absolute origin>
App-local overrides in app.conf
Apps may override all supported email keys in app.conf. An enabled primary SMTP configuration must still provide an explicit smtp_mail_from value after precedence resolution. If a secondary relay is configured, its smtp2_* keys follow the same precedence model independently.
Example:
base_path=/your-app
base_url=app.example.com
smtp_server=<your_smtp_server>
smtp_port=587
smtp_mail_from=app_noreply@example.com
smtp_login=<your_app_smtp_login>
smtp_pass=YOUR_APP_SMTP_PASS
smtp_encryption=STARTTLS
smtp_timeout_seconds=15
smtp_max_message_bytes=1048576
Resolution precedence
For each supported email key, Pharos resolves values in this order:
- app-local override from
app.conf - server default from the config file selected by
PHAROS_CONFIG - framework default only where the wider runtime already allows one and only for that same explicit key
This precedence applies independently to the primary smtp_* keys and the optional secondary smtp2_* keys.
Verification-link origin precedence
Verification links are resolved in this order:
- app-local
base_url - server-default
base_url - request-derived origin fallback
If base_url is provided as a bare host such as app.example.com, Pharos normalizes it to https://app.example.com for outbound verification links.
SMTP behavior
Pharos supports exactly two relay mode pairs for both the primary and optional secondary relay:
STARTTLSon port587SSLon port465
If an explicit SMTP configuration provides a mismatched mode and port pair, the runtime rejects that configuration instead of silently correcting it.
When both relays are configured, Pharos attempts the primary relay first and only attempts the secondary relay when the primary delivery attempt does not succeed. The secondary relay remains inactive when its smtp2_* keys are unset, empty, or null.
For transient transport failures only, Pharos applies one bounded in-request retry per relay with a fixed one-second backoff. Terminal config, auth, validation, and unsupported-mode failures are not retried.
The framework validates sender, recipient, and subject header safety before attempting transport. Message bodies are bounded by smtp_max_message_bytes.
Secret handling
SMTP secrets should be provided through environment variables rather than checked-in config. By default the examples reuse the same secret env var for both relays, though operators may choose different env vars if needed.
Example:
export PHAROS_SMTP_PASS='your relay password'
Do not store SMTP passwords in repo-tracked config, templates, or docs examples.
App-owned template contract
Pharos loads outbound email templates from the active app artifact at:
templates/email/<template_id>.yaml
Each template currently supports these top-level YAML keys:
subjectbody
Variable substitution uses bounded {{name}} placeholders.
Current framework-owned template ids include:
verify_emailbusiness_invite
Delivery persistence
Framework-owned verification and invite delivery writes state into notification_deliveries. Expected delivery states include:
queuedretryableduring a bounded in-request retry windowsucceededskippedfailed
The same notification_delivery row also records bounded retry metadata such as:
attempt_countmax_attemptsbackoff_secondslast_errorlast_smtp_codenext_attempt_atupdated_atcompleted_at
Framework-owned signup verification tokens are persisted in email_verifications.
Logging contract
Delivery failures and skipped deliveries are written to the resolved app log path, typically:
/var/log/pharos/<app_id>.log
These log lines include bounded metadata such as:
- app id
- relay name such as
primaryorsecondary - template id
- subject
- recipient
- stable reason label
- bounded transport error text
When both relays are configured, Pharos records bounded email_delivery_attempt, email_delivery_retry, and final relay outcome events in service logs so relay attempt order is visible without exposing secrets. App-log routing may also include the same events when that surface is enabled in the current deployment.
They do not include:
- SMTP passwords
- full message bodies
Project policy currently disables .error.jsonl sidecar emission for outbound email events so logging stays consolidated in the .log files.
Provider example
A managed SMTP relay works with this contract using authenticated settings such as:
smtp_server=<your_smtp_server>smtp_port=587smtp_encryption=STARTTLS
Brevo is one compatible example, but the framework remains provider-agnostic as long as the relay supports standard authenticated SMTP.
Operator notes
Keep relay setup explicit and bounded:
- DNS for the configured relay host must resolve from the Pharos runtime host before delivery can succeed
- SMTP auth failures should be treated as config or credential defects, not as a signal to silently fall back to unrelated keys
smtp_timeout_secondsandsmtp2_timeout_secondsshould stay low enough to fail fast on dead relays without stalling request handling longer than necessary- use
STARTTLSonly with port587 - use
SSLonly with port465 - when both relays are configured, expect service-log ordering of
email_delivery_attemptforprimary, thensecondaryonly if the primary attempt did not succeed
Private-network deployment notes
When Pharos runs in a private VPC without direct internet egress:
- configure Cloud NAT or equivalent outbound egress
- allow egress to TCP
587forSTARTTLS - allow egress to TCP
465whenSSLis needed - configure SPF and DKIM for the sender domain
- use a stable public egress IP only when the relay provider requires IP authorization