Skip to content

A bad SMTP configuration lets /license/trial create a trial, then 500 #1963

Closed

Description

@MODSetter

The gap

docs/architecture/license/portal.md lists this under Known gaps:

A bad SMTP configuration raises on the first send, not at startup, so /license/trial can create the trial and then fail with 500.

Why it matters

The trial route is careful about every other failure. It checks mail is enabled before it does anything, answers 503 if Keygen is unreachable, 503 if the mail server is temporarily down and says the trial exists, and 400 if the recipient was refused. The one case it cannot see is its own misconfiguration: SmtpMailer.__init__ raises ValueError for a missing host, a missing sender or an SMTP_SECURITY that would send credentials in the clear, and the mailer is not built until the first send — which happens after the trial has been created in Keygen.

So the caller gets an opaque 500, and the address is spent: trialKey now carries their folded email, so trying again answers 409 "already claimed". One typo in a deployment's .env turns every first trial attempt into a burned address, and nothing in the deployment says so until a user hits it.

Where

What done looks like

  • A deployment with SMTP_ENABLED on and a broken SMTP configuration is visibly broken before it serves a request, or at the latest refuses the request before spending anything.
  • /license/trial on such a deployment never creates a trial it cannot deliver.
  • One decision, yours: fail the process at startup, or validate inside _require_mailer() so the route answers 503 like every other unavailable-mail case. Starting up loudly matches the factory's stated philosophy — mail being unreachable is "an error to fix, never a reason to silently drop mail" — while a 503 keeps the licence routes' habit of never turning an outage into an unhandled error. Pick one and say why; do not do both halfway.
  • Update the doc's "Mail" bullet that records this as a gap, and delete the Known gaps line in the same PR.

What not to change

_require_mailer() must keep running before any Keygen lookup, and /license/resend must keep answering identically whatever it finds. Both exist so the endpoint cannot be used to discover who is a customer, and a configuration error that reported differently depending on the address would undo that. Leave the two error types in protocol.py alone as well — a configuration fault is neither MailerUnavailableError nor MailerRejectedError, and forcing it into one of them loses the distinction between "our server is down" and "our settings are wrong".

How to verify

surfsense_backend/tests/unit/mailer/test_factory.py and tests/unit/mailer/test_smtp.py already cover the configuration checks and the error mapping. Add the failing test first: mail enabled, configuration bad, and assert the trial is never created.

cd surfsense_backend
uv run pytest -m unit
uv run ruff check .

Before you start

Read CONTRIBUTING.md, the license portal doc and ADR 0024. This code is under surfsense_backend/app/license/ and app/mailer/, not app/proprietary/, so it is open to contributors. Never put a real host, sender or credential in a test fixture, a log line or the PR description. Open your PR against dev, not main. Comment here to claim this.

Activity

  1. added
    bugSomething isn't working
    help wantedExtra attention is needed
    backendPython/FastAPI backend
    hostedHosted service, license portal, scraper API and wind-down
    on Sep 28, 2026
  2. mehul2409 commented on Sep 29, 2026

    @mehul2409
    Contributor

    I'd like to claim this one. I'll start with the failing test (mail enabled, bad SMTP configuration, trial never created) and then make the fix.

  3. added a commit that references this issue on Sep 29, 2026
    50ef1bd
  4. MODSetter commented on Sep 29, 2026

    @MODSetter
    OwnerAuthor

    Fixed by #2047, fix(license): answer 503 before Keygen when SMTP config is invalid, by @mehul2409. Merged into dev.

    Closing by hand rather than by keyword: Fixes #1963 only auto-closes on a merge to the default branch, and this repo merges through dev (CONTRIBUTING), so this would have stayed open and claimable while the work was already done. It reaches main with the next release.

    Nothing left to pick up here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendPython/FastAPI backendbugSomething isn't workinghelp wantedExtra attention is neededhostedHosted service, license portal, scraper API and wind-down

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions