Skip to content

Outgoing email: pick a provider instead of typing seven fields - #428

Open
onel wants to merge 12 commits into
devfrom
feat/mail-provider-presets
Open

Outgoing email: pick a provider instead of typing seven fields#428
onel wants to merge 12 commits into
devfrom
feat/mail-provider-presets

Conversation

@onel

@onel onel commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #426.

What

Adding an email account meant typing seven fields the admin had to look up in their provider's docs. For any provider worth presetting, four of them are constants. Settings → Outgoing email is now a two-step add: pick the provider, then supply only the credential, the from address, a username where the provider does not fix one, and a region for the two providers whose region changes the host.

Host, port and encryption are prefilled behind an Advanced settings disclosure — editable, so a non-standard endpoint is never trapped. custom renders as that disclosure open with nothing prefilled: today's form.

Nine presets in a new leaf package internal/mailpreset, served admin-only at GET /api/v1/mail-presets. Every one is STARTTLS on a port a hosted box can reach — 587, except SMTP2GO's 2525. Hosted blocks 25 and 465 (SERVICE_PROVISIONING.md, measured in #425), so no preset may default to implicit TLS.

Two of the issue's constants were wrong

Each host, port and username rule was checked against that provider's own docs:

  • Mailgun's EU host is smtp.eu.mailgun.org — a prefix, not a region code — so the issue's single {region} host template could not serve both it and SES. Each region option now names the host it resolves to. One mechanism, no substitution.
  • Brevo's username is the SMTP login xxx@smtp-brevo.com, not the account email. Help text, not structure, but exactly the kind of thing that turns into a failed test-send.

The rest

  • mail_providers.provider_type via the idempotent ALTER path, DEFAULT 'custom'. No CHECK (one cannot ride an ALTER), validated in Go like scope and exposure. Old rows load as custom, which is right — they were typed by hand. Kept for the credential broker, not for the form.
  • The server stores what the client sends and never re-derives host/port/encryption from the preset; re-deriving would silently undo the advanced override.
  • The test-send names the blocked port when a hosted box fails to connect on 25 or 465, instead of a bare timeout. An auth rejection means the port was reachable, so it stays plain.
  • internal/lifecycle/mail.go, the install plan and the binding endpoints are untouched on purpose.
  • DECISIONS.md 2026-08-27 (D1–D4), SERVICE_PROVISIONING.md, SETTINGS.md, progress entry + index, OpenAPI + TS types regenerated.

Verification

make test-nopam, gofmt, go vet, make openapi-check and make check-web are green. make check did not run end to end: this machine cannot build msteinert/pam (C.RTLD_NEXT), which fails identically on unmodified dev.

No live test-send was performed against any provider. That is the issue's real acceptance gate — a hostname or username rule can be wrong in a way no unit test sees — and it needs a provisioned hosted box plus a real account at each of the eight providers. Remaining gaps are in the progress entry.

https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo

Adding an outgoing-email account meant typing host, port, encryption,
username, password, from address and label — each looked up in the
provider's own docs. For any provider worth presetting, four of those
are constants and often the username is too.

Settings → Outgoing email is now a two-step add: pick the provider,
then supply the credential, the from address, a username where the
provider does not fix one, and a region for the two providers whose
region changes the host. Host, port and encryption are prefilled behind
an Advanced settings disclosure so a non-standard endpoint is never
trapped; custom renders as that disclosure open with nothing prefilled,
which is the old form.

Nine presets live in a new leaf package internal/mailpreset and are
served admin-only at GET /api/v1/mail-presets. Every one is STARTTLS on
a port a hosted box can reach — 587, except SMTP2GO's 2525. Hosted
blocks 25 and 465, so no preset may default to implicit TLS.

Two of the planned constants were wrong. Mailgun's EU host is
smtp.eu.mailgun.org, a prefix rather than a region code, so one
{region} host template could not serve both it and SES; each region
option now names the host it resolves to. Brevo's username is the SMTP
login xxx@smtp-brevo.com, not the account email.

