Skip to content

Self-host email: optional emails opt-in, a mail relay through CoderHelm, and a Self-host admin view - #315

Merged
nambok merged 1 commit into
mainfrom
feat/selfhost-email-optin
Oct 27, 2026
Merged

nambok merged 1 commit into
mainfrom
feat/selfhost-email-optin

Conversation

@nambok

@nambok nambok commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Optional email is now opt-in on self-hosted installs; the hosted service is unchanged. An install admin chooses which optional emails go out, and how email leaves the install, in Settings, Emails, with no redeploy. A self-hosted install can also send its email through CoderHelm's new mail relay (part of hybrid) instead of its own Amazon SES. The relay sends only from CoderHelm's own templates, only to the install's listed people, from a separate sending subdomain. Platform admins get a Self-host view of every hybrid install.

Pairs with CoderHelm/coderhelm#231 (dashboard Emails page, Admin Self-host page, install guide).

Every email CoderHelm can send

Email Sent by Kind Self-host default
Sign-up codes, password resets Cognito (custom message Lambda) Essential on, never relayed (codes stay in the install)
Team invite gateway users.rs Essential on
Plugin approval request worker plugins.rs Essential on
Knowledge source sign-in alert worker knowledge_sync.rs Essential on
Token limit warning / reached worker clients/email.rs Essential (each person can opt out) on
Support ticket updates gateway support.rs Essential on
Update notices (available, done, failed, rolled back, domain) updater Lambda Essential (install admins) on
Test email gateway system.rs Essential (admin pressed it) on
Weekly summary worker weekly_summary.rs Optional off
Welcome email gateway auth.rs Optional off
Relay paused notice hosted worker email_events.rs CoderHelm's own notice to an install's admins n/a

The run-complete, run-failed, password-changed and passkey-* SES templates exist but nothing sends them.

Changes

Opt-in on self-host

  • common::mail: one send layer for every email. It checks the deployment (DEPLOYMENT_KIND; anything but hosted counts as self-hosted), whether optional email is on (SYSTEM / SETTINGS#EMAIL_OPTIONAL, else the config's OPTIONAL_EMAIL_DEFAULTS), and the route: SES, or the relay. The relay is used only when an admin chose it and the install is registered, and nothing falls back to the other route. A self-hosted install never emails a coderhelm.com address by either route.
  • Infra: integrations.weeklySummary defaults to false on self-host and stays true for hosted (split layout). The example config sets false. On self-host the Monday schedule always exists and the worker decides at runtime. New email.transport (ses | coderhelm) is the starting value, also a Launch Stack parameter (EmailTransport, default ses).
  • The weekly summary's mailto: unsubscribe on self-host is the install's own inbox, never support@coderhelm.com. The worker now gets SUPPORT_INBOX.
  • GET/PUT /api/system/emails (install admins, capability emails in permissions.rs): the optional emails, the essential ones with why, the transport and the relay's status. Switching to the relay lists the install's admins, people and pending invites with it first.
  • Setup checks: a new optional optional_emails check. It fails when the relay is chosen but unreachable or paused.
  • New Deliveries reason codes for each refusal.

CoderHelm's mail relay (hosted side of hybrid)

  • POST /hybrid/email, POST /hybrid/email/recipients, GET /hybrid/email/status. All are signed like every hybrid call, and only for a registered, verified, non-revoked install. The request id is signed and refused on reuse.
  • Templates and parameters only (common::mail::templates). Subjects are fixed. Parameters are typed, length-limited and escaped. Links and addresses in text are removed. Link parameters must point at the install's own verified host. Wording that reads as CoderHelm staff is refused. Secret parameters (signed approval and unsubscribe links) are marked, and nothing stores parameters.
  • From notifications@notify.coderhelm.com, named <install address> via CoderHelm, with no Reply-To. A name that looks like CoderHelm, staff or a hosted host falls back to the CloudFront address (the names::looks_like_coderhelm rule plus digit look-alikes). The footer names the install and its listed admin.
  • Recipients: salted per-install hashes. Only a listed admin can list people, with at most 50 new addresses a day (200 for the first listing). Admin addresses are KMS-sealed, used only for the pause notice.
  • Limits: 30 a minute and 500 a day per install. Bounces and complaints arrive through SES, then SNS, then the ticket queue (raw). The worker suppresses the address, counts it, records it in the install's Deliveries, and pauses the install (and emails its admins) above 5% bounces or 0.5% complaints over 7 days. Platform admins can pause and resume.
  • Delivery records keep the template id, a redacted address and the status, for 3 days. The audit log keeps counts and hashes only.
  • CDK (lib/mail-relay.ts, hosted only): the notify.coderhelm.com SES identity with Easy DKIM and MAIL FROM bounce.notify.coderhelm.com, DKIM CNAMEs, MX, SPF -all, DMARC p=reject on the subdomain, a configuration set with an SNS destination, and ses:SendEmail limited to that identity, that configuration set and ses:FromAddress. notify is added to reserved_names.json.
  • Updater: update notices go out by the install's chosen route (signed relay calls in Python, the same signature vector as Rust). It reads the hybrid registration from the install secret (new read-only grant).

Platform admin: Self-host

  • GET /api/admin/hybrid/summary and GET /api/admin/hybrid/:id (with the audit log), and POST /api/admin/hybrid/:id/email/pause, under the platform_admin capability in permissions.rs. They need a platform admin and the admin role. Metadata only: recipients appear only as counts.

Docs

  • email.md: the inventory, Settings, Emails, and Send through CoderHelm with what CoderHelm sees.
  • hybrid.md: a trust model row and the Email threat model.
  • options.md: "Weekly summary email (off; turn on in Settings)".
  • config-reference.md, integrations.md.

Threat model (adversarial pass)

Attempt Refused by Test
Sign as another install, replay, alter the body per-install secret, 5-minute window, signed request id stored once mail::adversarial::one_install_cannot_sign_as_another_or_replay
Look like CoderHelm support no subject/HTML/From/Reply-To params, fixed subjects, reserved wording refused, sender name never staff or look-alike an_email_dressed_as_coderhelm_support_is_refused_or_reads_as_the_install, the_sender_name_is_the_verified_address_never_staff_wording
Email an outside address listed recipients only, admin-only listing, daily cap, CoderHelm addresses refused, header injection refused an_outside_address_gets_nothing
Phishing link links only to the install's verified host, links stripped from text a_phishing_link_never_reaches_the_email
Secrets in storage secret params marked, records hold domain/redacted address only secret_parameters_never_reach_a_record

Manual step for Nam (DNS)

The relay's records are in CDK, in the coderhelm.com Route 53 zone. They only go live when the hosted stack deploys, and the new identity must show Verified in SES before the relay can send. Separately, _dmarc.coderhelm.com is p=none today and SPF lists only Google. The relay doesn't depend on either because the subdomain has its own p=reject, but tightening the main domain's DMARC is worth doing.

Self-host links (#312) follow-ups

  • The weekly summary's unsubscribe address uses public_urls::support_inbox().
  • Relay templates are rendered as a self-hosted install with pretend_self_hosted(), and hosted_mentions() is empty for every template. The relayed weekly summary is covered too.
  • no_hosted_urls: the updater's default hosted API URL is on the allowlist, with its reason. Infra passes HYBRID_URL through the same hybridUrlEnvironment() the services use, so a plain install still carries nothing hosted. The relay's sender (notify.coderhelm.com) appears only in hosted config and tests, so the lint doesn't flag it and needs no entry.
  • Self-host: every link points at the install, never at coderhelm.com #312 left the support subject "With Coderhelm support" as it was. The relay refuses that wording (param_reserved).
  • The updater's new "needs your action" notice is a relay template value (blocked).

Known limits

  • An install on its own custom domain (not its CloudFront address or CoderHelm name) can't have links relayed (link_not_own_host). It should use SES for now.
  • Support ticket subjects that read "With Coderhelm support" are refused by the relay (param_reserved), as intended. The self-host support wording is being changed in the self-host links work.
  • Cognito sign-up codes are never relayed: they stay inside the install. With Cognito's own sender, no SES is needed for them.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace: all pass.
  • cargo test --workspace: 2222 pass, including no_hosted_urls.
  • infra: npm test, 66 pass (synth for hosted and self-host configs).
  • lambda: python3 -m unittest discover -s tests, 131 pass.
  • selfhost/launch: node --test bootstrap.test.mjs, 25 pass.
  • The name grep is empty. No arrow glyphs. No AWS writes.

…lm, and a Self-host admin view

- Every email goes through one layer (common::mail). On a self-hosted
  install the optional ones (weekly summary, welcome) are off until an
  install admin turns them on in Settings, Emails. The hosted service is
  unchanged. A self-hosted install never emails a coderhelm.com address.
- Self-hosted installs may send through CoderHelm's mail relay (hybrid,
  opt-in, no fallback). It sends templates and parameters only, to listed
  recipients only, from notify.coderhelm.com with DKIM, SPF and DMARC
  reject. It has per-install limits, handles bounces and complaints with
  an automatic pause, and keeps delivery metadata only.
- Platform admins see every hybrid install and the relay and email numbers.
@nambok
nambok force-pushed the feat/selfhost-email-optin branch from d2b58d3 to 30b8de8 Compare October 27, 2026 02:35
@nambok
nambok merged commit afacdf5 into main Oct 27, 2026
8 checks passed
@nambok
nambok deleted the feat/selfhost-email-optin branch October 27, 2026 03:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant