Repository navigation
feat: Build mailkit, the mail sending brick #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
1dbbd95
chore(mailkit): scaffold the module
SirLouen 1d0e448
feat(mailkit): render mail from template files with an operator override
SirLouen 78d80d0
feat(mailkit): deliver messages over smtp
SirLouen 930d901
feat(mailkit): keep sent mail in a test double
SirLouen d342997
feat(ci): run the module gates on every push
SirLouen 26aff4a
docs: record the module and what it ships
SirLouen 56e5cb4
fix(mailkit): refuse a template name carrying a path separator
SirLouen 4eacc83
test(smtp): pin the credentials a sender offers a relay
SirLouen 8caeb93
fix(smtp): hold credentials to mandatory transport security
SirLouen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ./... | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| 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: | ||
| - "**/*.go" | ||
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # 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] | ||
|
|
||
| ### 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, 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 | ||
| than delivering them and answering the same refusals a delivering | ||
| sender answers. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| // Package mailkit renders application mail and declares the seam that sends it. | ||
| package mailkit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 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 | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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= |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| // 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, answering ErrNoRecipient, ErrInvalidRecipient, | ||
| // or a wrapped delivery error. | ||
| 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package smtp_test | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "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 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 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, | ||
| roots: roots, | ||
| cert: cert, | ||
| answered: make(chan string, 1), | ||
| } | ||
| go relay.accept(listener) | ||
| return relay | ||
| } | ||
|
|
||
| // accept serves sessions 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 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(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 { | ||
| return | ||
| } | ||
| switch { | ||
| case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): | ||
| if secured { | ||
| _, _ = fmt.Fprint(conn, "250-relay\r\n250 AUTH PLAIN\r\n") | ||
| continue | ||
| } | ||
| _, _ = 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") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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: | ||
| } | ||
| } | ||
|
|
||
| // 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 | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Run CI for every branch push.
branches: [main]excludes direct pushes to every non-main branch. This conflicts with the stated CI-on-every-push objective. Remove the branch restriction, or include all supported branches.Proposed fix
on: push: - branches: [main] pull_request:📝 Committable suggestion
🧰 Tools
🪛 zizmor (1.29.0)
[warning] 3-6: insufficient job-level concurrency limits (concurrency-limits): workflow is missing concurrency setting
(concurrency-limits)
🤖 Prompt for AI Agents