mail_providers.provider_type persists which preset was picked, via the
idempotent ALTER path with DEFAULT 'custom'. No CHECK — one cannot ride
an ALTER — so it is validated in Go like scope and exposure. Rows that
predate this load as custom, which is right: they were typed by hand.
The column is kept for the credential broker, not for the form.

The server stores what the client sends and never re-derives host, port
or encryption from the preset; re-deriving would silently undo the
advanced override. The test-send now names the blocked port when a
hosted box fails to connect on 25 or 465, instead of a bare timeout.

internal/lifecycle/mail.go is untouched on purpose.

Not done: no live test-send against any provider. The constants are
verified against each vendor's current docs and nothing more.

Closes #426.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

The PR does not appear safe to merge because preset failures still block Custom SMTP setup and can let a Postmark username diverge from its retained token.

The current add-flow error state offers only a retry even though Custom SMTP is part of the unavailable server response. The edit flow also loses Postmark’s username lock when that response is unavailable, while the API accepts the changed username and keeps the old password.

Files Needing Attention: web-ui/src/views/settings/OutgoingEmailAddSection.vue, web-ui/src/views/settings/OutgoingEmailSection.vue

Important Files Changed

Filename Overview
web-ui/src/views/settings/OutgoingEmailSection.vue Adds preset-aware editing, but the previously reported Postmark credential mismatch remains reachable when preset loading fails.
web-ui/src/views/settings/OutgoingEmailAddSection.vue Adds the two-step account flow and retry feedback, but a persistent preset failure still blocks the server-provided Custom SMTP path.
web-ui/src/mailProviderForm.ts Centralizes preset form initialization, validation, warnings, and same-as-password synchronization.
internal/api/mail.go Adds preset and pre-save verification endpoints, provider-type handling, and clearer hosted-port connection errors.
internal/mailpreset/mailpreset.go Defines immutable SMTP provider presets, including region mappings and the Custom SMTP option.
internal/store/mail.go Persists provider type while retaining the existing write-only password during blank-password updates.

Reviews (7): Last reviewed commit: "small UI change" | Re-trigger Greptile

Comment thread web-ui/src/views/settings/OutgoingEmailSection.vue Outdated
Comment thread web-ui/src/views/settings/OutgoingEmailSection.vue Outdated
Self-review found one Block and two Notes.

Block: editing a Postmark account wiped its username. startEdit blanks
the password field, because an empty password means "keep the stored
one". The update path then copied that empty value across to the
username, which same_as_password keeps in step, and the server
overwrites username unconditionally. Editing a Postmark account's label
was enough to leave it with a stored password and no username, failing
AUTH on its next send with nothing in the UI to say why. A blank
password is no longer copied across.

blockedPortHint matched on the string prefix "connect: ", a silent
coupling to sendTestMail's error wording. Dial failures are now a typed
*dialError matched with errors.As, and a new test drives the real
sendTestMail against a closed port to assert the live path still
produces one — reverting the type makes that test fail.

The :open binding on the two details disclosures is correct only
because both blocks remount on change. That assumption is now written
next to the binding.

Also records in the progress entry that every preset host was probed
live with net/smtp: all sixteen hostnames, including all eight SES
regions, connect on the preset's port, complete STARTTLS with a valid
certificate for that name, and offer AUTH PLAIN.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
Comment thread web-ui/src/views/settings/OutgoingEmailSection.vue Outdated
onel added 9 commits August 27, 2026 22:51
The rule said to always implement in a git worktree. In practice that
leaves a second copy of the repo to find, test in and clean up, and the
finished branch has to be pulled back into the main folder anyway.

A worktree now covers only the case it exists for: uncommitted changes
a checkout would disturb. Otherwise branch off dev and work in place.
Keeps the guidance that a worktree, when needed, goes outside the repo
folder and is removed once the branch is pushed.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
The first pass at the outgoing-email form had three problems.

