feat: add production SMTP email sender wrapping go-mail#245
Conversation
Add SMTPEmailSender in pkg/email/ wrapping wneessen/go-mail library for production email delivery. Auth features (magic links, password reset, email confirmation) now use env-based sender selection via EMAIL_PROVIDER. New files: - pkg/email/smtp.go: SMTPEmailSender with TLS, auto-discovered auth - pkg/email/factory.go: NewEmailSenderFromEnv() factory (console/smtp/noop) - pkg/email/smtp_test.go, factory_test.go: comprehensive unit tests Changes: - handler.go.tmpl: replace 6x hardcoded NewConsoleEmailSender() with env-based newEmailSender() helper - internal/generator/auth.go: ProtectResources uses NewEmailSenderFromEnv() - commands/env.go: add EMAIL_FROM to required SMTP vars validation - Delete dead email.go.tmpl (never loaded by generator) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: add production SMTP email sender wrapping go-mailOverall this is a well-structured addition — clean interface preservation, good fail-fast behavior, and solid test coverage. A few issues worth addressing before merge. Bugs / Correctness
Running
// Instead of:
os.Unsetenv("EMAIL_PROVIDER")
// Use:
t.Setenv("EMAIL_PROVIDER", "")(or restructure so the unset is handled via PerformanceNew SMTP client and TCP/TLS connection created per Security
// TLSOpportunistic upgrades to TLS when available but allows plaintext fallback.
// For strict TLS enforcement in production, change to mail.TLSMandatory.
mail.WithTLSPortPolicy(mail.TLSOpportunistic),Code QualityDuplicated Multiple email sender instances created at startup Import insertion ordering in Minor Nits
Summary
Good work overall — the fail-fast approach, env-based provider selection, and backwards-compatible default are all solid design choices. 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Pull request overview
Adds a production-capable SMTP-backed email sender to pkg/email, plus an env-driven factory for selecting an email provider, and updates generated auth/generator code to use the factory instead of hardcoding the console sender.
Changes:
- Introduce
SMTPEmailSender(wrappingwneessen/go-mail) and env factories (NewSMTPEmailSenderFromEnv,NewEmailSenderFromEnv) inpkg/email. - Update auth templates and
ProtectResourcesinjection to initialize the sender viaEMAIL_PROVIDERat startup. - Extend
lvt envSMTP validation/reasons and remove the unusedemail.go.tmplauth email template.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/email/smtp.go | New SMTP sender implementation using go-mail. |
| pkg/email/smtp_test.go | Unit tests for SMTP sender defaults/config wiring. |
| pkg/email/factory.go | Env-based factory for selecting console/smtp/noop providers. |
| pkg/email/factory_test.go | Tests for env switching and SMTP env error cases. |
| internal/kits/system/multi/templates/auth/handler.go.tmpl | Generated auth handlers now build email sender from env (fail-fast). |
| internal/kits/system/multi/templates/auth/email.go.tmpl | Deletes duplicate/unused email template code. |
| internal/generator/auth.go | ProtectResources injection now uses env-based sender and adds log import when needed. |
| internal/generator/auth_test.go | Updates assertions/fixtures to match env-based sender initialization. |
| commands/env.go | Adds EMAIL_FROM to SMTP validation and updates env var reasons. |
| go.mod | Adds go-mail dependency entry. |
| go.sum | Adds checksums for go-mail. |
| // - Set BASE_URL environment variable for email links (default: http://localhost:8080) | ||
| // - By default, emails are logged to console. Replace email.NewConsoleEmailSender() | ||
| // in Handler() with your production email sender. | ||
| // - Set EMAIL_PROVIDER=smtp and configure SMTP_* env vars for production email sending. |
| func TestNewEmailSenderFromEnv_DefaultConsole(t *testing.T) { | ||
| os.Unsetenv("EMAIL_PROVIDER") | ||
| sender, err := NewEmailSenderFromEnv() | ||
| if err != nil { |
| github.com/sethvargo/go-retry v0.3.0 // indirect | ||
| github.com/tdewolff/minify/v2 v2.24.8 // indirect | ||
| github.com/tdewolff/parse/v2 v2.8.5 // indirect | ||
| github.com/wneessen/go-mail v0.7.2 // indirect |
|
|
||
| // If SMTP, check required SMTP vars | ||
| if emailProvider == "smtp" { | ||
| smtpVars := []string{"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS"} | ||
| smtpVars := []string{"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS", "EMAIL_FROM"} |
| smtpVars := []string{"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASS", "EMAIL_FROM"} | ||
| for _, key := range smtpVars { |
- Extract newController() helper in handler.go.tmpl, eliminating 6x repeated baseURL + emailSender + controller init (30 lines removed) - Add "noop" to valid EMAIL_PROVIDER list in env.go validation - Fix .env.example comment: "console, smtp, or noop" (was "custom") - Replace os.Unsetenv with t.Setenv in factory tests for isolation - Anchor "log" import check in auth.go to avoid false positives on "log/slog", "catalog", etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: add production SMTP email senderOverall this is a clean, well-scoped implementation. Good use of the factory pattern, sensible defaults, and solid test coverage. A few issues worth addressing: Bug: go-mail marked as indirect in go.modThe line Security concern: TLSOpportunistic in production
Fragile import detection in auth.gohasLog := strings.Contains(mainContent, "\t\"log\"\n") || strings.Contains(mainContent, "\t\"log\"\r\n")This produces a false negative (log import not detected, duplicate gets inserted) if:
A safer approach: extract the import block first and search within it, or scope the search to just the import section. New SMTP client created per Send() callIn Minor: newController() initialises a fresh email sender per handler factoryEach of Missing Send() test for SMTPEmailSender
Nit: EMAIL_FROM_NAME reason string does not indicate it is optionalConsider What is good
The two items to address before merge: go-mail marked as indirect in go.mod (correctness) and fragile log import detection in auth.go (potential codegen bug that could produce uncompilable output). The TLS note is important for security-conscious users but less blocking. |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Code Review: feat: add production SMTP email sender wrapping go-mail. Overall this is a well-structured PR. Full review text follows in replies. |
Issues to Fix Before Merge1. go-mail is marked indirect in go.mod github.com/wneessen/go-mail v0.7.2 is directly imported by pkg/email/smtp.go and should not carry the // indirect marker. Running go mod tidy will correct this automatically. 2. SMTP port validation does not reject out-of-range values In factory.go, strconv.Atoi accepts any integer including 0, -1, or 99999. A port outside 1-65535 will not be caught until the first dial attempt, producing a confusing send-time error instead of a startup error. A range check after parsing closes this gap. |
Design and Performance Observations3. Each handler factory independently constructs its own EmailSender In handler.go.tmpl, each factory (Handler, LogoutHandler, MagicLinkHandler, etc.) independently calls newController(queries), which independently calls NewEmailSenderFromEnv() and reads os.Getenv. In practice this is fine at startup, but senders can diverge if env vars differ between calls (e.g. in parallel tests). Constructing the sender once in main and passing it as a parameter is the cleaner approach. 4. log.Fatalf in newController makes the error path untestable newController calls log.Fatalf on error. The PR description justifies fail-fast at startup, which is acceptable. However this makes the error path impossible to test without process-level tricks. The generated ProtectResources code injects the same pattern. At minimum, add a comment that newController is only safe at program startup, not during request handling. 5. New SMTP client per Send call SMTPEmailSender.Send creates mail.NewClient and calls DialAndSend on every invocation: a new TCP connection and TLS handshake per email. Fine for infrequent auth emails. A comment in smtp.go flagging the connection-reuse opportunity would help future contributors. 6. Import injection condition is subtly misleading In auth.go (ProtectResources), the outer condition enters the block if either import is missing and the inner code handles each independently, so the logic is correct. But the outer OR may confuse future editors who expect AND. A brief comment or two separate guards would make the intent clearer. |
Security Observations7. TLSOpportunistic can silently fall back to plaintext in production TLSOpportunistic is the right default for local dev tools like Mailpit, but in production it falls back to an unencrypted connection if TLS negotiation fails, potentially exposing SMTP credentials and magic-link tokens. Consider adding a SMTP_TLS env var (opportunistic / mandatory / none) so operators can enforce TLSMandatory. At minimum, add a warning comment in the generated .env file next to SMTP_HOST. 8. Plain-text only is an undocumented interface constraint SMTPEmailSender hardcodes mail.TypeTextPlain. The EmailSender interface doc does not mention this. If a future template generates HTML, callers will silently deliver plain text. A one-line note on the EmailSender interface would prevent this confusion. 9. SMTP password stored as plain string in SMTPConfig Standard Go practice, so no action needed unless the project has a policy on in-memory secret handling. |
Test CoverageThe factory tests are thorough: default/console/noop/smtp/missing-host/missing-from/invalid-port/unknown-provider paths are all covered. Small gap: EMAIL_FROM_NAME propagation is never asserted. The field is read from env and assigned to SMTPConfig.FromName, but no test verifies it. Easy to add alongside TestNewEmailSenderFromEnv_SMTP. smtp_test.go only tests construction, not Send. A round-trip test against a loopback SMTP mock would catch message-assembly bugs. Nice-to-have, not a blocker. Generator test update is correct - the auth_test.go idempotency fixture is properly updated to use NewEmailSenderFromEnv. |
SummarySolid implementation. Two items most worth fixing before merge:
Everything else is a documentation improvement or future-iteration concern. Backward compatibility (defaulting to console) is clean and the test suite is well-structured. Generated with Claude Code |
Generated apps reference email.NewEmailSenderFromEnv() which only exists in the local (unreleased) version of pkg/email. Add a go.mod replace directive pointing to the local project root so E2E tests can build generated apps that use unreleased pkg/ changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code ReviewOverall this is a clean, well-structured PR. The design decisions are sound and well-justified. A few things worth addressing. go.mod: indirect vs direct dependency
smtp.go: new TCP connection per Send() call The SMTP client is created fresh on every auth.go: import group formatting when both imports are missing When handler.go.tmpl: newController() called once per handler type With 6 handler factories, Minor: port range validation missing
if port < 1 || port > 65535 {
return nil, fmt.Errorf("email: SMTP_PORT %d out of valid range (1-65535)", port)
}Security note: TLSOpportunistic in production The PR description explains this choice well (Mailpit compat). Worth a brief code comment next to What's done well
The |
This reverts commit eb9adb8.
PR Review: feat: add production SMTP email sender wrapping go-mailOverall this is a solid, well-structured addition. The factory pattern is clean, the Bug:
|
Move EMAIL_PROVIDER switching logic from pkg/email/factory.go into the handler template directly. This avoids generated apps depending on unreleased pkg/email functions (NewEmailSenderFromEnv, NewSMTPEmailSender) which don't exist in the published module yet. The template now switches on EMAIL_PROVIDER inline using only exported types (SMTPConfig, NewSMTPEmailSender, NewConsoleEmailSender, NewNoopEmailSender). The pkg/email factory functions remain available as library code for direct use. ProtectResources reverts to using NewConsoleEmailSender() since it only needs a sender for the controller constructor (doesn't send emails). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: add production SMTP email sender wrapping go-mailOverall this is a solid, well-scoped addition. Interface is preserved, test coverage is good, and the design decisions are reasonable. A few things worth addressing before merge. Bug:
|
| Issue | Severity |
|---|---|
go-mail marked // indirect |
Low (tooling) |
Template bypasses NewEmailSenderFromEnv() validation |
Medium (behavior doesn't match documented intent) |
TLSOpportunistic needs comment for operators |
Low |
| New connection per Send | Info |
EMAIL_FROM_NAME reason map comment |
Nit |
The validation gap in handler.go.tmpl is the most important to fix — everything else is polish. Good work overall on the cleanup of the 6 duplicated handler factories.
Generated apps import the published version of github.com/livetemplate/lvt which doesn't yet include NewSMTPEmailSender or SMTPConfig. The template must only use exports that exist in the released module. The template now uses NewConsoleEmailSender() with documentation pointing users to pkg/email for SMTP configuration. After the next release, the template can be updated to use NewEmailSenderFromEnv() for automatic env-based provider selection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR ReviewThere's a critical bug that undermines the PR's stated goal, plus a few secondary issues. 🐛 Critical: Template still hardcodes
|
- go mod tidy: go-mail is now correctly marked as direct (not indirect) since pkg/email/smtp.go imports it directly - Add explanatory comment about TLSOpportunistic policy and how to switch to TLSMandatory for strict production enforcement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: add production SMTP email senderOverall this is a clean, well-structured PR. The go-mail wrapper is a good choice, the newController() refactor eliminates real duplication, and the factory pattern is intuitive. A few things worth addressing: Bug / CorrectnessPort range not validated (pkg/email/factory.go:28-35) strconv.Atoi will happily parse 0, -1, or 99999 as a valid port. These fail silently at dial time with a confusing OS-level error. Consider adding a bounds check after parsing (e.g., if port < 1 or port > 65535, return an error describing the out-of-range value). A test case alongside TestNewEmailSenderFromEnv_SMTP_InvalidPort would be straightforward to add. PerformanceNew SMTP client per Send() call (pkg/email/smtp.go) Every Send() dials a fresh TCP connection, negotiates TLS, authenticates, sends, and tears down. For low-volume transactional email (magic links, password resets) this is perfectly fine. Worth a brief note in the struct doc so users with higher-volume needs know they would need a different approach. SecurityTLSOpportunistic trade-off (pkg/email/smtp.go) The comment already explains this well and points to mail.TLSMandatory. No action needed. An optional future enhancement: a TLSPolicy field on SMTPConfig to let callers enforce mandatory TLS without forking Send(). Code QualitynewController() comment vs. reality (handler.go.tmpl) The godoc says "Called once per handler factory at startup", but each handler function calls newController() independently, creating separate controller instances. Functionally fine, but the comment implies shared state that does not exist. Suggested wording: EMAIL_FROM_NAME in getVarReason getVarReason is used for required vars, but EMAIL_FROM_NAME is optional. Harmless as a lookup-map entry, but a clarifying comment would help future maintainers. Test Coverage
Minor NitgenerateEnvContent now validates EMAIL_FROM as required for SMTP, but the generated .env template does not include an EMAIL_FROM= placeholder in the SMTP section. Users will hit the validator without knowing what to add. A commented placeholder would improve onboarding. The email.go.tmpl deletion is correct, good cleanup. Summary: Solid implementation. The one actionable item is port range validation + a test for it. Everything else is a suggestion rather than a blocker. |
- Add port range validation (1-65535) in NewSMTPEmailSenderFromEnv - Add TestSMTPEmailSender_Send_DialError to verify error wrapping - Add TestNewEmailSenderFromEnv_SMTP_PortOutOfRange test - Fix newController() godoc to match reality (independent calls, not shared) - Add connection-per-send note to SMTPEmailSender struct doc - Clarify EMAIL_FROM_NAME as optional in getVarReason - Mark EMAIL_FROM as required in .env template comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code ReviewOverall this is a clean, well-structured PR. The newController() refactor alone is a solid improvement, and the SMTP implementation is sensibly wrapped with good test coverage. A few things worth addressing: Bug / Inconsistency: validateValues vs NewSMTPEmailSenderFromEnv disagree on required SMTP vars commands/env.go:780 treats SMTP_USER and SMTP_PASS as required when EMAIL_PROVIDER=smtp. But pkg/email/factory.go treats SMTP_USER/SMTP_PASS as optional (reads them without validation). This means Dead / misleading entry in getVarReason commands/env.go:739 adds EMAIL_FROM_NAME with reason "sender display name (optional, not required)". EMAIL_FROM_NAME never appears in getRequiredVars(), so this map entry is unreachable dead code. Having "optional, not required" in a map whose purpose is to explain why things are required is also contradictory. This entry should be removed. Security: TLSOpportunistic as the default The comment in pkg/email/smtp.go explains the tradeoff well, but production users need a way to enforce strict TLS without editing generated code. A TLSPolicy field in SMTPConfig or an SMTP_TLS_POLICY env var in NewSMTPEmailSenderFromEnv would give them that control. At minimum, a note in the godoc that TLS is opportunistic by default would help users know to review this before deploying to production. Minor: getRequiredVars vs validateValues asymmetry getRequiredVars lists only EMAIL_PROVIDER for email features, but validateValues conditionally enforces 5 more SMTP vars. A user with EMAIL_PROVIDER=smtp and no SMTP_HOST set would pass the required-vars check but fail validation — a confusing UX. Worth documenting the two-phase approach or unifying the logic. Nit: newController template comment The comment tells users to replace NewConsoleEmailSender() with email.NewSMTPEmailSender(). Since NewEmailSenderFromEnv() is the intended future default, pointing users there post-release would be more consistent with the stated roadmap. What is good
|
Summary
SMTPEmailSenderinpkg/email/wrappingwneessen/go-mailfor production email deliveryNewEmailSenderFromEnv()factory that selects sender based onEMAIL_PROVIDERenv var (console/smtp/noop)newController()helper in auth handler template to eliminate 6x repeated init codeEMAIL_FROMto required SMTP env var validation inlvt envnoopto validEMAIL_PROVIDERvalues in env validationemail.go.tmpl(never loaded by generator, duplicatedpkg/email/)Template approach (intentional)
The handler template uses
email.NewConsoleEmailSender()as default. It does NOT callNewEmailSenderFromEnv()because generated apps import the published version ofpkg/emailwhich doesn't include the new factory/SMTP exports yet. After the next release, a follow-up PR will update the template to useNewEmailSenderFromEnv()for automatic env-based provider selection.Users who want SMTP now can manually replace
NewConsoleEmailSender()withNewSMTPEmailSender(email.SMTPConfig{...})in their generatedauth.go. Thepkg/emaillibrary code is ready for this.Design decisions
go-mailrather than rawnet/smtp— handles TLS/STARTTLS, LOGIN auth, MIME correctlySMTPAuthAutoDiscover— auto-negotiates PLAIN/CRAM-MD5/LOGIN based on server capabilitiesTLSOpportunistic— uses TLS when available, doesn't refuse without it (dev SMTP tools like Mailpit don't support TLS)Send(to, subject, body) errorinterface — no breaking changesconsolewhenEMAIL_PROVIDERis unset — existing apps continue working unchangedTest plan
go test ./pkg/email/...— 17 tests (smtp config, factory env switching, error cases)go test ./internal/generator/...— auth_test assertions passgo test ./commands/...— env validation tests passgo test ./...— full suite (26/26 packages pass)🤖 Generated with Claude Code