From 1dbbd956e3deaa943c62ce7a963837030ce99aa7 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 29 Aug 2026 23:34:51 +0200 Subject: [PATCH 1/9] chore(mailkit): scaffold the module --- mailkit/.golangci.yml | 50 +++++++++++++++++++++++++++++++++++++++++++ mailkit/CHANGELOG.md | 11 ++++++++++ mailkit/doc.go | 4 ++++ mailkit/go.mod | 5 +++++ 4 files changed, 70 insertions(+) create mode 100644 mailkit/.golangci.yml create mode 100644 mailkit/CHANGELOG.md create mode 100644 mailkit/doc.go create mode 100644 mailkit/go.mod diff --git a/mailkit/.golangci.yml b/mailkit/.golangci.yml new file mode 100644 index 0000000..9c99924 --- /dev/null +++ b/mailkit/.golangci.yml @@ -0,0 +1,50 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + mailer-purity: + list-mode: strict + files: + allow: + - $gostd + - github.com/wneessen/go-mail + - github.com/mocktools/go-smtp-mock/v2 + - github.com/gopherium/framework/mailkit + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework diff --git a/mailkit/CHANGELOG.md b/mailkit/CHANGELOG.md new file mode 100644 index 0000000..9e24a4c --- /dev/null +++ b/mailkit/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to the `mailkit` module are documented in this +file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +module follows [Semantic Versioning](https://semver.org/). While at +v0.x, minor releases may contain breaking changes. + +Releases of this module are tagged `mailkit/vX.Y.Z`. + +## [Unreleased] diff --git a/mailkit/doc.go b/mailkit/doc.go new file mode 100644 index 0000000..d59b63e --- /dev/null +++ b/mailkit/doc.go @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package mailkit renders and sends application mail. +package mailkit diff --git a/mailkit/go.mod b/mailkit/go.mod new file mode 100644 index 0000000..a38e7f6 --- /dev/null +++ b/mailkit/go.mod @@ -0,0 +1,5 @@ +module github.com/gopherium/framework/mailkit + +go 1.25.7 + +toolchain go1.26.7 From 1d0e448f9709e21e6d1286b9f81e25da63f6a828 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 29 Aug 2026 23:35:07 +0200 Subject: [PATCH 2/9] feat(mailkit): render mail from template files with an operator override --- mailkit/mailkit.go | 30 ++++ mailkit/templates.go | 85 ++++++++++ mailkit/templates_test.go | 317 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 mailkit/mailkit.go create mode 100644 mailkit/templates.go create mode 100644 mailkit/templates_test.go diff --git a/mailkit/mailkit.go b/mailkit/mailkit.go new file mode 100644 index 0000000..cb0e25d --- /dev/null +++ b/mailkit/mailkit.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mailkit + +import ( + "context" + "errors" +) + +// Message is one mail to one recipient. +type Message struct { + To string + Subject string + Body string +} + +// Sender delivers one message. +type Sender interface { + // Send delivers m, refusing an empty To with ErrNoRecipient. + Send(ctx context.Context, m Message) error +} + +// ErrNoRecipient reports a message whose To is empty. +var ErrNoRecipient = errors.New("mailkit: no recipient") + +// ErrInvalidRecipient reports an address the transport refuses. +var ErrInvalidRecipient = errors.New("mailkit: invalid recipient") + +// ErrNoSubject reports a template whose rendered output is empty. +var ErrNoSubject = errors.New("mailkit: no subject") diff --git a/mailkit/templates.go b/mailkit/templates.go new file mode 100644 index 0000000..5cc7860 --- /dev/null +++ b/mailkit/templates.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mailkit + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Templates renders mail from template files, preferring an override directory. +type Templates struct { + defaults fs.FS + overrideDir string +} + +// NewTemplates returns Templates over the embedded defaults and an optional override directory. +func NewTemplates(defaults fs.FS, overrideDir string) (*Templates, error) { + if defaults == nil { + return nil, errors.New("mailkit: nil defaults filesystem") + } + if overrideDir != "" { + info, err := os.Stat(overrideDir) + if err != nil { + return nil, fmt.Errorf("mailkit: override directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("mailkit: override directory %s is not a directory", overrideDir) + } + } + return &Templates{defaults: defaults, overrideDir: overrideDir}, nil +} + +// Render executes the named template with data, answering a Message whose To the caller fills. +func (t *Templates) Render(name string, data any) (Message, error) { + raw, err := t.read(name) + if err != nil { + return Message{}, err + } + parsed, err := template.New(name).Parse(string(raw)) + if err != nil { + return Message{}, fmt.Errorf("mailkit: parse template %s: %w", name, err) + } + var out strings.Builder + if err := parsed.Execute(&out, data); err != nil { + return Message{}, fmt.Errorf("mailkit: render template %s: %w", name, err) + } + return split(name, out.String()) +} + +// read answers the named file from the override directory, else the defaults. +func (t *Templates) read(name string) ([]byte, error) { + if !fs.ValidPath(name) || name == "." || strings.Contains(name, "/") { + return nil, fmt.Errorf("mailkit: template name %q: %w", name, fs.ErrInvalid) + } + if t.overrideDir != "" { + raw, err := os.ReadFile(filepath.Join(t.overrideDir, name)) + if err == nil { + return raw, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("mailkit: read override %s: %w", name, err) + } + } + raw, err := fs.ReadFile(t.defaults, name) + if err != nil { + return nil, fmt.Errorf("mailkit: template %s: %w", name, err) + } + return raw, nil +} + +// split cuts rendered output into the subject line and the body. +func split(name, rendered string) (Message, error) { + rendered = strings.TrimPrefix(rendered, "\ufeff") + rendered = strings.TrimLeft(rendered, " \t\r\n") + if rendered == "" { + return Message{}, fmt.Errorf("mailkit: render template %s: %w", name, ErrNoSubject) + } + subject, body, _ := strings.Cut(rendered, "\n") + return Message{Subject: strings.TrimRight(subject, "\r"), Body: body}, nil +} diff --git a/mailkit/templates_test.go b/mailkit/templates_test.go new file mode 100644 index 0000000..d04dc2f --- /dev/null +++ b/mailkit/templates_test.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mailkit_test + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/gopherium/framework/mailkit" +) + +// defaultsWith returns an in memory defaults filesystem holding one invite template. +func defaultsWith(content string) fstest.MapFS { + return fstest.MapFS{"invite.tmpl": &fstest.MapFile{Data: []byte(content)}} +} + +// templates builds Templates over the given sources, failing the test on error. +func templates(t *testing.T, defaults fs.FS, overrideDir string) *mailkit.Templates { + t.Helper() + held, err := mailkit.NewTemplates(defaults, overrideDir) + if err != nil { + t.Fatalf("NewTemplates() error = %v, want nil", err) + } + return held +} + +func TestRenderFillsSubjectAndBodyFromTheTemplate(t *testing.T) { + t.Parallel() + + defaults := defaultsWith("You are invited, {{.Name}}\nHello {{.Name}}, set your password.") + tpls := templates(t, defaults, "") + + m, err := tpls.Render("invite.tmpl", map[string]string{"Name": "Maria Perez"}) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "You are invited, Maria Perez" { + t.Errorf("subject = %q, want the rendered first line", m.Subject) + } + if m.Body != "Hello Maria Perez, set your password." { + t.Errorf("body = %q, want the rendered remainder", m.Body) + } + if m.To != "" { + t.Errorf("to = %q, want empty for the caller to fill", m.To) + } +} + +func TestRenderSplitsAtTheFirstNewline(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Subject line\nFirst body line\nSecond body line\n"), "") + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Subject line" { + t.Errorf("subject = %q, want %q", m.Subject, "Subject line") + } + if m.Body != "First body line\nSecond body line\n" { + t.Errorf("body = %q, want every later line kept", m.Body) + } +} + +func TestRenderPrefersTheOverrideDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + overridden := filepath.Join(dir, "invite.tmpl") + if err := os.WriteFile(overridden, []byte("Operator subject\nOperator body"), 0o600); err != nil { + t.Fatalf("writing the override: %v", err) + } + tpls := templates(t, defaultsWith("Default subject\nDefault body"), dir) + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Operator subject" { + t.Errorf("subject = %q, want the override to win", m.Subject) + } +} + +func TestRenderFallsBackToTheDefaults(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Default subject\nDefault body"), t.TempDir()) + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want the embedded default used", err) + } + if m.Subject != "Default subject" { + t.Errorf("subject = %q, want the embedded default", m.Subject) + } +} + +func TestRenderReadsTheOverrideAtRenderTime(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + overridden := filepath.Join(dir, "invite.tmpl") + if err := os.WriteFile(overridden, []byte("First wording\nBody"), 0o600); err != nil { + t.Fatalf("writing the override: %v", err) + } + tpls := templates(t, defaultsWith("Default subject\nDefault body"), dir) + if _, err := tpls.Render("invite.tmpl", nil); err != nil { + t.Fatalf("first Render() error = %v, want nil", err) + } + + if err := os.WriteFile(overridden, []byte("Second wording\nBody"), 0o600); err != nil { + t.Fatalf("rewriting the override: %v", err) + } + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("second Render() error = %v, want nil", err) + } + if m.Subject != "Second wording" { + t.Errorf("subject = %q, want the edit applied with no restart", m.Subject) + } +} + +func TestRenderSurfacesAnUnreadableOverride(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "invite.tmpl"), 0o700); err != nil { + t.Fatalf("planting the unreadable override: %v", err) + } + tpls := templates(t, defaultsWith("Default subject\nDefault body"), dir) + + _, err := tpls.Render("invite.tmpl", nil) + + if err == nil { + t.Fatal("Render() error = nil, want the unreadable override surfaced, not the default applied") + } + if errors.Is(err, fs.ErrNotExist) { + t.Errorf("Render() error = %v, want a read failure, not a missing template", err) + } +} + +func TestRenderTrimsACarriageReturnFromTheSubject(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Subject line\r\nBody line\r\n"), "") + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Subject line" { + t.Errorf("subject = %q, want the carriage return trimmed", m.Subject) + } +} + +func TestRenderStripsALeadingByteOrderMark(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("\ufeffSubject line\nBody"), "") + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Subject line" { + t.Errorf("subject = %q, want the byte order mark stripped", m.Subject) + } +} + +func TestRenderTrimsLeadingBlankOutput(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("{{/* the invite mail */}}\nSubject line\nBody"), "") + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Subject line" { + t.Errorf("subject = %q, want the blank first line skipped", m.Subject) + } + if m.Body != "Body" { + t.Errorf("body = %q, want %q", m.Body, "Body") + } +} + +func TestRenderRefusesAnEmptyRender(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("{{if .Confirmed}}Subject\nBody{{end}}"), "") + + _, err := tpls.Render("invite.tmpl", map[string]bool{"Confirmed": false}) + + if !errors.Is(err, mailkit.ErrNoSubject) { + t.Errorf("Render() error = %v, want mailkit.ErrNoSubject", err) + } + if err != nil && !strings.Contains(err.Error(), "invite.tmpl") { + t.Errorf("Render() error = %v, want the template named", err) + } +} + +func TestRenderAnswersAnEmptyBodyForASubjectOnlyFile(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Only a subject"), "") + + m, err := tpls.Render("invite.tmpl", nil) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Only a subject" { + t.Errorf("subject = %q, want the whole file", m.Subject) + } + if m.Body != "" { + t.Errorf("body = %q, want empty", m.Body) + } +} + +func TestRenderTruncatesTheSubjectAtRenderedNewlines(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Hi {{.Name}}\nBody"), "") + + m, err := tpls.Render("invite.tmpl", map[string]string{"Name": "Maria\nPerez"}) + + if err != nil { + t.Fatalf("Render() error = %v, want nil", err) + } + if m.Subject != "Hi Maria" { + t.Errorf("subject = %q, want the first rendered line alone", m.Subject) + } + if m.Body != "Perez\nBody" { + t.Errorf("body = %q, want the rendered remainder", m.Body) + } +} + +func TestRenderRefusesAnUnknownTemplate(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Subject\nBody"), "") + + _, err := tpls.Render("goodbye.tmpl", nil) + + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Render() error = %v, want fs.ErrNotExist wrapped", err) + } + if err != nil && !strings.Contains(err.Error(), "goodbye.tmpl") { + t.Errorf("Render() error = %v, want the template named", err) + } +} + +func TestRenderRefusesAPathTraversingName(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Subject\nBody"), t.TempDir()) + + for _, name := range []string{"../invite.tmpl", "nested/invite.tmpl", ""} { + if _, err := tpls.Render(name, nil); err == nil { + t.Errorf("Render(%q) error = nil, want the name refused", name) + } + } +} + +func TestRenderSurfacesAParseError(t *testing.T) { + t.Parallel() + + tpls := templates(t, defaultsWith("Subject {{.Name\nBody"), "") + + if _, err := tpls.Render("invite.tmpl", nil); err == nil { + t.Error("Render() error = nil, want the parse failure surfaced") + } +} + +func TestNewTemplatesRefusesNilDefaults(t *testing.T) { + t.Parallel() + + if _, err := mailkit.NewTemplates(nil, ""); err == nil { + t.Error("NewTemplates() error = nil, want nil defaults refused") + } +} + +func TestNewTemplatesRefusesAMissingOverrideDirectory(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "no-such-directory") + + if _, err := mailkit.NewTemplates(defaultsWith("Subject\nBody"), missing); err == nil { + t.Error("NewTemplates() error = nil, want the missing directory refused at boot") + } +} + +func TestNewTemplatesRefusesAFileAsOverrideDirectory(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "a-file") + if err := os.WriteFile(file, []byte("held"), 0o600); err != nil { + t.Fatalf("writing the file: %v", err) + } + + if _, err := mailkit.NewTemplates(defaultsWith("Subject\nBody"), file); err == nil { + t.Error("NewTemplates() error = nil, want a plain file refused as the directory") + } +} From 78d80d09ee6bf76c6100b709b61ba1e5eee0a7c6 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 29 Aug 2026 23:57:04 +0200 Subject: [PATCH 3/9] feat(mailkit): deliver messages over smtp --- mailkit/go.mod | 10 + mailkit/go.sum | 18 ++ mailkit/mailkit.go | 4 +- mailkit/smtp/smtp.go | 178 ++++++++++++++ mailkit/smtp/smtp_internal_test.go | 54 +++++ mailkit/smtp/smtp_test.go | 364 +++++++++++++++++++++++++++++ 6 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 mailkit/go.sum create mode 100644 mailkit/smtp/smtp.go create mode 100644 mailkit/smtp/smtp_internal_test.go create mode 100644 mailkit/smtp/smtp_test.go diff --git a/mailkit/go.mod b/mailkit/go.mod index a38e7f6..cc2dd5a 100644 --- a/mailkit/go.mod +++ b/mailkit/go.mod @@ -3,3 +3,13 @@ module github.com/gopherium/framework/mailkit go 1.25.7 toolchain go1.26.7 + +require ( + github.com/mocktools/go-smtp-mock/v2 v2.5.4 + github.com/wneessen/go-mail v0.8.1 +) + +require ( + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/mailkit/go.sum b/mailkit/go.sum new file mode 100644 index 0000000..c88cbcc --- /dev/null +++ b/mailkit/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mocktools/go-smtp-mock/v2 v2.5.4 h1:U89Y4SuOhDFUfboMYUtXzWDp7hNLrofRa5yNqGSESSM= +github.com/mocktools/go-smtp-mock/v2 v2.5.4/go.mod h1:qBGjYXy5jKKVFhDnB39DYQfn4hWfcqWAlJTcvrku3rg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mailkit/mailkit.go b/mailkit/mailkit.go index cb0e25d..e7d5128 100644 --- a/mailkit/mailkit.go +++ b/mailkit/mailkit.go @@ -16,7 +16,9 @@ type Message struct { // Sender delivers one message. type Sender interface { - // Send delivers m, refusing an empty To with ErrNoRecipient. + // Send delivers m, answering ErrNoRecipient for an empty To, + // ErrInvalidRecipient for an address the transport refuses, and a + // wrapped delivery error the caller treats as one thing otherwise. Send(ctx context.Context, m Message) error } diff --git a/mailkit/smtp/smtp.go b/mailkit/smtp/smtp.go new file mode 100644 index 0000000..4d76544 --- /dev/null +++ b/mailkit/smtp/smtp.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package smtp delivers mailkit messages over SMTP. +package smtp + +import ( + "context" + "errors" + "fmt" + "net" + "net/mail" + "time" + + gomail "github.com/wneessen/go-mail" + + "github.com/gopherium/framework/mailkit" +) + +// TLS names the transport security a relay is held to. Mandatory and +// opportunistic both mean STARTTLS, so a relay speaking implicit TLS on +// port 465 is not reachable and fails as a dial timeout. +type TLS string + +// The transport security policies a sender accepts. +const ( + TLSMandatory TLS = "mandatory" + TLSOpportunistic TLS = "opportunistic" + TLSNone TLS = "none" +) + +// DefaultPort is the submission port a sender dials when the config names none. +const DefaultPort = 587 + +// Config names the relay a sender delivers through. +type Config struct { + Host string + Port int + Username string + Password string + From string + TLS TLS + HELO string + Timeout time.Duration +} + +// Sender delivers mailkit messages through one relay. It is safe to +// share across goroutines, and it dials once per message. Cancelling +// the context governs the dial alone, so a transmission already under +// way is not aborted and its mail may still arrive. +type Sender struct { + client *gomail.Client + from *mail.Address +} + +// dialedKey carries the connection holder one send hands to the dialer. +type dialedKey struct{} + +// dialed remembers the connection a dial opened so a failure can close it. +type dialed struct{ conn net.Conn } + +// New returns a Sender for the relay cfg names, refusing an unusable config. +func New(cfg Config) (*Sender, error) { + from, err := mail.ParseAddress(cfg.From) + if err != nil { + return nil, fmt.Errorf("mailkit/smtp: from address: %w", err) + } + opts, err := options(cfg) + if err != nil { + return nil, err + } + client, err := gomail.NewClient(cfg.Host, append(opts, gomail.WithDialContextFunc(dial))...) + if err != nil { + return nil, fmt.Errorf("mailkit/smtp: build client: %w", err) + } + return &Sender{client: client, from: from}, nil +} + +// dial opens the connection and hands it to the holder the sending context carries. +func dial(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, network, address) + if held, ok := ctx.Value(dialedKey{}).(*dialed); ok && err == nil { + held.conn = conn + } + return conn, err +} + +// Send delivers m through the relay, refusing an empty or malformed recipient. +func (s *Sender) Send(ctx context.Context, m mailkit.Message) error { + if m.To == "" { + return mailkit.ErrNoRecipient + } + msg := gomail.NewMsg() + msg.FromMailAddress(s.from) + if err := msg.To(m.To); err != nil { + return fmt.Errorf("mailkit/smtp: %w %s: %w", mailkit.ErrInvalidRecipient, m.To, err) + } + msg.Subject(m.Subject) + msg.SetBodyString(gomail.TypeTextPlain, m.Body) + return s.deliver(ctx, m.To, msg) +} + +// deliver sends one message, closing the connection a failed dial leaves open. +func (s *Sender) deliver(ctx context.Context, to string, msg *gomail.Msg) error { + held := &dialed{} + if err := s.client.DialAndSendWithContext(context.WithValue(ctx, dialedKey{}, held), msg); err != nil { + if held.conn != nil { + _ = held.conn.Close() + } + return fmt.Errorf("mailkit/smtp: deliver to %s: %w", to, err) + } + return nil +} + +// options answers the go-mail client options cfg asks for, refusing an unusable one. +func options(cfg Config) ([]gomail.Option, error) { + if cfg.Host == "" { + return nil, errors.New("mailkit/smtp: no host") + } + policy, err := policyOf(cfg.TLS) + if err != nil { + return nil, err + } + if cfg.Timeout < 0 { + return nil, errors.New("mailkit/smtp: negative timeout") + } + auth, err := credentials(cfg) + if err != nil { + return nil, err + } + opts := []gomail.Option{gomail.WithPort(port(cfg.Port)), gomail.WithTLSPolicy(policy)} + if cfg.Timeout > 0 { + opts = append(opts, gomail.WithTimeout(cfg.Timeout)) + } + if cfg.HELO != "" { + opts = append(opts, gomail.WithHELO(cfg.HELO)) + } + return append(opts, auth...), nil +} + +// credentials answers the authentication options cfg asks for, refusing an unusable pair. +func credentials(cfg Config) ([]gomail.Option, error) { + if cfg.Username == "" && cfg.Password == "" { + return nil, nil + } + if cfg.Username == "" || cfg.Password == "" { + return nil, errors.New("mailkit/smtp: username and password go together") + } + if cfg.TLS == TLSNone { + return nil, errors.New("mailkit/smtp: credentials need transport security") + } + return []gomail.Option{ + gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover), + gomail.WithUsername(cfg.Username), + gomail.WithPassword(cfg.Password), + }, nil +} + +// policyOf maps a named policy onto go-mail, treating the empty one as mandatory. +func policyOf(named TLS) (gomail.TLSPolicy, error) { + switch named { + case TLSMandatory, "": + return gomail.TLSMandatory, nil + case TLSOpportunistic: + return gomail.TLSOpportunistic, nil + case TLSNone: + return gomail.NoTLS, nil + default: + return 0, fmt.Errorf("mailkit/smtp: unknown tls policy %q", named) + } +} + +// port answers the configured port, or the submission default when none is named. +func port(named int) int { + if named == 0 { + return DefaultPort + } + return named +} diff --git a/mailkit/smtp/smtp_internal_test.go b/mailkit/smtp/smtp_internal_test.go new file mode 100644 index 0000000..526d969 --- /dev/null +++ b/mailkit/smtp/smtp_internal_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package smtp + +import ( + "testing" + + gomail "github.com/wneessen/go-mail" +) + +func TestPortAnswersTheSubmissionDefault(t *testing.T) { + t.Parallel() + + if held := port(0); held != 587 { + t.Errorf("port(0) = %d, want the submission port 587", held) + } +} + +func TestPortKeepsAConfiguredPort(t *testing.T) { + t.Parallel() + + if held := port(2525); held != 2525 { + t.Errorf("port(2525) = %d, want the configured port kept", held) + } +} + +func TestPolicyOfMapsEveryNamedPolicy(t *testing.T) { + t.Parallel() + + tests := map[TLS]gomail.TLSPolicy{ + TLSMandatory: gomail.TLSMandatory, + "": gomail.TLSMandatory, + TLSOpportunistic: gomail.TLSOpportunistic, + TLSNone: gomail.NoTLS, + } + for named, want := range tests { + held, err := policyOf(named) + if err != nil { + t.Errorf("policyOf(%q) error = %v, want nil", named, err) + continue + } + if held != want { + t.Errorf("policyOf(%q) = %v, want %v", named, held, want) + } + } +} + +func TestPolicyOfRefusesAnUnknownPolicy(t *testing.T) { + t.Parallel() + + if _, err := policyOf("sometimes"); err == nil { + t.Error("policyOf() error = nil, want an unknown policy refused") + } +} diff --git a/mailkit/smtp/smtp_test.go b/mailkit/smtp/smtp_test.go new file mode 100644 index 0000000..df272a4 --- /dev/null +++ b/mailkit/smtp/smtp_test.go @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: Apache-2.0 + +package smtp_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + smtpmock "github.com/mocktools/go-smtp-mock/v2" + + "github.com/gopherium/framework/mailkit" + "github.com/gopherium/framework/mailkit/smtp" +) + +// relay starts a mock SMTP server that survives the reset go-mail sends after delivering. +func relay(t *testing.T) *smtpmock.Server { + t.Helper() + server := smtpmock.New(smtpmock.ConfigurationAttr{MultipleMessageReceiving: true}) + if err := server.Start(); err != nil { + t.Fatalf("starting the mock relay: %v", err) + } + t.Cleanup(func() { _ = server.Stop() }) + return server +} + +// senderTo builds a sender aimed at the mock, which speaks neither TLS nor authentication. +func senderTo(t *testing.T, server *smtpmock.Server) *smtp.Sender { + t.Helper() + held, err := smtp.New(smtp.Config{ + Host: "127.0.0.1", + Port: server.PortNumber(), + From: "crm@example.com", + TLS: smtp.TLSNone, + HELO: "example.com", + }) + if err != nil { + t.Fatalf("smtp.New() error = %v, want nil", err) + } + return held +} + +// delivered answers the payload of the one message the relay received. +func delivered(t *testing.T, server *smtpmock.Server) string { + t.Helper() + messages, err := server.WaitForMessages(1, 5*time.Second) + if err != nil { + t.Fatalf("waiting for the message: %v", err) + } + return messages[0].MsgRequest() +} + +func TestSendDeliversSubjectBodyAndRecipient(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + + err := sender.Send(t.Context(), mailkit.Message{ + To: "maria@example.com", + Subject: "You are invited", + Body: "Set your password to begin.", + }) + + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + payload := delivered(t, server) + wants := []string{"You are invited", "Set your password to begin.", "maria@example.com", "crm@example.com"} + for _, want := range wants { + if !strings.Contains(payload, want) { + t.Errorf("payload missing %q, got:\n%s", want, payload) + } + } +} + +func TestSendAddressesTheEnvelopeToTheRecipient(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + + if err := sender.Send(t.Context(), mailkit.Message{ + To: "maria@example.com", + Subject: "Subject", + Body: "Body", + }); err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + messages, err := server.WaitForMessages(1, 5*time.Second) + if err != nil { + t.Fatalf("waiting for the message: %v", err) + } + if from := messages[0].MailfromRequest(); !strings.Contains(from, "crm@example.com") { + t.Errorf("envelope sender = %q, want the configured from", from) + } + pairs := messages[0].RcpttoRequestResponse() + if len(pairs) != 1 || !strings.Contains(pairs[0][0], "maria@example.com") { + t.Errorf("envelope recipients = %v, want the one recipient", pairs) + } +} + +func TestSendCarriesAPlainTextBody(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + + if err := sender.Send(t.Context(), mailkit.Message{ + To: "maria@example.com", + Subject: "Subject", + Body: "Body", + }); err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + payload := delivered(t, server) + if !strings.Contains(payload, "text/plain") { + t.Errorf("payload is not plain text, got:\n%s", payload) + } + for _, unwanted := range []string{"text/html", "multipart/"} { + if strings.Contains(payload, unwanted) { + t.Errorf("payload carries %q, want text only, got:\n%s", unwanted, payload) + } + } +} + +func TestSendRefusesAnEmptyRecipientBeforeDialing(t *testing.T) { + t.Parallel() + + sender, err := smtp.New(smtp.Config{ + Host: "127.0.0.1", + Port: 9, + From: "crm@example.com", + TLS: smtp.TLSNone, + HELO: "example.com", + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("smtp.New() error = %v, want nil", err) + } + + sendErr := sender.Send(t.Context(), mailkit.Message{Subject: "Subject", Body: "Body"}) + + if !errors.Is(sendErr, mailkit.ErrNoRecipient) { + t.Errorf("Send() error = %v, want mailkit.ErrNoRecipient with no dial attempted", sendErr) + } +} + +func TestSendRefusesAMalformedRecipient(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + + err := sender.Send(t.Context(), mailkit.Message{To: "not-an-address", Subject: "Subject", Body: "Body"}) + + if !errors.Is(err, mailkit.ErrInvalidRecipient) { + t.Errorf("Send() error = %v, want mailkit.ErrInvalidRecipient", err) + } +} + +func TestSendReportsAnUnreachableRelay(t *testing.T) { + t.Parallel() + + sender, err := smtp.New(smtp.Config{ + Host: "127.0.0.1", + Port: 9, + From: "crm@example.com", + TLS: smtp.TLSNone, + HELO: "example.com", + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("smtp.New() error = %v, want nil", err) + } + + sendErr := sender.Send(t.Context(), mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}) + + if sendErr == nil { + t.Fatal("Send() error = nil, want the unreachable relay surfaced") + } + if errors.Is(sendErr, mailkit.ErrNoRecipient) || errors.Is(sendErr, mailkit.ErrInvalidRecipient) { + t.Errorf("Send() error = %v, want a delivery failure, not a recipient refusal", sendErr) + } +} + +func TestSendDeliversEveryMessageOnOneSender(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + + for _, to := range []string{"maria@example.com", "ada@example.com"} { + if err := sender.Send(t.Context(), mailkit.Message{To: to, Subject: "Subject", Body: "Body"}); err != nil { + t.Fatalf("Send(%s) error = %v, want nil", to, err) + } + } + + messages, err := server.WaitForMessages(2, 5*time.Second) + if err != nil { + t.Fatalf("waiting for both messages: %v", err) + } + seen := map[string]bool{} + for _, held := range messages { + pairs := held.RcpttoRequestResponse() + if len(pairs) != 1 { + t.Fatalf("recipients = %v, want one per message", pairs) + } + seen[pairs[0][0]] = true + if !strings.Contains(held.MsgRequest(), "Body") { + t.Errorf("payload = %q, want the body carried", held.MsgRequest()) + } + } + for _, to := range []string{"maria@example.com", "ada@example.com"} { + if !hasRecipient(seen, to) { + t.Errorf("recipients %v, want one addressed to %s", seen, to) + } + } +} + +// hasRecipient reports whether any recorded envelope names the address. +func hasRecipient(seen map[string]bool, address string) bool { + for held := range seen { + if strings.Contains(held, address) { + return true + } + } + return false +} + +func TestSendOffersTheConfiguredCredentials(t *testing.T) { + t.Parallel() + + server := relay(t) + sender, err := smtp.New(smtp.Config{ + Host: "127.0.0.1", + Port: server.PortNumber(), + From: "crm@example.com", + TLS: smtp.TLSOpportunistic, + HELO: "example.com", + Username: "crm", + Password: "a long enough secret", + }) + if err != nil { + t.Fatalf("smtp.New() error = %v, want nil", err) + } + + sendErr := sender.Send(t.Context(), mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}) + + if sendErr == nil { + t.Fatal("Send() error = nil, want the relay refusing the offered authentication") + } + if !strings.Contains(sendErr.Error(), "AUTH") { + t.Errorf("Send() error = %v, want authentication to have been attempted", sendErr) + } +} + +func TestSendCarriesTheConfiguredHELO(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + + if err := sender.Send(t.Context(), mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}); err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + messages, err := server.WaitForMessages(1, 5*time.Second) + if err != nil { + t.Fatalf("waiting for the message: %v", err) + } + if helo := messages[0].HeloRequest(); !strings.Contains(helo, "example.com") { + t.Errorf("helo = %q, want the configured name", helo) + } +} + +func TestSendHonoursACancelledContext(t *testing.T) { + t.Parallel() + + server := relay(t) + sender := senderTo(t, server) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := sender.Send(ctx, mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}) + + if err == nil { + t.Fatal("Send() error = nil, want the cancelled context to have stopped the dial") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("Send() error = %v, want context.Canceled wrapped", err) + } +} + +func TestNewRefusesAnUnusableConfig(t *testing.T) { + t.Parallel() + + tests := map[string]smtp.Config{ + "no host": {From: "crm@example.com"}, + "no from": {Host: "127.0.0.1"}, + "malformed from": {Host: "127.0.0.1", From: "not-an-address"}, + "unknown tls": {Host: "127.0.0.1", From: "crm@example.com", TLS: "sometimes"}, + "port below range": {Host: "127.0.0.1", From: "crm@example.com", Port: -1}, + "port above range": {Host: "127.0.0.1", From: "crm@example.com", Port: 70000}, + "negative timeout": {Host: "127.0.0.1", From: "crm@example.com", Timeout: -time.Second}, + "username without password": { + Host: "127.0.0.1", From: "crm@example.com", Username: "crm", + }, + "password without username": { + Host: "127.0.0.1", From: "crm@example.com", Password: "a long enough secret", + }, + "credentials without transport security": { + Host: "127.0.0.1", From: "crm@example.com", TLS: smtp.TLSNone, + Username: "crm", Password: "a long enough secret", + }, + } + for name, cfg := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if _, err := smtp.New(cfg); err == nil { + t.Errorf("smtp.New() error = nil, want %s refused at construction", name) + } + }) + } +} + +func TestNewAcceptsEveryTLSPolicy(t *testing.T) { + t.Parallel() + + for _, policy := range []smtp.TLS{smtp.TLSMandatory, smtp.TLSOpportunistic, smtp.TLSNone, ""} { + if _, err := smtp.New(smtp.Config{ + Host: "127.0.0.1", + From: "crm@example.com", + TLS: policy, + }); err != nil { + t.Errorf("smtp.New(TLS %q) error = %v, want nil", policy, err) + } + } +} + +func TestNewAcceptsCredentials(t *testing.T) { + t.Parallel() + + if _, err := smtp.New(smtp.Config{ + Host: "127.0.0.1", + From: "crm@example.com", + TLS: smtp.TLSMandatory, + Username: "crm", + Password: "a long enough secret", + }); err != nil { + t.Errorf("smtp.New() error = %v, want credentials accepted", err) + } +} + +func TestSenderSatisfiesTheMailkitSeam(t *testing.T) { + t.Parallel() + + var _ mailkit.Sender = (*smtp.Sender)(nil) +} From 930d901974f4d393b94f577896735c5aa150cdcb Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 30 Aug 2026 00:22:13 +0200 Subject: [PATCH 4/9] feat(mailkit): keep sent mail in a test double --- mailkit/doc.go | 2 +- mailkit/mailkit.go | 5 +- mailkit/smtp/smtp.go | 11 +- mailkit/templates.go | 2 +- mailkit/templates_test.go | 4 +- mailkit/testkit/sender.go | 40 +++++++ mailkit/testkit/sender_test.go | 189 +++++++++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 mailkit/testkit/sender.go create mode 100644 mailkit/testkit/sender_test.go diff --git a/mailkit/doc.go b/mailkit/doc.go index d59b63e..6f26457 100644 --- a/mailkit/doc.go +++ b/mailkit/doc.go @@ -1,4 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -// Package mailkit renders and sends application mail. +// Package mailkit renders application mail and declares the seam that sends it. package mailkit diff --git a/mailkit/mailkit.go b/mailkit/mailkit.go index e7d5128..4d4c2f0 100644 --- a/mailkit/mailkit.go +++ b/mailkit/mailkit.go @@ -16,9 +16,8 @@ type Message struct { // Sender delivers one message. type Sender interface { - // Send delivers m, answering ErrNoRecipient for an empty To, - // ErrInvalidRecipient for an address the transport refuses, and a - // wrapped delivery error the caller treats as one thing otherwise. + // Send delivers m, answering ErrNoRecipient, ErrInvalidRecipient, + // or a wrapped delivery error. Send(ctx context.Context, m Message) error } diff --git a/mailkit/smtp/smtp.go b/mailkit/smtp/smtp.go index 4d76544..8e207ec 100644 --- a/mailkit/smtp/smtp.go +++ b/mailkit/smtp/smtp.go @@ -16,9 +16,7 @@ import ( "github.com/gopherium/framework/mailkit" ) -// TLS names the transport security a relay is held to. Mandatory and -// opportunistic both mean STARTTLS, so a relay speaking implicit TLS on -// port 465 is not reachable and fails as a dial timeout. +// TLS names the transport security a relay is held to, over STARTTLS alone. type TLS string // The transport security policies a sender accepts. @@ -43,10 +41,9 @@ type Config struct { Timeout time.Duration } -// Sender delivers mailkit messages through one relay. It is safe to -// share across goroutines, and it dials once per message. Cancelling -// the context governs the dial alone, so a transmission already under -// way is not aborted and its mail may still arrive. +// Sender delivers mailkit messages through one relay, safe to share +// across goroutines, dialing once per message under a context that +// governs the dial alone. type Sender struct { client *gomail.Client from *mail.Address diff --git a/mailkit/templates.go b/mailkit/templates.go index 5cc7860..b4a006f 100644 --- a/mailkit/templates.go +++ b/mailkit/templates.go @@ -18,7 +18,7 @@ type Templates struct { overrideDir string } -// NewTemplates returns Templates over the embedded defaults and an optional override directory. +// NewTemplates returns Templates over the caller's default files and an optional override directory. func NewTemplates(defaults fs.FS, overrideDir string) (*Templates, error) { if defaults == nil { return nil, errors.New("mailkit: nil defaults filesystem") diff --git a/mailkit/templates_test.go b/mailkit/templates_test.go index d04dc2f..d9dc7b8 100644 --- a/mailkit/templates_test.go +++ b/mailkit/templates_test.go @@ -97,10 +97,10 @@ func TestRenderFallsBackToTheDefaults(t *testing.T) { m, err := tpls.Render("invite.tmpl", nil) if err != nil { - t.Fatalf("Render() error = %v, want the embedded default used", err) + t.Fatalf("Render() error = %v, want the default used", err) } if m.Subject != "Default subject" { - t.Errorf("subject = %q, want the embedded default", m.Subject) + t.Errorf("subject = %q, want the default", m.Subject) } } diff --git a/mailkit/testkit/sender.go b/mailkit/testkit/sender.go new file mode 100644 index 0000000..f65de4e --- /dev/null +++ b/mailkit/testkit/sender.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package testkit provides test doubles for mailkit consumers. +package testkit + +import ( + "context" + "fmt" + "net/mail" + + "github.com/gopherium/framework/mailkit" +) + +// Sender keeps the messages it is given rather than delivering them, +// refusing a recipient and a cancelled context as a delivering sender +// does. Err forces a delivery failure. It holds no lock, so a test +// sending from several goroutines synchronizes itself. +type Sender struct { + Messages []mailkit.Message + Err error +} + +// Send keeps m, or answers the recipient refusal, the cancelled +// context, or the configured failure. +func (s *Sender) Send(ctx context.Context, m mailkit.Message) error { + if m.To == "" { + return mailkit.ErrNoRecipient + } + if _, err := mail.ParseAddress(m.To); err != nil { + return fmt.Errorf("mailkit/testkit: %w %s: %w", mailkit.ErrInvalidRecipient, m.To, err) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("mailkit/testkit: %w", err) + } + if s.Err != nil { + return s.Err + } + s.Messages = append(s.Messages, m) + return nil +} diff --git a/mailkit/testkit/sender_test.go b/mailkit/testkit/sender_test.go new file mode 100644 index 0000000..aa26736 --- /dev/null +++ b/mailkit/testkit/sender_test.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +package testkit_test + +import ( + "context" + "errors" + "testing" + + "github.com/gopherium/framework/mailkit" + "github.com/gopherium/framework/mailkit/testkit" +) + +func TestSenderKeepsWhatItWasGiven(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + + if err := sender.Send(t.Context(), mailkit.Message{ + To: "maria@example.com", + Subject: "You are invited", + Body: "Set your password.", + }); err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + + if len(sender.Messages) != 1 { + t.Fatalf("messages = %d, want 1", len(sender.Messages)) + } + held := sender.Messages[0] + if held.To != "maria@example.com" || held.Subject != "You are invited" || held.Body != "Set your password." { + t.Errorf("message = %+v, want the one that was sent", held) + } +} + +func TestSenderKeepsEveryMessageInOrder(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + + for _, to := range []string{"maria@example.com", "ada@example.com"} { + if err := sender.Send(t.Context(), mailkit.Message{To: to, Subject: "S", Body: "B"}); err != nil { + t.Fatalf("Send(%s) error = %v, want nil", to, err) + } + } + + if len(sender.Messages) != 2 { + t.Fatalf("messages = %d, want 2", len(sender.Messages)) + } + if sender.Messages[0].To != "maria@example.com" || sender.Messages[1].To != "ada@example.com" { + t.Errorf("messages = %+v, want them kept in sending order", sender.Messages) + } +} + +func TestSenderRefusesAnEmptyRecipient(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + + err := sender.Send(t.Context(), mailkit.Message{Subject: "S", Body: "B"}) + + if !errors.Is(err, mailkit.ErrNoRecipient) { + t.Errorf("Send() error = %v, want mailkit.ErrNoRecipient", err) + } + if len(sender.Messages) != 0 { + t.Errorf("messages = %d, want the refused message kept out", len(sender.Messages)) + } +} + +func TestSenderRefusesAMalformedRecipient(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + + err := sender.Send(t.Context(), mailkit.Message{To: "not-an-address", Subject: "S", Body: "B"}) + + if !errors.Is(err, mailkit.ErrInvalidRecipient) { + t.Errorf("Send() error = %v, want mailkit.ErrInvalidRecipient", err) + } + if len(sender.Messages) != 0 { + t.Errorf("messages = %d, want the refused message kept out", len(sender.Messages)) + } +} + +func TestSenderAnswersTheConfiguredFailure(t *testing.T) { + t.Parallel() + + boom := errors.New("relay down") + sender := &testkit.Sender{Err: boom} + + err := sender.Send(t.Context(), mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}) + + if !errors.Is(err, boom) { + t.Errorf("Send() error = %v, want the configured failure", err) + } + if len(sender.Messages) != 0 { + t.Errorf("messages = %d, want a failed send kept out", len(sender.Messages)) + } +} + +func TestSenderRefusesTheRecipientBeforeTheConfiguredFailure(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{Err: errors.New("relay down")} + + err := sender.Send(t.Context(), mailkit.Message{Subject: "S", Body: "B"}) + + if !errors.Is(err, mailkit.ErrNoRecipient) { + t.Errorf("Send() error = %v, want the recipient refused first, as the real sender does", err) + } +} + +func TestSenderHonoursACancelledContext(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := sender.Send(ctx, mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}) + + if !errors.Is(err, context.Canceled) { + t.Errorf("Send() error = %v, want context.Canceled, as a delivering sender answers", err) + } + if len(sender.Messages) != 0 { + t.Errorf("messages = %d, want a cancelled send kept out", len(sender.Messages)) + } +} + +func TestSenderAcceptsTheAddressFormsADeliveringSenderAccepts(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + + for _, to := range []string{"maria@example.com", "Maria Perez ", ""} { + if err := sender.Send(t.Context(), mailkit.Message{To: to, Subject: "S", Body: "B"}); err != nil { + t.Errorf("Send(%q) error = %v, want the address accepted", to, err) + } + } + if len(sender.Messages) != 3 { + t.Errorf("messages = %d, want all three kept", len(sender.Messages)) + } +} + +func TestSenderRefusesTheAddressFormsADeliveringSenderRefuses(t *testing.T) { + t.Parallel() + + refused := map[string]string{ + "several addresses": "maria@example.com, ada@example.com", + "no domain": "maria@", + "no local part": "@example.com", + "only spaces": " ", + "a bare name": "Maria Perez", + } + for name, to := range refused { + t.Run(name, func(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{} + + err := sender.Send(t.Context(), mailkit.Message{To: to, Subject: "S", Body: "B"}) + + if !errors.Is(err, mailkit.ErrInvalidRecipient) { + t.Errorf("Send(%q) error = %v, want mailkit.ErrInvalidRecipient", to, err) + } + if len(sender.Messages) != 0 { + t.Errorf("messages = %d, want the refused message kept out", len(sender.Messages)) + } + }) + } +} + +func TestSenderRefusesAMalformedRecipientBeforeTheConfiguredFailure(t *testing.T) { + t.Parallel() + + sender := &testkit.Sender{Err: errors.New("relay down")} + + err := sender.Send(t.Context(), mailkit.Message{To: "not-an-address", Subject: "S", Body: "B"}) + + if !errors.Is(err, mailkit.ErrInvalidRecipient) { + t.Errorf("Send() error = %v, want the recipient refused first, as the real sender does", err) + } +} + +func TestSenderSatisfiesTheMailkitSeam(t *testing.T) { + t.Parallel() + + var _ mailkit.Sender = (*testkit.Sender)(nil) +} From d3429972f925ee3c6bf90b8be04789c1f48f9d35 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 30 Aug 2026 00:22:42 +0200 Subject: [PATCH 5/9] feat(ci): run the module gates on every push --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++ mailkit/.golangci.yml | 1 + 2 files changed, 63 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aaee778 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ["mailkit"] + defaults: + run: + working-directory: ${{ matrix.module }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - run: go test -race -covermode=atomic -coverprofile=cover.out ./... + - run: go vet ./... + + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ["mailkit"] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12 + working-directory: ${{ matrix.module }} + + vulncheck: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ["mailkit"] + defaults: + run: + working-directory: ${{ matrix.module }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/mailkit/.golangci.yml b/mailkit/.golangci.yml index 9c99924..4823e71 100644 --- a/mailkit/.golangci.yml +++ b/mailkit/.golangci.yml @@ -28,6 +28,7 @@ linters: mailer-purity: list-mode: strict files: + - "**/*.go" allow: - $gostd - github.com/wneessen/go-mail From 26aff4a51d69b8a3fc5ec384f75a88c2612cdb2d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 30 Aug 2026 00:22:50 +0200 Subject: [PATCH 6/9] docs: record the module and what it ships --- README.md | 7 +++++-- mailkit/CHANGELOG.md | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fa94669..0d0679c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ need and ignore the rest. > while they mature. Pin a version and read the module's CHANGELOG > before upgrading. Production use is at your own risk until v1. +## Modules + +- [`mailkit`](mailkit/) renders mail from template files and sends it + over SMTP. + ## Design One repository, one Go module per brick, no shared code between them. @@ -18,8 +23,6 @@ prefixed tag such as `mailkit/v0.1.0`. Modules depend on published versions only, never on sibling source, so what you pin is what you get. -Guides live at [docs.gopherium.org](https://docs.gopherium.org). - ## Reporting security issues See [SECURITY.md](SECURITY.md). Do not open public issues for diff --git a/mailkit/CHANGELOG.md b/mailkit/CHANGELOG.md index 9e24a4c..c11d015 100644 --- a/mailkit/CHANGELOG.md +++ b/mailkit/CHANGELOG.md @@ -9,3 +9,27 @@ v0.x, minor releases may contain breaking changes. Releases of this module are tagged `mailkit/vX.Y.Z`. ## [Unreleased] + +### Added + +- `Message` and `Sender`, the one method seam every mail provider satisfies. +- `ErrNoRecipient`, answered for an empty recipient. +- `ErrInvalidRecipient`, answered for an address the transport rejects. +- `ErrNoSubject`, answered for a template that renders nothing. +- `Templates` and `NewTemplates`, rendering mail from template files + whose first non-blank line is the subject and whose remainder is the + body, over a default filesystem the caller supplies. +- A named override directory whose files replace the defaults by + filename, read at render time. +- `smtp.Sender` and `smtp.New`, delivering over SMTP through go-mail. +- `smtp.Config`, naming the relay host, port, credentials, sender + address, transport security, HELO name and timeout. +- `smtp.TLS` with `TLSMandatory`, `TLSOpportunistic` and `TLSNone`, over + STARTTLS alone, so a relay speaking implicit TLS on port 465 is not + reachable and fails as a dial timeout. An empty value applies + mandatory. +- `smtp.DefaultPort`, the submission port 587 a sender dials when the + config names none. +- `testkit.Sender` with `Messages` and `Err`, keeping messages rather + than delivering them and answering the same refusals a delivering + sender answers. From 56e5cb47cdcdc3adcf6676be0189106e9fa8149f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 30 Aug 2026 00:50:36 +0200 Subject: [PATCH 7/9] fix(mailkit): refuse a template name carrying a path separator --- mailkit/templates.go | 2 +- mailkit/templates_test.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mailkit/templates.go b/mailkit/templates.go index b4a006f..fd215a6 100644 --- a/mailkit/templates.go +++ b/mailkit/templates.go @@ -54,7 +54,7 @@ func (t *Templates) Render(name string, data any) (Message, error) { // read answers the named file from the override directory, else the defaults. func (t *Templates) read(name string) ([]byte, error) { - if !fs.ValidPath(name) || name == "." || strings.Contains(name, "/") { + if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, `/\`) { return nil, fmt.Errorf("mailkit: template name %q: %w", name, fs.ErrInvalid) } if t.overrideDir != "" { diff --git a/mailkit/templates_test.go b/mailkit/templates_test.go index d9dc7b8..d40b890 100644 --- a/mailkit/templates_test.go +++ b/mailkit/templates_test.go @@ -268,9 +268,10 @@ func TestRenderRefusesAPathTraversingName(t *testing.T) { tpls := templates(t, defaultsWith("Subject\nBody"), t.TempDir()) - for _, name := range []string{"../invite.tmpl", "nested/invite.tmpl", ""} { - if _, err := tpls.Render(name, nil); err == nil { - t.Errorf("Render(%q) error = nil, want the name refused", name) + for _, name := range []string{"../invite.tmpl", "nested/invite.tmpl", "", `..\invite.tmpl`, `nested\invite.tmpl`} { + _, err := tpls.Render(name, nil) + if !errors.Is(err, fs.ErrInvalid) { + t.Errorf("Render(%q) error = %v, want the name refused as invalid", name, err) } } } From 4eacc8325f11bb7c3c9ebe8b974df890b961f96e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 30 Aug 2026 00:50:45 +0200 Subject: [PATCH 8/9] test(smtp): pin the credentials a sender offers a relay --- mailkit/smtp/authrelay_test.go | 87 ++++++++++++++++++++++++++++++++++ mailkit/smtp/smtp_test.go | 21 ++++---- 2 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 mailkit/smtp/authrelay_test.go diff --git a/mailkit/smtp/authrelay_test.go b/mailkit/smtp/authrelay_test.go new file mode 100644 index 0000000..faaca5d --- /dev/null +++ b/mailkit/smtp/authrelay_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package smtp_test + +import ( + "bufio" + "crypto/hmac" + "crypto/md5" + "encoding/base64" + "fmt" + "net" + "strings" + "testing" + "time" +) + +// authRelay is a relay that offers challenge response authentication and keeps what it is answered. +type authRelay struct { + port int + answered chan string +} + +// newAuthRelay starts a relay offering CRAM-MD5 and answering every credential with a refusal. +func newAuthRelay(t *testing.T) *authRelay { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listening: %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + relay := &authRelay{port: listener.Addr().(*net.TCPAddr).Port, answered: make(chan string, 1)} + go relay.accept(listener) + return relay +} + +// accept serves one session at a time until the listener closes. +func (a *authRelay) accept(listener net.Listener) { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go a.serve(conn) + } +} + +// serve walks one session as far as the credential and then refuses it. +func (a *authRelay) serve(conn net.Conn) { + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + reader := bufio.NewReader(conn) + _, _ = fmt.Fprint(conn, "220 relay\r\n") + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + switch { + case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): + _, _ = fmt.Fprint(conn, "250-relay\r\n250 AUTH CRAM-MD5\r\n") + case strings.HasPrefix(line, "AUTH CRAM-MD5"): + _, _ = fmt.Fprintf(conn, "334 %s\r\n", base64.StdEncoding.EncodeToString([]byte(cramChallenge))) + answer, err := reader.ReadString('\n') + if err != nil { + return + } + raw, _ := base64.StdEncoding.DecodeString(strings.TrimSpace(answer)) + select { + case a.answered <- string(raw): + default: + } + _, _ = fmt.Fprint(conn, "535 refused\r\n") + default: + _, _ = fmt.Fprint(conn, "250 ok\r\n") + } + } +} + +// cramChallenge is the fixed challenge the relay offers. +const cramChallenge = "" + +// cramAnswer answers what a client holding the credentials must reply to cramChallenge. +func cramAnswer(username, password string) string { + mac := hmac.New(md5.New, []byte(password)) + mac.Write([]byte(cramChallenge)) + return fmt.Sprintf("%s %x", username, mac.Sum(nil)) +} diff --git a/mailkit/smtp/smtp_test.go b/mailkit/smtp/smtp_test.go index df272a4..53740f9 100644 --- a/mailkit/smtp/smtp_test.go +++ b/mailkit/smtp/smtp_test.go @@ -235,27 +235,32 @@ func hasRecipient(seen map[string]bool, address string) bool { func TestSendOffersTheConfiguredCredentials(t *testing.T) { t.Parallel() - server := relay(t) + relay := newAuthRelay(t) sender, err := smtp.New(smtp.Config{ Host: "127.0.0.1", - Port: server.PortNumber(), + Port: relay.port, From: "crm@example.com", TLS: smtp.TLSOpportunistic, HELO: "example.com", Username: "crm", Password: "a long enough secret", + Timeout: 5 * time.Second, }) if err != nil { t.Fatalf("smtp.New() error = %v, want nil", err) } - sendErr := sender.Send(t.Context(), mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}) - - if sendErr == nil { - t.Fatal("Send() error = nil, want the relay refusing the offered authentication") + if err := sender.Send(t.Context(), mailkit.Message{To: "maria@example.com", Subject: "S", Body: "B"}); err == nil { + t.Fatal("Send() error = nil, want the relay refusing the credentials") } - if !strings.Contains(sendErr.Error(), "AUTH") { - t.Errorf("Send() error = %v, want authentication to have been attempted", sendErr) + + select { + case answered := <-relay.answered: + if want := cramAnswer("crm", "a long enough secret"); answered != want { + t.Errorf("credential answered = %q, want %q", answered, want) + } + case <-time.After(5 * time.Second): + t.Error("the relay was offered no credentials, want the configured pair") } } From 8caeb93ecb02c35d0ef2f7789469b657c24806f1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 30 Aug 2026 01:05:46 +0200 Subject: [PATCH 9/9] fix(smtp): hold credentials to mandatory transport security --- mailkit/CHANGELOG.md | 7 ++- mailkit/smtp/authrelay_test.go | 98 +++++++++++++++++++++++++--------- mailkit/smtp/smtp.go | 10 +++- mailkit/smtp/smtp_test.go | 28 ++++++---- 4 files changed, 104 insertions(+), 39 deletions(-) diff --git a/mailkit/CHANGELOG.md b/mailkit/CHANGELOG.md index c11d015..8112bc6 100644 --- a/mailkit/CHANGELOG.md +++ b/mailkit/CHANGELOG.md @@ -23,11 +23,16 @@ Releases of this module are tagged `mailkit/vX.Y.Z`. filename, read at render time. - `smtp.Sender` and `smtp.New`, delivering over SMTP through go-mail. - `smtp.Config`, naming the relay host, port, credentials, sender - address, transport security, HELO name and timeout. + address, transport security, HELO name, timeout and trust roots. +- Credentials require mandatory transport security, so a relay that + drops the upgrade never receives them. - `smtp.TLS` with `TLSMandatory`, `TLSOpportunistic` and `TLSNone`, over STARTTLS alone, so a relay speaking implicit TLS on port 465 is not reachable and fails as a dial timeout. An empty value applies mandatory. +- `smtp.Config.TLSConfig`, naming the roots a relay is verified against, + so a relay holding a private certificate is reachable. Nil verifies + against the system roots. - `smtp.DefaultPort`, the submission port 587 a sender dials when the config names none. - `testkit.Sender` with `Messages` and `Err`, keeping messages rather diff --git a/mailkit/smtp/authrelay_test.go b/mailkit/smtp/authrelay_test.go index faaca5d..64720f7 100644 --- a/mailkit/smtp/authrelay_test.go +++ b/mailkit/smtp/authrelay_test.go @@ -4,36 +4,49 @@ package smtp_test import ( "bufio" - "crypto/hmac" - "crypto/md5" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "fmt" + "math/big" "net" "strings" "testing" "time" ) -// authRelay is a relay that offers challenge response authentication and keeps what it is answered. +// authRelay is a relay that upgrades to TLS, offers authentication and keeps what it is answered. type authRelay struct { port int + roots *x509.CertPool + cert tls.Certificate answered chan string } -// newAuthRelay starts a relay offering CRAM-MD5 and answering every credential with a refusal. +// newAuthRelay starts a relay that offers STARTTLS then authentication, refusing every credential. func newAuthRelay(t *testing.T) *authRelay { t.Helper() + cert, roots := selfSigned(t) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listening: %v", err) } t.Cleanup(func() { _ = listener.Close() }) - relay := &authRelay{port: listener.Addr().(*net.TCPAddr).Port, answered: make(chan string, 1)} + relay := &authRelay{ + port: listener.Addr().(*net.TCPAddr).Port, + roots: roots, + cert: cert, + answered: make(chan string, 1), + } go relay.accept(listener) return relay } -// accept serves one session at a time until the listener closes. +// accept serves sessions until the listener closes. func (a *authRelay) accept(listener net.Listener) { for { conn, err := listener.Accept() @@ -44,12 +57,13 @@ func (a *authRelay) accept(listener net.Listener) { } } -// serve walks one session as far as the credential and then refuses it. +// serve walks one session through the upgrade to the credential and refuses it. func (a *authRelay) serve(conn net.Conn) { defer func() { _ = conn.Close() }() - _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) reader := bufio.NewReader(conn) _, _ = fmt.Fprint(conn, "220 relay\r\n") + secured := false for { line, err := reader.ReadString('\n') if err != nil { @@ -57,18 +71,20 @@ func (a *authRelay) serve(conn net.Conn) { } switch { case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): - _, _ = fmt.Fprint(conn, "250-relay\r\n250 AUTH CRAM-MD5\r\n") - case strings.HasPrefix(line, "AUTH CRAM-MD5"): - _, _ = fmt.Fprintf(conn, "334 %s\r\n", base64.StdEncoding.EncodeToString([]byte(cramChallenge))) - answer, err := reader.ReadString('\n') - if err != nil { - return + if secured { + _, _ = fmt.Fprint(conn, "250-relay\r\n250 AUTH PLAIN\r\n") + continue } - raw, _ := base64.StdEncoding.DecodeString(strings.TrimSpace(answer)) - select { - case a.answered <- string(raw): - default: + _, _ = fmt.Fprint(conn, "250-relay\r\n250 STARTTLS\r\n") + case strings.HasPrefix(line, "STARTTLS"): + _, _ = fmt.Fprint(conn, "220 go ahead\r\n") + secure := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{a.cert}}) + if err := secure.Handshake(); err != nil { + return } + conn, reader, secured = secure, bufio.NewReader(secure), true + case strings.HasPrefix(line, "AUTH PLAIN"): + a.keep(strings.TrimSpace(strings.TrimPrefix(line, "AUTH PLAIN"))) _, _ = fmt.Fprint(conn, "535 refused\r\n") default: _, _ = fmt.Fprint(conn, "250 ok\r\n") @@ -76,12 +92,44 @@ func (a *authRelay) serve(conn net.Conn) { } } -// cramChallenge is the fixed challenge the relay offers. -const cramChallenge = "" +// keep decodes one offered credential and records it. +func (a *authRelay) keep(encoded string) { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return + } + select { + case a.answered <- strings.ReplaceAll(string(raw), "\x00", " "): + default: + } +} -// cramAnswer answers what a client holding the credentials must reply to cramChallenge. -func cramAnswer(username, password string) string { - mac := hmac.New(md5.New, []byte(password)) - mac.Write([]byte(cramChallenge)) - return fmt.Sprintf("%s %x", username, mac.Sum(nil)) +// selfSigned returns a certificate for the loopback address and the roots trusting it. +func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating the key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "relay.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("creating the certificate: %v", err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing the certificate: %v", err) + } + roots := x509.NewCertPool() + roots.AddCert(parsed) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: parsed}, roots } diff --git a/mailkit/smtp/smtp.go b/mailkit/smtp/smtp.go index 8e207ec..f0dec80 100644 --- a/mailkit/smtp/smtp.go +++ b/mailkit/smtp/smtp.go @@ -5,6 +5,7 @@ package smtp import ( "context" + "crypto/tls" "errors" "fmt" "net" @@ -39,6 +40,8 @@ type Config struct { TLS TLS HELO string Timeout time.Duration + // TLSConfig names the roots a relay is verified against. Nil verifies against the system roots. + TLSConfig *tls.Config } // Sender delivers mailkit messages through one relay, safe to share @@ -131,6 +134,9 @@ func options(cfg Config) ([]gomail.Option, error) { if cfg.HELO != "" { opts = append(opts, gomail.WithHELO(cfg.HELO)) } + if cfg.TLSConfig != nil { + opts = append(opts, gomail.WithTLSConfig(cfg.TLSConfig)) + } return append(opts, auth...), nil } @@ -142,8 +148,8 @@ func credentials(cfg Config) ([]gomail.Option, error) { if cfg.Username == "" || cfg.Password == "" { return nil, errors.New("mailkit/smtp: username and password go together") } - if cfg.TLS == TLSNone { - return nil, errors.New("mailkit/smtp: credentials need transport security") + if cfg.TLS != TLSMandatory && cfg.TLS != "" { + return nil, errors.New("mailkit/smtp: credentials need mandatory transport security") } return []gomail.Option{ gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover), diff --git a/mailkit/smtp/smtp_test.go b/mailkit/smtp/smtp_test.go index 53740f9..0213c43 100644 --- a/mailkit/smtp/smtp_test.go +++ b/mailkit/smtp/smtp_test.go @@ -4,6 +4,7 @@ package smtp_test import ( "context" + "crypto/tls" "errors" "strings" "testing" @@ -232,19 +233,20 @@ func hasRecipient(seen map[string]bool, address string) bool { return false } -func TestSendOffersTheConfiguredCredentials(t *testing.T) { +func TestSendOffersTheConfiguredCredentialsOverTLS(t *testing.T) { t.Parallel() relay := newAuthRelay(t) sender, err := smtp.New(smtp.Config{ - Host: "127.0.0.1", - Port: relay.port, - From: "crm@example.com", - TLS: smtp.TLSOpportunistic, - HELO: "example.com", - Username: "crm", - Password: "a long enough secret", - Timeout: 5 * time.Second, + Host: "127.0.0.1", + Port: relay.port, + From: "crm@example.com", + TLS: smtp.TLSMandatory, + HELO: "example.com", + Username: "crm", + Password: "a long enough secret", + Timeout: 10 * time.Second, + TLSConfig: &tls.Config{RootCAs: relay.roots, ServerName: "127.0.0.1", MinVersion: tls.VersionTLS12}, }) if err != nil { t.Fatalf("smtp.New() error = %v, want nil", err) @@ -256,10 +258,10 @@ func TestSendOffersTheConfiguredCredentials(t *testing.T) { select { case answered := <-relay.answered: - if want := cramAnswer("crm", "a long enough secret"); answered != want { + if want := " crm a long enough secret"; answered != want { t.Errorf("credential answered = %q, want %q", answered, want) } - case <-time.After(5 * time.Second): + case <-time.After(10 * time.Second): t.Error("the relay was offered no credentials, want the configured pair") } } @@ -322,6 +324,10 @@ func TestNewRefusesAnUnusableConfig(t *testing.T) { Host: "127.0.0.1", From: "crm@example.com", TLS: smtp.TLSNone, Username: "crm", Password: "a long enough secret", }, + "credentials over downgradable transport security": { + Host: "127.0.0.1", From: "crm@example.com", TLS: smtp.TLSOpportunistic, + Username: "crm", Password: "a long enough secret", + }, } for name, cfg := range tests { t.Run(name, func(t *testing.T) {