The provider cards were bordered text boxes that did not read as
clickable. They are now real buttons carrying the provider's logo above
its name, with hover, focus-visible and active states.

The first field said only "Name" and nothing said whose name. It is now
"Account name" with a hint saying it is malmo's own label for the
account and only the admin sees it. Every other input gained a real
label; no field relies on a placeholder to say what it is.

Two columns made the field pairing look meaningful when it was not, so
fields are one per line.

Logos are bundled rather than fetched: a box may have no internet, and
the dashboard must not call a CDN on render. Single-path monochrome
marks in currentColor, so they inherit the olive tokens. Postmark,
SMTP2GO and Google Workspace have no mark available and fall back to a
lettermark tile; custom is not a brand and gets the Lucide server
glyph. Paths come from simple-icons, whose icon data is CC0.

Styling reuses the repo's idioms instead of new ad-hoc classes: the
shared Button component for every action, and the labelled-stack field
pattern from CustomInstallView. "Advanced settings" is now "Server
settings", which says what is inside it.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
Adding an account was two states on one page, so the browser Back button
did nothing where it looked like it should. The two steps are now two
routes: /settings/mail/add picks a provider, /settings/mail/add/:preset
fills in the rest. The picked preset is read from the route rather than
held in a ref, which is what makes Back work. Field values stay out of
the URL — a credential must never land in browser history — so a reload
re-seeds the form from the preset. A :preset naming nothing falls back
to the picker.

The split moved the form rules into src/mailProviderForm.ts. The add
form and the inline edit form have to agree field for field, and they
now do so by construction rather than by two copies staying in step.

New POST /api/v1/mail-providers/verify checks a config the admin has
not saved yet: it takes the same body as create rather than an id, so a
provider that cannot connect never becomes an account someone has to
find and delete. It connects, does STARTTLS, authenticates and hangs
up, sending nothing. The add form runs it by default behind a "Test
configuration when adding" checkbox, before the elevation prompt.

It shares the dial-and-auth path with the test-send (connectMail)
rather than approximating it, so the check cannot drift from a real
send; it stops before MAIL FROM. That is also its limit: it cannot
prove the provider accepts the from address, which is why the per-row
test-send stays. Sending the check mail to a malmo-owned address was
rejected — a sandboxed SES or Mailgun account may only send to verified
recipients, so a fixed address would fail for the admins whose config
is fine, and bounces would spend the user's sender reputation.

dialError no longer prefixes "connect:", which was rendering as "could
not connect: connect: ... connect: connection refused". The type
carries that meaning now that errors.As matches it, not the string.

Logos are real colour marks in web-ui/src/assets/mail-providers/,
matched by preset id, so adding one is adding a file. The slot is a
fixed-height box with object-contain, never a fixed square, because the
folder holds both square icons and wide wordmarks. Missing marks fall
back to a lettermark tile.

An empty account list now shows an empty state with the paper airplane
and the Add account button, instead of a bare line of text.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
Account, Activity, Installed app detail and Users each hand-rolled the
same bordered pill with their own utility classes. They now use the
shared <Button> component, so the settings surfaces pick up one set of
hover, focus-visible and disabled states instead of four near-copies.

No behaviour change.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
architecture.md's per-package table had no internal/mailpreset row. That
table is the as-built map the rest of the docs tell you to trust over
any other package list, so a new package missing from it is the bug the
rule exists to prevent.

web-ui.md's folder tree was missing mailProviderForm.ts,
MailProviderLogo.vue and both outgoing-email views, with a pointer to
the assets/mail-providers README so adding a logo is findable from the
code map. OutgoingEmailSection.vue had been missing there since #122,
not just since this branch.

CLAUDE.md's internal/ list gains mailpreset. That list is explicitly
non-authoritative, but it costs one word to keep it right.

Claude-Session: https://claude.ai/code/session_01XHrpFqkLosCXFbKeCHoEdo
Em dashes read as a pause most people have to re-parse. Every user-visible
string on the outgoing-email pages and in the install dialog now uses a full
stop or a colon instead. Two of the strings live on the Go side: the
blocked-port hint the brain appends to a failed connection, and the body of
the test email itself.

No behaviour changes, and no test asserts on any of the reworded strings.

Claude-Session: https://claude.ai/code/session_01XNeparFYrzXctmyE51aqAE
The install dialog listed accounts as bare radio buttons with the account
name next to them. An account name is whatever the admin typed ("Work
mail"), so the list never said which provider actually sends the mail. It
is now a stack of cards: the provider logo, the account name, the whole
card as the hit target, and an outline on the selected one. "None" leads
and stays a normal choice.

This needed one field on the wire. MailProviderOption, the id+label shape
that the install plan and GET /api/v1/mail-providers/options both return,
gains provider_type. It names the preset a logo is looked up by, carries
no host and no credential, and so is safe on both non-admin surfaces.

Claude-Session: https://claude.ai/code/session_01XNeparFYrzXctmyE51aqAE
…rname

Two holes an automated review found. Both fail quietly.

The provider list comes from the server, and "Custom SMTP server" is one of
its entries, so a failed GET /api/v1/mail-presets left an empty grid with no
message and nothing to click. A deep link into /settings/mail/add/<preset>
was worse: it sat on "Loading…" forever, because the unknown-preset redirect
only fires once the list has arrived. Both paths now say the list could not
be loaded and offer a retry.

For a same_as_password preset (Postmark) the username is the server token.
The Advanced username field let an admin edit it on its own: harmless on the
add form, where it is overwritten, but on the edit form a blank password
means "keep the stored one", so the account was saved with a username the
credential no longer matches and started failing AUTH with nothing in the UI
to explain it. The field is now shown but disabled for that mode. A fixed
preset (SendGrid's literal apikey) stays editable, because there the value
is a real one an admin might need to override.

Claude-Session: https://claude.ai/code/session_01XNeparFYrzXctmyE51aqAE
Vite watched the UI; nothing watched the Go side. A brain edit only reached
the running process on the next make dev, and the symptom was never obvious.
A route added minutes ago answers 405. A new response field arrives
undefined. It cost more time to diagnose than the rebuild costs to run.

dev/dev-go.sh now runs the fake host-agent and the brain and rebuilds both
when a .go file changes. Three choices worth knowing:

- The debounce is 10 seconds, not the usual fraction of one. Most edits here
  arrive from a coding agent, which writes a burst of files over several
  seconds; a short debounce would restart the brain in the middle of one,
  again and again. MALMO_DEV_DEBOUNCE=2 for hand editing.
- The build goes to .dev/next/ first and only a build that succeeded stops
  anything, so a typo costs a "[watch] build failed" line, not the stack.
  Staging also avoids "text file busy": Linux will not write over a running
  executable.
- Shutdown sends TERM, waits up to 5 seconds, then KILLs, so a wedged child
  cannot hang the Ctrl-C that got you there.

Needs GNU find for -newermt, so Linux and WSL. Elsewhere it prints one line
and runs exactly as before.

Claude-Session: https://claude.ai/code/session_01XNeparFYrzXctmyE51aqAE
/>
</div>

<div v-if="!editPreset || editPreset.username_mode === 'user'" class="space-y-1.5">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Postmark username lock disappears

When /mail-presets is unavailable while an admin edits a Postmark account, editPreset is undefined, so this branch exposes the username and syncSameAsPassword does nothing. Saving a changed username with a blank password replaces the username while preserving the old token, causing later SMTP authentication to fail.

Knowledge Base Used: Access, setup, and system management API

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.

Settings → Outgoing email: pre-configured providers instead of a seven-field form

1 participant