Skip to content

feat(mailer): support a separate TCP relay address - #65

Merged
shockalotti merged 4 commits into
Calnode:mainfrom
marijnbent:feat/smtp-relay-address
Sep 21, 2026
Merged

shockalotti merged 4 commits into
Calnode:mainfrom
marijnbent:feat/smtp-relay-address

Conversation

@marijnbent

@marijnbent marijnbent commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Some hosts block direct SMTP connections but provide a TCP relay. This change lets Calnode connect to that relay while keeping the configured SMTP host for TLS verification and authentication.

EMAIL_SMTP_CONNECT_HOST and EMAIL_SMTP_CONNECT_PORT are optional connection overrides, parsed by internal/config and passed explicitly through SMTPConfig, BuildMailer, and NewSMTP. Each unset value uses the configured SMTP host or port. Overrides apply at startup with environment or database settings and remain active after settings reloads and during test emails. Active relays are logged. The relay must forward the connection without terminating TLS.

Updated with current main and resolved the SMTP message-validation merge conflict.

Validation:

  • pnpm install --frozen-lockfile, pnpm check, and pnpm build pass.
  • go vet ./..., go build ./..., and go test ./... pass.
  • Focused race checks pass for the relay tests.
  • Implicit TLS and STARTTLS tests verify relay connection, original-host SNI, and rejection of a failed handshake.
  • Integration tests verify environment/database startup and settings reload through the test-email endpoint. Separate tests cover configuration parsing and individual override defaults.

@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@marijnbent

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

@marijnbent
marijnbent marked this pull request as ready for review September 20, 2026 18:56
@pullfrog

pullfrog Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

This repo's model isn't fully set up yet.

OPENAI_COMPATIBLE_MODEL env var is required when the model is set to "openai-compatible/byok". set it to the model ID served by your OpenAI-compatible endpoint (e.g. a Cloudflare AI Gateway or DashScope model). see https://docs.pullfrog.com/openai-compatible for setup.

Configure model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using OpenAI-compatible | 𝕏

@marijnbent

Copy link
Copy Markdown
Contributor Author

recheck

@shockalotti

Copy link
Copy Markdown
Contributor

@pullfrog please review this PR

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — two minor suggestions inline.

Reviewed changes

  • internal/mailer relay dial target — SMTP gains connectHost/connectPort, defaulting to the configured host/port and overridable via EMAIL_SMTP_CONNECT_HOST / EMAIL_SMTP_CONNECT_PORT. Send dials the override, while TLS SNI/certificate verification and smtp.PlainAuth continue to use s.host (and newDialers still receives s.host).
  • DEPLOY.md — adds a short section stating the relay must forward SMTP without terminating TLS.
  • internal/mailer/smtp_relay_test.go — starts a TLS listener that rejects the handshake, points the connect override at it, and asserts both that the send fails and that the observed ClientHello ServerName is the configured SMTP host (smtp.example.com). The test is meaningful: it would fail if newDialers were switched to s.connectHost, or if the relay addr were not used.

The core change is correct: verification stays pinned to the configured host, so the relay cannot MITM, and the dial stays bounded by the existing deadline.

ℹ️ Nitpicks

  • The new test exercises only the implicit-TLS branch (implicitTLS=true). The STARTTLS branch pins ServerName from s.host separately (smtp.go:143), so it is not at risk here, but a second case through the STARTTLS path would lock the identity guarantee for that mode too.
  • docs/ARCHITECTURE.md §12 already describes the dial path in detail (including newDialers and why the dial is bounded). A one-line mention of the relay override there would keep that section the authoritative description of mail transport.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using opencode-go/deepseek-v4.1-flash | 𝕏

Comment thread internal/mailer/smtp.go Outdated
Comment thread DEPLOY.md Outdated

@shockalotti shockalotti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this — the dial-vs-identity split is done right (dial connectHost:connectPort, TLS ServerName/auth stay on s.host, and the SNI test proves it). Requesting one structural change before merge:

Read the env at the config layer, not inside NewSMTP. os.Getenv inside the constructor hides behavior from SMTPConfig/BuildMailer — the single place this repo decides which email path sends (see CLAUDE.md email rule). Consequences as written: the same stored config dials different hosts depending on process env, invisible to the settings UI and the test-email path, and unconstructable per-instance in tests. Suggested shape: add connectHost/connectPort fields to SMTPConfig, populate them from EMAIL_SMTP_CONNECT_HOST/EMAIL_SMTP_CONNECT_PORT where the config is built (handler/boot), and pass them into NewSMTP explicitly (params or options), keeping NewSMTP pure.

Smaller ask: log at startup/send time when a relay override is active, so “which path sent this?” stays answerable.

Happy to re-review once the config threading is in.

@marijnbent

Copy link
Copy Markdown
Contributor Author

Updated in 1a9fce3 and merged current main.

  • Moved environment reads to internal/config. SMTPConfig carries the relay address, and NewSMTP now receives it explicitly.
  • Preserved relay routing for environment/database startup, settings reloads, and test emails. Both startup paths now use BuildMailer for transport selection.
  • Added relay configuration logging, the environment reference entries, .env.example entries, and the architecture note.
  • Added STARTTLS coverage and integration tests for startup and settings reloads.

Frontend checks/build, Go vet/build/full tests, and focused relay race checks pass locally. Ready for another review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ The relay configuration now threads cleanly: NewSMTP is pure, the override flows through SMTPConfig / BuildMailer, and the boot + settings-reload integration test proves the TLS ServerName stays pinned to the configured SMTP host. One test-input nit inline.

Reviewed changes

  • Reworked NewSMTP to take connectHost/connectPort explicitly and default empty values to the SMTP host/port in the constructor (no more env reads).
  • Parsed the override in internal/config (SMTPConnectHost/SMTPConnectPort) and threaded it through SMTPConfig, a new Handler.BuildMailer, and boot/settings-save.
  • Routed both boot mailer paths through h.BuildMailer, which logs an active relay; documented the vars in DEPLOY.md §1 + troubleshooting, .env.example, and ARCHITECTURE.md §12.
  • Added config-parsing, NewSMTP-defaulting, implicit-TLS + STARTTLS identity, and server boot/reload integration tests.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using opencode-go/deepseek-v4.1-flash | 𝕏

Comment thread internal/server/smtp_relay_test.go Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Corrected the relay integration test's PATCH body to send email_from — the key PatchEmailSettings actually decodes — instead of the silently-ignored from_address, so the saved From is now populated rather than empty. This was the only open Pullfrog thread from the prior review; it is now addressed and resolved.

The preceding commit (1a9fce3) had already threaded the relay address through internal/config → SMTPConfig → BuildMailer, keeping NewSMTP pure, with TLS SNI/certificate verification and smtp.PlainAuth pinned to the configured SMTP host (smtp.go:111, smtp.go:123, smtp.go:144, smtp.go:153). This delta touches only the test assertion input and changes no production behavior.

Pullfrog  | View workflow run | Using opencode-go/deepseek-v4.1-flash | 𝕏

@shockalotti
shockalotti merged commit 245e725 into Calnode:main Sep 21, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants