# SMTP mail sending in internal/mail

## Context

project sends transactional mail (form confirmation) through `internal/mail.Client`, which today wraps Postmark only. `cmd/project/main.go:190` builds it from `cfg.Postmark` and hands it to `sub.NewHandler`, whose extension (`internal/sub/ext/confirmation.go`) builds `*postmark.Email` and calls `Send(ctx, req)`.

Add SMTP relay sending to `internal/mail` as a second backend, selected at deploy time via config. The sub layer (message construction, `EmailSender` interfaces in `internal/sub/handler.go`, `internal/sub/ext/ext.go`) is intentionally untouched: `Client.Send` keeps its signature and both backends share the same message input type.

## Approach

### 1. Add the SMTP library dependency

- `github.com/wneessen/go-mail v0.8.1`. First import site is `internal/mail/mail.go`, aliased `smail` (the library's package name is `mail`, same as ours).
- Order: write the import line, then `go mod tidy`, then `go mod vendor`.
- `go.mod` gains `github.com/wneessen/go-mail v0.8.1` in the direct require block. Its own deps (`golang.org/x/crypto v0.54.0`, `golang.org/x/text v0.40.0`) are already satisfied by existing versions in the module graph, so no other require changes.
- Grep `go.mod` afterward to confirm the require survived (per project dependency rule).
- Do NOT use the vendored `example.com/smtp-trier`: it is a recipient-deliverability probe used for validation, not a relay sender.

### 2. `internal/config` — extend the SMTP section

- `internal/config/types.go` — `SMTP` struct becomes (new fields first, existing kept):

  // SMTP config
  type SMTP struct {
      Host              string
      Port              int
      Login             string
      Password          string
      From              string
      EnforceValidation bool
  }

- `internal/config/config.go` — in `New()`, the `SMTP` literal becomes:

  SMTP: &SMTP{
      Host:              env.String("smtp.host"),
      Port:              env.Int("smtp.port"),
      Login:             env.String("smtp.login"),
      Password:          env.String("smtp.password"),
      From:              env.String("smtp.from"),
      EnforceValidation: env.Bool("smtp.validation"),
  },

- `internal/config/config_test.go` — add to the `values` map: `"SMTP_HOST": "smtp.test"`, `"SMTP_PORT": "587"`, `"SMTP_LOGIN": "login"`, `"SMTP_PASSWORD": "secret"`, `"SMTP_FROM": "from@test"`. In `TestNew` assert: `s.Equal("smtp.test", config.SMTP.Host)`, `s.Equal(587, config.SMTP.Port)`, `s.Equal("login", config.SMTP.Login)`, `s.Equal("secret", config.SMTP.Password)`, `s.Equal("from@test", config.SMTP.From)`.

### 3. `internal/mail/mail.go` — dual-backend client

Rewrite the file. Package doc comment text becomes "Package mail provides a client to send mail using Postmark or SMTP" (keep the `//nolint:var-naming` prefix on that line as today).

Imports (one grouped block, gofumpt order): `bytes`, `context`, `fmt`, `net/http`, blank, `github.com/mattevans/postmark-go`, `github.com/rs/zerolog`, `smail "github.com/wneessen/go-mail"`, blank, `example.com/project/internal/config`.

// Client to send mail
type Client struct {
	from    string
	replyto string
	sender  *postmark.Client
	smtp    *smail.Client
}

// New creates a mail client, preferring SMTP when it is configured
func New(cfg *config.Config) *Client {
	if c := newSMTP(cfg.SMTP); c != nil {
		return c
	}
	return newPostmark(cfg.Postmark)
}

func newSMTP(s *config.SMTP) *Client {
	if s == nil || s.Host == "" || s.From == "" {
		return nil
	}
	var opts []smail.Option
	switch {
	case s.Port == 465:
		opts = append(opts, smail.WithSSL())
	case s.Port > 0:
		opts = append(opts, smail.WithPort(s.Port))
	}
	if s.Login != "" && s.Password != "" {
		opts = append(opts,
			smail.WithSMTPAuth(smail.SMTPAuthAutoDiscover),
			smail.WithUsername(s.Login),
			smail.WithPassword(s.Password),
		)
	}
	sc, err := smail.NewClient(s.Host, opts...)
	if err != nil {
		return nil
	}
	return &Client{from: s.From, replyto: s.From, smtp: sc}
}

`newPostmark(p *config.Postmark) *Client` is the body of today's `New` with the token guard widened: return nil when `p == nil || p.Token == ""`; otherwise the exact existing `postmark.NewClient(postmark.WithClient(&http.Client{Transport: &postmark.AuthTransport{Token: p.Token}}))` construction, returned as `&Client{from: p.From, replyto: p.ReplyTo, sender: pm}`.

`Send` keeps its signature and keeps the `req.From = c.from` / `req.ReplyTo = c.replyto` overrides. Dispatch:

func (c *Client) Send(ctx context.Context, req *postmark.Email) error {
	log := zerolog.Ctx(ctx)

	req.From = c.from
	req.ReplyTo = c.replyto

	var err error
	if c.smtp != nil {
		err = c.sendSMTP(ctx, req)
		if err != nil {
			log.Error().Err(err).Msg("cannot send email")
		}
	} else {
		_, resp, err := c.sender.Email.Send(req)
		if err != nil {
			log.Error().Err(err).Any("response", resp).Msg("cannot send email")
		}
	}
	if err != nil {
		return err
	}

	log.Debug().Str("subject", req.Subject).Str("to", req.To).Msg("email has been sent")
	return nil
}

(The postmark error log line and the success log line stay byte-identical to today.)

func (c *Client) sendSMTP(ctx context.Context, req *postmark.Email) error {
	msg, err := c.buildMessage(req)
	if err != nil {
		return err
	}
	return c.smtp.DialAndSendWithContext(ctx, msg)
}

`buildMessage` — exact field mapping from `*postmark.Email` to `*smail.Msg`:

func (c *Client) buildMessage(req *postmark.Email) (*smail.Msg, error) {
	msg := smail.NewMsg()
	if err := msg.From(req.From); err != nil {
		return nil, fmt.Errorf("invalid from address: %w", err)
	}
	if err := msg.ToFromString(req.To); err != nil {
		return nil, fmt.Errorf("invalid to address: %w", err)
	}
	if req.Cc != "" {
		if err := msg.CcFromString(req.Cc); err != nil {
			return nil, fmt.Errorf("invalid cc address: %w", err)
		}
	}
	if req.Bcc != "" {
		if err := msg.BccFromString(req.Bcc); err != nil {
			return nil, fmt.Errorf("invalid bcc address: %w", err)
		}
	}
	if req.ReplyTo != "" {
		if err := msg.ReplyTo(req.ReplyTo); err != nil {
			return nil, fmt.Errorf("invalid reply-to address: %w", err)
		}
	}
	msg.Subject(req.Subject)
	msg.SetMessageID()
	msg.SetDate()
	msg.SetUserAgent("project")
	for _, h := range req.Headers {
		msg.SetHeaderPreformatted(smail.Header(h.Name), h.Value)
	}

	switch {
	case req.TextBody != "" && req.HTMLBody != "":
		msg.SetBodyString(smail.TypeTextPlain, req.TextBody)
		msg.AddAlternativeString(smail.TypeTextHTML, req.HTMLBody)
	case req.TextBody != "":
		msg.SetBodyString(smail.TypeTextPlain, req.TextBody)
	case req.HTMLBody != "":
		msg.SetBodyString(smail.TypeTextHTML, req.HTMLBody)
	}

	for _, att := range req.Attachments {
		ct := smail.TypeAppOctetStream
		if att.ContentType != nil {
			ct = smail.ContentType(*att.ContentType)
		}
		if err := msg.AttachReader(att.Name, bytes.NewReader(att.Content), smail.WithFileContentType(ct)); err != nil {
			return nil, fmt.Errorf("invalid attachment %s: %w", att.Name, err)
		}
	}

	return msg, nil
}

Mapping rules (decisions, not optional):
- `To`/`Cc`/`Bcc` are comma-separated strings in the postmark type, so the `*FromString` variants are used; `From` and `ReplyTo` are single-address calls.
- Both text and html set → multipart/alternative (plain text primary, HTML alternative). One body set → single part. Neither → no body.
- `req.Headers` → custom headers via `SetHeaderPreformatted(smail.Header(name), value)`.
- Attachments mapped one-to-one; nil `ContentType` defaults to `application/octet-stream`.
- Postmark-only fields (`TemplateID`, `TemplateModel`, `TrackOpens`, `MessageStream`, `Metadata`, `Tag`) are ignored on the SMTP path.
- Envelope sender: go-mail derives MAIL FROM from the From header when no Envelope-From is set, so no explicit `EnvelopeFrom` call is needed.

### 4. `cmd/project/main.go`

Line 190 `pm := mail.New(cfg.Postmark.Token, cfg.Postmark.From, cfg.Postmark.ReplyTo)` becomes `pm := mail.New(cfg)`. The `mail` import stays. Nothing else in main.go changes.

### 5. `internal/mail/mail_test.go` — rewrite (package `mail`, internal test package as today)

Keep the `mailSuite`/`TestMail` testify suite scaffolding. Replace the two existing tests and add:

- `TestNew_Postmark`: `New(&config.Config{Postmark: &config.Postmark{Token: "test", From: "test@example.com", ReplyTo: "test@example.com"}})` → `s.IsType(&Client{}, c)`, `s.NotNil(c.sender)`, `s.Nil(c.smtp)`.
- `TestNew_Smtp`: `New(&config.Config{SMTP: &config.SMTP{Host: "smtp.test", Port: 587, From: "from@test"}})` → `s.NotNil(c)`, `s.NotNil(c.smtp)`, `s.Nil(c.sender)`.
- `TestNew_SmtpPreferred`: both Postmark and SMTP configured → `s.NotNil(c.smtp)`, `s.Nil(c.sender)`.
- `TestNew_SmtpMissingFrom`: `SMTP{Host: "smtp.test"}` plus a Postmark token → `s.Nil(c.smtp)`, `s.NotNil(c.sender)` (SMTP gate requires Host and From).
- `TestNew_Empty`: `New(&config.Config{})` → `s.Nil(c)`.
- `TestSend_Smtp`: end-to-end send against an in-process plain SMTP stub:
  - `smtpStub` struct with `l net.Listener` and `data []byte`; `newSMTPStub(t)` listens on `127.0.0.1:0`, starts a `serve` goroutine, registers `t.Cleanup(func() { l.Close() })`.
  - `serve`: accept one connection, write `220 stub ESMTP`, then loop on lines: `EHLO`/`HELO` → `250-stub\r\n250 OK\r\n` (multi-line, no STARTTLS/AUTH advertised); `MAIL FROM:` / `RCPT TO:` → `250 OK`; `DATA` → `354 go ahead`, read lines into `data` until the terminating `.\r\n`, then `250 OK`; `QUIT` → `221 bye`; `RSET`/`NOOP` → `250 OK`; anything else → `502 not implemented`.
  - Port from `stub.l.Addr().(*net.TCPAddr).Port`.
  - Client: `smail.NewClient("127.0.0.1", smail.WithPort(port), smail.WithTLSPolicy(smail.NoTLS))`. `NoTLS` is required because the library default `TLSMandatory` errors on a server that does not advertise STARTTLS; production `New` never uses `NoTLS`. Wrap: `c := &Client{from: "from@test", replyto: "from@test", smtp: sc}`.
  - `err := c.Send(s.T().Context(), &postmark.Email{To: "to@test", Subject: "hello", TextBody: "text body", HTMLBody: "<b>text body</b>"})` → `s.Require().NoError(err)`.
  - Assert `string(stub.data)` contains: `From: from@test`, `To: to@test`, `Reply-To: from@test`, `Subject: hello`, `Content-Type: multipart/alternative`, `text body`, `<b>text body</b>`.
  - No `time.Sleep`: `Send` returns only after the stub has replied 250 to DATA, which happens after `data` is captured, so the read is race-free.

No other test file changes: `confirmation_test.go` and `handler_test.go` mock the `EmailSender` interface and do not construct `mail.Client` (verified by grep).

## Critical files & anchors

- `internal/mail/mail.go` — the whole client is rewritten; `Send` at the dispatch seam, `buildMessage` holds the postmark→RFC822 mapping.
- `cmd/project/main.go:190` — the only `mail.New` callsite in the repo.
- `internal/config/config.go:56` — the `SMTP` literal in `New()` that gains 4 fields.
- `vendor/github.com/wneessen/go-mail` (after step 1) — verify `smail.NoTLS`, `WithSSL`, `WithPort`, `SMTPAuthAutoDiscover`, `DialAndSendWithContext`, `SetBodyString`, `AddAlternativeString`, `AttachReader`, `WithFileContentType` with `go doc` if any name does not resolve (all verified against v0.8.1 source).

## Verification

From the repo root (justfile recipes):

1. `just test` — full suite green, including the rewritten mail suite and config suite.
2. Targeted proof of the new behavior: `go test -v -run TestMail ./internal/mail` — `TestSend_Smtp` captures the real RFC822 payload delivered to an SMTP server and asserts From/To/Reply-To/Subject and the multipart/alternative body.
3. `go test -v -run TestConfigSuite ./internal/config` — new `smtp.*` fields parse.
4. `just lint` — comment-guard, swagger, golangci-lint clean. This change adds no HTTP endpoints and no swagger annotations, so `docs/` should stay untouched; if a lint run modifies `docs/`, that diff is generated output and belongs to the commit.

## Assumptions & contingencies

- Backend selection: SMTP wins when `smtp.host` and `smtp.from` are both set, even if a Postmark token is also set; operators who want Postmark must leave `smtp.host` empty.
- `smtp.port` semantics: 0/unset → library default (port 587, STARTTLS mandatory); 465 → implicit TLS (`WithSSL`); any other port → `WithPort` with the library default mandatory-TLS policy (STARTTLS). Plaintext relays are intentionally unsupported (fail closed).
- SMTP `Reply-To` is the `smtp.from` address; no separate env var.
- Auth: LOGIN/PLAIN/CRAM-MD5/SCRAM auto-discovered when both `smtp.login` and `smtp.password` are set; no auth otherwise (for in-network open relays).
- If `smail.NewClient` errors (only possible with an out-of-range `smtp.port`), `New` falls through to Postmark if configured, else nil; the bad value remains visible in the config.
