From 411f1a7d03f81d5b4bc7abd2293dac8bde349478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 11 Jul 2026 15:06:32 +0200 Subject: [PATCH] feat: production-ready hardening, TLS SMTP, pluggable content, docs Core correctness and production readiness: - Fix a deadlock where Enqueue held an exclusive lock during a blocking channel send, which could block Start/Stop when the queue was full. The service now uses an RWMutex plus an atomic started flag; the queue is closed exactly once under the write lock while Enqueue holds the read lock. - Add exported read accessors to MailContent (FromName, FromAddress, ToName, ToAddress, MimeType, Subject, Body) so external MailerService backends can actually read message fields. - Reject CR/LF/NUL in header fields at build time (SMTP header-injection protection); raise address limits to RFC-compatible bounds and the body limit to 256 KiB. SMTP transport: - Add TLS: implicit TLS/SMTPS (ImplicitTLS, implied on 465) and opportunistic/required STARTTLS (RequireTLS). - Configurable DialTimeout, LocalName (EHLO), and custom TLSConfig; accept the full 1-65535 port range; render messages with CRLF and a UTF-8 Content-Type; wrap underlying errors via MailerError.Err/Unwrap. Service: - Add MailServiceConfig.QueueSize and a per-message Timeout; make Start/Stop idempotent and safe for concurrent use. Tests, docs, and tooling: - Add an in-process SMTP test server (plain, STARTTLS, implicit TLS), concurrency tests under -race, and validation/getter coverage (~92%). - Rewrite README (install, quick start, config tables, custom backends); add docs/ guides and examples; expand SECURITY.md. - Add Makefile, .golangci.yaml, DEVELOPMENT_GUIDELINES.md, CHANGELOG.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/ISSUE_TEMPLATE/bug_report.md | 24 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .golangci.yaml | 59 ++++ .testcoverage.yml | 9 + CHANGELOG.md | 42 +++ CODE_OF_CONDUCT.md | 21 ++ CONTRIBUTING.md | 26 ++ DEVELOPMENT_GUIDELINES.md | 60 ++++ Makefile | 51 +++ README.md | 302 +++++++++-------- SECURITY.md | 35 ++ doc.go | 45 ++- docs/README.md | 21 ++ docs/examples/basic.md | 80 +++++ docs/examples/custom-backend.md | 116 +++++++ docs/production-guide.md | 150 +++++++++ example/main.go | 124 +++---- go.mod | 2 +- mailer.go | 191 ++++++----- mailer_test.go | 180 +++++----- service.go | 196 ++++++----- service_test.go | 473 ++++++++++++--------------- smtp.go | 271 +++++++++++---- smtp_server_test.go | 210 ++++++++++++ smtp_test.go | 300 ++++++++++++----- 25 files changed, 2105 insertions(+), 891 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .golangci.yaml create mode 100644 .testcoverage.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 DEVELOPMENT_GUIDELINES.md create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 docs/README.md create mode 100644 docs/examples/basic.md create mode 100644 docs/examples/custom-backend.md create mode 100644 docs/production-guide.md create mode 100644 smtp_server_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e40a355 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: Bug report +title: "[BUG] " +labels: bug +about: Create a report to help us improve mailer +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment** + +- Go version: +- OS: +- Package version: + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0381948 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/slashdevops/mailer/security/policy + about: Please report security vulnerabilities privately. + - name: Questions and support + url: https://github.com/slashdevops/mailer/discussions + about: Ask questions and discuss usage with the community. diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..030179b --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,59 @@ +version: "2" +linters: + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + # https://golangci-lint.run/usage/linters/#disabled-by-default + disable: + - govet + - godot + - wsl + - testpackage + - whitespace + - tagalign + - nosprintfhostport + - nlreturn + - nestif + - mnd + - misspell + - lll + - godox + - funlen + - gochecknoinits + - depguard + - goconst + - dupword + - cyclop + - gocognit + - maintidx + - gocyclo + - dupl + + settings: + errcheck: + check-type-assertions: false + check-blank: false + disable-default-exclusions: true + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - (net.Conn).Close + - (*net/smtp.Client).Close + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - io.Copy(os.Stdout) + - (io.Writer).Write + - (*bufio.Writer).Write + - (*encoding/json.Encoder).Encode + - os.Setenv + - os.Unsetenv + - fmt.Printf + - fmt.Print + - fmt.Println + - fmt.Fprint + - fmt.Fprintf + - (*strings.Builder).WriteString diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..a038e00 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,9 @@ +threshold: + file: 70 + package: 70 + total: 70 + +# The example program is runnable documentation, not covered by unit tests. +exclude: + paths: + - ^example/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17f7d1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Exported read accessors on `MailContent` (`FromName`, `FromAddress`, `ToName`, + `ToAddress`, `MimeType`, `Subject`, `Body`) so external `MailerService` + implementations can read message fields. +- TLS support in the SMTP transport: implicit TLS/SMTPS (`ImplicitTLS`, implied + on port 465) and opportunistic/required STARTTLS (`RequireTLS`). +- Configurable SMTP `DialTimeout`, `LocalName` (EHLO), and custom `TLSConfig`. +- `MailServiceConfig.QueueSize` to size the buffered queue independently of the + worker count. +- Per-message delivery deadline via `MailServiceConfig.Timeout`. +- Header-injection protection: the builder rejects CR/LF/NUL in header fields. +- Error wrapping via `MailerError.Err`/`Unwrap` for `errors.Is`/`errors.As`. +- In-process SMTP test server covering plain, STARTTLS, and implicit TLS paths; + concurrency tests run under `-race`. +- Project docs (`docs/`), `Makefile`, `.golangci.yaml`, `DEVELOPMENT_GUIDELINES.md`. + +### Changed + +- **Fixed a deadlock**: `Enqueue` no longer holds an exclusive lock during a + blocking channel send, so a full queue can no longer block `Start`/`Stop`. + Concurrency now uses an `RWMutex` plus an atomic `started` flag. +- SMTP messages are rendered with CRLF line endings and a UTF-8 `Content-Type`. +- `SMTPPort` accepts the full valid range (1–65535) instead of a fixed allowlist. +- Address length validation raised to RFC-compatible bounds (up to 254 chars); + body limit raised to 256 KiB. +- SMTP transport errors now wrap their underlying cause. + +### Security + +- STARTTLS/implicit TLS support with `RequireTLS` prevents sending credentials + or content over unencrypted connections. +- SMTP header/command injection is prevented at message-build time. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2432488 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,21 @@ +# Code of Conduct + +This project follows the Contributor Covenant Code of Conduct. + +## Our pledge + +We pledge to make participation in our community a harassment-free experience for everyone. + +## Our standards + +Examples of behavior that contributes to a positive environment include: + +- being respectful and constructive +- accepting feedback gracefully +- focusing on what is best for the community + +Examples of unacceptable behavior include: + +- harassment or intimidation +- insulting or derogatory comments +- publishing others' private information without permission diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..93b411f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +Thank you for helping improve `mailer`. + +## Development workflow + +1. Fork the repository and create a topic branch (`feat/...`, `fix/...`, `docs/...`). +2. Make focused changes and add or update tests and documentation. +3. Run the local verification suite: + + ```sh + make check # gofmt + go vet + go test -race + golangci-lint + ``` + + Or individually: `make fmt`, `make vet`, `make test`, `make lint`, `make cover`. +4. Open a pull request with a clear summary and rationale. + +## Pull request expectations + +- Include tests for any behavior change; new concurrent code must pass under `-race`. +- Keep the coverage threshold in `.testcoverage.yml` satisfied (`make cover`). +- Keep changes focused and update `CHANGELOG.md`. +- Mention any breaking changes clearly. + +See [DEVELOPMENT_GUIDELINES.md](DEVELOPMENT_GUIDELINES.md) for coding, concurrency, +and testing standards. diff --git a/DEVELOPMENT_GUIDELINES.md b/DEVELOPMENT_GUIDELINES.md new file mode 100644 index 0000000..e37ed1e --- /dev/null +++ b/DEVELOPMENT_GUIDELINES.md @@ -0,0 +1,60 @@ +# Development Guidelines + +These guidelines keep `mailer` consistent, correct, and easy to maintain. + +## Requirements + +- Go **1.25+**. +- `golangci-lint` for linting (`make lint`). + +## Project layout + +The package is intentionally flat (a library, not an application): + +| File | Responsibility | +| ---- | -------------- | +| `mailer.go` | `MailContent`, `MailContentBuilder`, validation, `MimeType`, `MailerError`. | +| `service.go` | `MailService` queue and worker pool, `MailQueueError`. | +| `smtp.go` | `MailerSMTP` transport (TLS/STARTTLS, auth, message rendering). | +| `doc.go` | Package-level documentation. | +| `*_test.go` | Unit and integration tests (including an in-process SMTP server). | +| `example/` | A runnable usage example (`package main`). | +| `docs/` | Guides and examples. | + +## Coding standards + +- Format with `gofmt` (`make fmt`); no unformatted code is merged. +- Keep the package **dependency-free** — standard library only. +- Every exported identifier has a doc comment starting with its name. +- Return typed errors (`*MailerError`, `*MailQueueError`) and wrap causes with + the `Err` field so `errors.Is`/`errors.As` work. +- Log through `log/slog` at appropriate levels; never log credentials or message bodies. +- Public API changes are additive where possible. `MailContent` stays immutable — + add accessors rather than exported fields. + +## Concurrency rules + +- `MailService` is safe for concurrent use. The `content` channel is closed + exactly once, under the write lock, in `Stop`; `Enqueue` holds the read lock + for the duration of a send so the channel can never be closed mid-send. +- Never perform a blocking channel send while holding an exclusive lock. +- All new concurrent behavior must be covered by tests that pass under `-race`. + +## Testing + +- Run `make test` (race detector + coverage) before opening a PR. +- Maintain the coverage threshold in `.testcoverage.yml` (`make cover`). +- Prefer the in-process `fakeSMTPServer` over network access for transport tests. +- Table-driven tests for validation and configuration. + +## Commit and PR workflow + +1. Branch from `main` (`feat/...`, `fix/...`, `docs/...`, `chore/...`). +2. Keep changes focused; add or update tests and docs. +3. Run `make check` locally. +4. Open a PR with a clear summary and rationale; call out breaking changes. + +## Releases + +Releases are tag-driven (`vX.Y.Z`). Pushing a matching tag triggers the release +workflow. Update `CHANGELOG.md` before tagging. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1beede8 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +GO ?= go +GOLANGCI_LINT ?= golangci-lint +COVER_PROFILE ?= coverage.txt + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help. + @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-16s\033[0m %s\n", $$1, $$2}' + +.PHONY: tidy +tidy: ## Tidy and verify go modules. + $(GO) mod tidy + $(GO) mod verify + +.PHONY: fmt +fmt: ## Format all Go sources. + gofmt -w . + +.PHONY: vet +vet: ## Run go vet. + $(GO) vet ./... + +.PHONY: build +build: ## Build all packages. + $(GO) build ./... + +.PHONY: test +test: ## Run tests with the race detector and coverage. + $(GO) test -race -covermode=atomic -coverprofile=$(COVER_PROFILE) ./... + +.PHONY: cover +cover: test ## Enforce the coverage threshold from .testcoverage.yml. + $(GO) run github.com/vladopajic/go-test-coverage/v2@latest --config=./.testcoverage.yml --profile=$(COVER_PROFILE) + +.PHONY: cover-html +cover-html: test ## Open the HTML coverage report. + $(GO) tool cover -html=$(COVER_PROFILE) + +.PHONY: lint +lint: ## Run golangci-lint. + $(GOLANGCI_LINT) run ./... + +.PHONY: check +check: fmt vet test lint ## Run the full local verification suite. + +.PHONY: clean +clean: ## Remove build and coverage artifacts. + rm -f $(COVER_PROFILE) + $(GO) clean ./... diff --git a/README.md b/README.md index 34874d5..c7bc527 100644 --- a/README.md +++ b/README.md @@ -2,180 +2,192 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/mailer.svg)](https://pkg.go.dev/github.com/slashdevops/mailer) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/mailer?style=plastic) -[![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/mailer)](https://goreportcard.com/report/github.com/slashdevops/mailer) - -This package provides a robust and concurrent email sending service for Go applications. It allows queueing emails and sending them asynchronously using a pool of workers via a configurable backend (e.g., SMTP). +[![license](https://img.shields.io/github/license/slashdevops/mailer.svg)](https://github.com/slashdevops/mailer/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/mailer/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/mailer/actions/workflows/release.yml) +[![release](https://img.shields.io/github/release/slashdevops/mailer/all.svg)](https://github.com/slashdevops/mailer/releases) + +`mailer` is a production-oriented Go library for **asynchronous, concurrent email delivery**. It combines a buffered queue, a worker-pool runtime, context-aware lifecycle control, validated message construction, and a pluggable transport (with a batteries-included, TLS-capable SMTP backend) so you can offload email sending from your request path without blocking. + +```text + Enqueue() buffered queue worker pool MailerService +producer ─────────▶ ┌──────────────────────────────┐ ─────▶ [w1 w2 … wN] ─────▶ Send(ctx, MailContent) + └──────────────────────────────┘ │ + back-pressure when full ▼ + SMTP / custom +``` ## Features -* **Concurrent Sending:** Uses a worker pool to send emails concurrently. -* **Buffering:** Queues emails in a buffered channel, sized according to the worker count. -* **Graceful Shutdown:** Supports context cancellation for stopping workers and waits for them to finish processing enqueued items. -* **Pluggable Backend:** Uses a `MailerService` interface, allowing different sending mechanisms (e.g., SMTP, API-based services). An SMTP implementation (`MailerSMTP`) is included. -* **Content Validation:** Includes a builder (`MailContentBuilder`) for creating validated `MailContent` with checks for field lengths and allowed MIME types. -* **Context Propagation:** Leverages `context.Context` for cancellation and timeout propagation throughout the sending process. -* **Structured Logging:** Uses the standard `log/slog` package for informative logging. -* **Error Handling:** Provides specific error types (`MailerError`, `MailQueueError`) for better error management. -* **Customizable Worker Count:** Allows configuring the number of concurrent workers within defined limits. -* **MIME Type Support:** Supports `text/plain` and `text/html` MIME types. -* **Sender and Recipient Details:** Allows specifying sender and recipient names along with email addresses. +- **Concurrent worker pool** — configurable number of workers drain a shared queue. +- **Back-pressure** — a bounded, buffered queue; `Enqueue` blocks (or fails on context cancellation) when full instead of growing unbounded. +- **Graceful shutdown** — `Stop()` closes the queue, drains in-flight work, and waits for workers. Context cancellation stops workers immediately. +- **Pluggable transports** — implement the small `MailerService` interface to send through any provider; `MailContent` exposes read accessors so external backends work. +- **TLS-capable SMTP** — implicit TLS (SMTPS, port 465) and opportunistic/required STARTTLS, PLAIN auth, configurable dial timeout and EHLO name. +- **Validated, injection-safe content** — a fluent `MailContentBuilder` validates addresses, MIME type, and lengths, and rejects CR/LF/NUL in header fields (SMTP header-injection protection). +- **Observability** — structured `log/slog` logging and typed, wrappable errors (`errors.Is`/`errors.As`). +- **Zero third-party dependencies** — standard library only. -## Installation +## Requirements -To use this library in your project, install it using `go get`: +- Go **1.25** or newer. + +## Installation ```sh go get github.com/slashdevops/mailer@latest -```` +``` -## Components +```go +import "github.com/slashdevops/mailer" +``` -* **`MailService`**: The main service that manages the email queue and worker pool. -* **`MailContent` / `MailContentBuilder`**: Struct and builder for defining email content (sender, recipient, subject, body, MIME type). -* **`MailerService`**: Interface for the actual email sending logic. -* **`MailerSMTP`**: An implementation of `MailerService` using standard SMTP. +## Quick start -## Configuration +```go +package main -### `MailService` +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" -Configure the `MailService` using `MailServiceConfig`: + "github.com/slashdevops/mailer" +) -```go -type MailServiceConfig struct { - Ctx context.Context // Optional: Parent context for cancellation. - WorkerCount int // Number of concurrent sending workers (1-100). - Timeout time.Duration // Optional: Timeout for operations (currently unused in core service logic but available). - Mailer MailerService // The backend mailer implementation (e.g., MailerSMTP). +func main() { + // 1. Configure a transport (the built-in SMTP backend). + smtpMailer, err := mailer.NewMailerSMTP(mailer.MailerSMTPConf{ + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: 587, + Username: os.Getenv("SMTP_USER"), + Password: os.Getenv("SMTP_PASS"), + RequireTLS: true, // refuse to send over an unencrypted connection + }) + if err != nil { + slog.Error("invalid SMTP configuration", "error", err) + os.Exit(1) + } + + // 2. Bind the worker context to OS signals for graceful shutdown. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // 3. Create and start the queue-backed service. + service, err := mailer.NewMailService(&mailer.MailServiceConfig{ + Ctx: ctx, + WorkerCount: 4, + QueueSize: 256, + Mailer: smtpMailer, + }) + if err != nil { + slog.Error("failed to create mail service", "error", err) + os.Exit(1) + } + service.Start() + + // 4. Build a validated message and enqueue it. + content, err := mailer.NewMailContentBuilder(). + WithFromName("Operations"). + WithFromAddress("ops@example.com"). + WithToName("Customer"). + WithToAddress("customer@example.com"). + WithMimeType(mailer.MimeTypeTextPlain). + WithSubject("Hello from mailer"). + WithBody("This is a queued email."). + Build() + if err != nil { + slog.Error("invalid email content", "error", err) + os.Exit(1) + } + if err := service.Enqueue(content); err != nil { + slog.Error("failed to enqueue email", "error", err) + } + + // 5. Shut down gracefully: drain the queue and wait for workers. + <-ctx.Done() + service.Stop() } ``` -### `MailerSMTP` +A complete, runnable program lives in [example/main.go](example/main.go). + +## Concepts + +| Type | Responsibility | +| ---- | -------------- | +| `MailContentBuilder` / `MailContent` | Build and validate an immutable message. | +| `MailService` | Queue messages and dispatch them through a worker pool. | +| `MailerService` (interface) | Transport contract: `Send(ctx, MailContent) error`. | +| `MailerSMTP` | Standard-library SMTP transport with TLS/STARTTLS and auth. | + +### `MailServiceConfig` -Configure the `MailerSMTP` backend using `MailerSMTPConf`: +| Field | Default | Description | +| ----- | ------- | ----------- | +| `Ctx` | `context.Background()` | Governs worker lifetime; cancel to stop workers. | +| `WorkerCount` | — (required, 1–100) | Number of concurrent workers. | +| `QueueSize` | `WorkerCount` | Buffered queue capacity (burst absorption). | +| `Timeout` | `0` (disabled) | Per-message deadline applied to each `Send`. | +| `Mailer` | — (required) | The transport implementation. | + +### `MailerSMTPConf` + +| Field | Default | Description | +| ----- | ------- | ----------- | +| `SMTPHost` / `SMTPPort` | — (required) | Server host and port (1–65535). | +| `Username` / `Password` | empty | PLAIN auth credentials; empty disables auth. | +| `ImplicitTLS` | `false` (implied on 465) | TLS from the first byte (SMTPS). | +| `RequireTLS` | `false` | Fail rather than send over an unencrypted link. | +| `TLSConfig` | verify against `SMTPHost`, TLS 1.2+ | Custom `*tls.Config`. | +| `DialTimeout` | `10s` | TCP connection timeout. | +| `LocalName` | `localhost` | Name announced in EHLO/HELO. | + +## Lifecycle & shutdown semantics + +- `Start()` is idempotent and launches `WorkerCount` goroutines. +- `Enqueue()` is safe for concurrent use. It blocks while the queue is full and returns `ErrServiceStopped` after `Stop()`, or the context error if the context is cancelled first. +- `Stop()` is idempotent and safe from multiple goroutines. It stops accepting work, closes the queue, and **drains queued messages** before returning. +- Cancelling `Ctx` stops workers promptly **without** draining; use it for hard shutdown and `Stop()` for graceful shutdown. + +## Custom transports + +`MailContent` exposes read accessors (`FromName()`, `FromAddress()`, `ToName()`, `ToAddress()`, `MimeType()`, `Subject()`, `Body()`), so any backend can implement `MailerService`: ```go -type MailerSMTPConf struct { - SMTPHost string // SMTP server hostname. - SMTPPort int // SMTP server port (e.g., 587, 465, 25). - Username string // SMTP username for authentication. - Password string // SMTP password for authentication. +type ConsoleMailer struct{} + +func (ConsoleMailer) Send(ctx context.Context, m mailer.MailContent) error { + slog.Info("would send", "to", m.ToAddress(), "subject", m.Subject()) + return nil } ``` -## Usage Example +See [docs/examples/custom-backend.md](docs/examples/custom-backend.md). -```go -package main +## Documentation -import ( - "context" - "fmt" - "log/slog" - "os" - "os/signal" - "syscall" - "time" - - "github.com/slashdevops/mailer" // Assuming this is the module path -) +- [Docs overview](docs/README.md) +- [Production guide](docs/production-guide.md) — lifecycle, TLS, worker sizing, observability, testing. +- [Basic example](docs/examples/basic.md) +- [Custom backend example](docs/examples/custom-backend.md) +- [GoDoc reference](https://pkg.go.dev/github.com/slashdevops/mailer) -func main() { - // --- Configuration --- - smtpConf := mailer.MailerSMTPConf{ - SMTPHost: "smtp.example.com", // Replace with your SMTP host - SMTPPort: 587, // Replace with your SMTP port - Username: "user@example.com", // Replace with your SMTP username - Password: "your_password", // Replace with your SMTP password - } - - smtpMailer, err := mailer.NewMailerSMTP(smtpConf) - if err != nil { - slog.Error("Failed to configure SMTP mailer", "error", err) - os.Exit(1) - } - - // Create a context that can be cancelled - appCtx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - mailServiceConf := &mailer.MailServiceConfig{ - Ctx: appCtx, // Use the cancellable context - WorkerCount: 5, // Number of concurrent workers - Mailer: smtpMailer, // Use the configured SMTP mailer - } - - mailService, err := mailer.NewMailService(mailServiceConf) - if err != nil { - slog.Error("Failed to create mail service", "error", err) - os.Exit(1) - } - - // --- Start the Service --- - // Start the service with the application context - mailService.Start(appCtx) - slog.Info("Mail service started. Press Ctrl+C to stop.") - - // --- Enqueue Emails --- - go func() { - // Example of enqueuing emails - for i := 0; i < 10; i++ { - subject := fmt.Sprintf("Test Email %d", i+1) - body := fmt.Sprintf("This is the body of test email #%d.", i+1) - - content, err := (&mailer.MailContentBuilder{}). - WithFromName("Sender Name"). - WithFromAddress("sender@example.com"). - WithToName("Recipient Name"). - WithToAddress("recipient@example.com"). // Replace with a valid recipient - WithMimeType("text/plain"). - WithSubject(subject). - WithBody(body). - Build() - - if err != nil { - slog.Error("Failed to build mail content", "error", err) - continue // Skip this email - } - - err = mailService.Enqueue(content) - if err != nil { - // This might happen if the context is cancelled while enqueuing - slog.Error("Failed to enqueue email", "error", err) - // If context is cancelled, we should probably stop trying to enqueue - if appCtx.Err() != nil { - break - } - } else { - slog.Info("Email enqueued", "subject", subject) - } - time.Sleep(500 * time.Millisecond) // Simulate some delay between emails - } - slog.Info("Finished enqueuing sample emails.") - }() - - // --- Wait for Shutdown Signal --- - <-appCtx.Done() // Block until context is cancelled (Ctrl+C) - - slog.Info("Shutdown signal received.") - - // --- Stop the Service Gracefully --- - // Stop accepting new emails and wait for workers to finish - // Note: Stop() closes the channel. If context cancellation is the primary - // shutdown mechanism, workers will stop based on <-ctx.Done(). - // Calling Stop() ensures the channel is closed if not already done by context cancellation propagation. - // Depending on exact needs, you might just rely on context cancellation and use Wait(). - // Using Stop() here is generally safer for ensuring cleanup. - mailService.Stop() // This also calls Wait() internally after closing the channel - - slog.Info("Mail service stopped gracefully.") -} +## Development + +```sh +make test # go test -race with coverage +make lint # golangci-lint +make cover # enforce the coverage threshold ``` -## Contributing +See [CONTRIBUTING.md](CONTRIBUTING.md) and [DEVELOPMENT_GUIDELINES.md](DEVELOPMENT_GUIDELINES.md). + +## Security -Contributions are welcome! Please feel free to submit pull requests or open issues. +Report vulnerabilities per [SECURITY.md](SECURITY.md). The builder rejects header-injection attempts (CR/LF/NUL in header fields), and the SMTP backend supports `RequireTLS` to avoid sending credentials or content over unencrypted connections. ## License -This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. +Licensed under the Apache License 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..420ecac --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Supported Versions + +While the project is pre-1.0, security fixes are applied to the latest released +minor version. + +| Version | Supported | +| ------- | ------------------ | +| 0.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Report privately using GitHub's +[private vulnerability reporting](https://github.com/slashdevops/mailer/security/advisories/new) +for this repository. Include: + +- a description of the issue and its impact, +- steps to reproduce or a proof of concept, +- affected version(s), and +- any suggested remediation. + +You can expect an initial acknowledgement within a few business days. We will +work with you on a fix and coordinate disclosure once a patch is available. + +## Security considerations when using this library + +- Enable `RequireTLS` on the SMTP transport so messages and credentials are + never sent over an unencrypted connection. +- Load SMTP credentials from a secret store or environment variables, never from + source control. +- Build messages exclusively through `MailContentBuilder`; it rejects CR/LF/NUL + in header fields to prevent SMTP header/command injection. diff --git a/doc.go b/doc.go index 5883a7a..267d8d7 100644 --- a/doc.go +++ b/doc.go @@ -1,27 +1,36 @@ /* -Package mailer provides a robust and concurrent email sending service for Go applications. +Package mailer provides a production-oriented, concurrent email sending service +for Go applications. -It features a queue-based system (`MailService`) that utilizes a pool of worker goroutines -to send emails asynchronously. This allows applications to enqueue emails quickly without -blocking on the actual sending process. +It features a queue-based dispatcher (MailService) backed by a pool of worker +goroutines, so applications can enqueue validated messages quickly without +blocking on delivery. Delivery is performed by any implementation of the +MailerService interface; a standard-library SMTP transport (MailerSMTP) with +TLS/STARTTLS and authentication is included. -Key Features: +Key features: - - Concurrent email sending via a configurable worker pool. - - Buffered queue for email content (`MailContent`). - - Graceful shutdown using context cancellation and wait groups. - - Pluggable backend architecture via the `MailerService` interface. - - Includes a standard SMTP implementation (`MailerSMTP`). - - Provides a builder (`MailContentBuilder`) for validated email content creation. + - Concurrent delivery via a configurable worker pool. + - Bounded, buffered queue with back-pressure. + - Graceful shutdown (queue drain) and context-driven hard shutdown. + - Pluggable transports via the MailerService interface; MailContent exposes + read accessors so external backends can read every field. + - SMTP transport with implicit TLS (SMTPS), opportunistic/required STARTTLS, + PLAIN auth, and a configurable dial timeout and EHLO name. + - Validated, injection-safe message construction via MailContentBuilder + (CR/LF/NUL are rejected in header fields). + - Structured logging (log/slog) and typed, wrappable errors. -Usage typically involves: +Typical usage: - 1. Configuring a `MailerService` implementation (e.g., `NewMailerSMTP`). - 2. Configuring the main `MailService` with the desired worker count and the chosen mailer backend. - 3. Starting the `MailService` with a context. - 4. Enqueuing `MailContent` instances using the `Enqueue` method. - 5. Stopping the service gracefully or relying on context cancellation for shutdown. + 1. Configure a transport (for example, NewMailerSMTP). + 2. Configure and create the MailService with a worker count and the transport. + 3. Start the service. + 4. Build validated MailContent with NewMailContentBuilder and Enqueue it. + 5. Stop the service for a graceful, queue-draining shutdown, or cancel the + context for an immediate stop. -See the `ExampleMailService_Enqueue` function in the tests for a practical usage demonstration. +See ExampleMailService_Enqueue for a runnable demonstration, and the docs +directory for the production guide and additional examples. */ package mailer diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..dd25c72 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Documentation + +This directory contains deeper implementation guidance and runnable examples for the `mailer` package. + +## Guides + +- [Production guide](production-guide.md) — architecture, lifecycle, TLS, worker sizing, observability, security, and deployment recommendations. +- [Basic example](examples/basic.md) — a minimal end-to-end setup for a mail queue. +- [Custom backend example](examples/custom-backend.md) — implement the `MailerService` interface for a non-SMTP provider. + +## Recommended reading order + +1. Start with the [production guide](production-guide.md) to understand the package model. +2. Review the [basic example](examples/basic.md) to see a real integration path. +3. Read the [custom backend example](examples/custom-backend.md) if you deliver through an API (SES, SendGrid, etc.) instead of SMTP. +4. Use [../example/main.go](../example/main.go) as a template for your own service. + +## API reference + +The full, authoritative API reference is published on +[pkg.go.dev/github.com/slashdevops/mailer](https://pkg.go.dev/github.com/slashdevops/mailer). diff --git a/docs/examples/basic.md b/docs/examples/basic.md new file mode 100644 index 0000000..c156a67 --- /dev/null +++ b/docs/examples/basic.md @@ -0,0 +1,80 @@ +# Basic example + +This example shows how to wire `mailer` into a small service, step by step. + +## Step 1: Create the SMTP backend + +`RequireTLS` makes delivery fail rather than send credentials over an unencrypted +connection. On port 587 the client upgrades with STARTTLS; use port 465 (or +`ImplicitTLS: true`) for implicit TLS. + +```go +backend, err := mailer.NewMailerSMTP(mailer.MailerSMTPConf{ + SMTPHost: "smtp.example.com", + SMTPPort: 587, + Username: os.Getenv("SMTP_USER"), + Password: os.Getenv("SMTP_PASS"), + RequireTLS: true, +}) +if err != nil { + log.Fatal(err) +} +``` + +## Step 2: Create and start the mail service + +```go +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +service, err := mailer.NewMailService(&mailer.MailServiceConfig{ + Ctx: ctx, + WorkerCount: 4, + QueueSize: 64, + Mailer: backend, +}) +if err != nil { + log.Fatal(err) +} + +service.Start() +``` + +## Step 3: Build a validated message + +```go +content, err := mailer.NewMailContentBuilder(). + WithFromName("Operations"). + WithFromAddress("ops@example.com"). + WithToName("Customer"). + WithToAddress("customer@example.com"). + WithMimeType(mailer.MimeTypeTextHTML). + WithSubject("Welcome"). + WithBody("

Thanks for signing up.

"). + Build() +if err != nil { + log.Fatal(err) // validation error (*mailer.MailerError) +} +``` + +## Step 4: Enqueue the message + +```go +if err := service.Enqueue(content); err != nil { + // ErrServiceStopped, or a context error if the context was cancelled. + log.Print(err) +} +``` + +## Step 5: Stop gracefully + +`Stop` closes the queue, drains any messages still queued, and waits for the +workers to finish before returning. + +```go +service.Stop() +``` + +This pattern suits CLI tools, HTTP handlers, and background jobs that need to +offload email sending to a worker pool. See the runnable +[example/main.go](../../example/main.go) for a complete program. diff --git a/docs/examples/custom-backend.md b/docs/examples/custom-backend.md new file mode 100644 index 0000000..14ededf --- /dev/null +++ b/docs/examples/custom-backend.md @@ -0,0 +1,116 @@ +# Custom backend example + +The `MailService` queue and worker pool are transport-agnostic. To deliver +through something other than SMTP — a provider API (Amazon SES, SendGrid, +Postmark), a message bus, or a test double — implement the `MailerService` +interface: + +```go +type MailerService interface { + Send(ctx context.Context, content MailContent) error +} +``` + +`MailContent` is immutable and exposes read accessors, so your backend can read +every field it needs: + +```go +content.FromName() // string +content.FromAddress() // string +content.ToName() // string +content.ToAddress() // string +content.MimeType() // mailer.MimeType (text/plain | text/html) +content.Subject() // string +content.Body() // string +``` + +## Example: an HTTP API backend + +```go +package mailx + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/slashdevops/mailer" +) + +// APIMailer delivers messages through a hypothetical JSON email API. +type APIMailer struct { + Endpoint string + APIKey string + Client *http.Client +} + +// Send implements mailer.MailerService. +func (m *APIMailer) Send(ctx context.Context, content mailer.MailContent) error { + payload, err := json.Marshal(map[string]string{ + "from": fmt.Sprintf("%s <%s>", content.FromName(), content.FromAddress()), + "to": fmt.Sprintf("%s <%s>", content.ToName(), content.ToAddress()), + "subject": content.Subject(), + "content_type": content.MimeType().String(), + "body": content.Body(), + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.Endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+m.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := m.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("email API returned status %d", resp.StatusCode) + } + return nil +} +``` + +Wire it into the service exactly like the SMTP backend: + +```go +service, err := mailer.NewMailService(&mailer.MailServiceConfig{ + WorkerCount: 8, + QueueSize: 512, + Mailer: &mailx.APIMailer{Endpoint: endpoint, APIKey: key, Client: http.DefaultClient}, +}) +``` + +## Guidelines for a robust backend + +- **Honor the context.** Pass `ctx` to every network call so `MailServiceConfig.Timeout` and shutdown propagate. +- **Return meaningful errors.** They are logged by the workers; wrap the cause so `errors.Is`/`errors.As` work upstream. +- **Be safe for concurrent use.** Workers call `Send` from multiple goroutines simultaneously — share an `*http.Client`, avoid per-call global mutation. +- **Layer policy by wrapping.** Implement retries, circuit breaking, or dead-letter handling in a `MailerService` that wraps another one, keeping the queue simple. + +## Example: a test double + +```go +type RecordingMailer struct { + mu sync.Mutex + Sent []mailer.MailContent +} + +func (r *RecordingMailer) Send(_ context.Context, c mailer.MailContent) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Sent = append(r.Sent, c) + return nil +} +``` + +Use a recording or mock mailer to unit-test your enqueue logic without touching +the network. diff --git a/docs/production-guide.md b/docs/production-guide.md new file mode 100644 index 0000000..6396ebb --- /dev/null +++ b/docs/production-guide.md @@ -0,0 +1,150 @@ +# Production guide + +This guide explains how to run `mailer` in production-grade services. + +## 1. Architecture overview + +The package is built around three layers: + +1. **Message construction and validation** — `MailContentBuilder` produces an immutable, validated `MailContent`. +2. **A queue-backed worker service** — `MailService` accepts work asynchronously and dispatches it through a pool of workers. +3. **A pluggable transport** — any implementation of `MailerService`; the package ships `MailerSMTP`. + +```text +MailContentBuilder ─▶ MailContent ─▶ MailService.Enqueue ─▶ [queue] ─▶ workers ─▶ MailerService.Send +``` + +## 2. Service lifecycle + +Create the service once at startup, start the workers, and stop them during shutdown. + +```go +ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +defer stop() + +backend, err := mailer.NewMailerSMTP(mailer.MailerSMTPConf{ /* ... */ }) +if err != nil { + return err +} + +service, err := mailer.NewMailService(&mailer.MailServiceConfig{ + Ctx: ctx, + WorkerCount: 4, + QueueSize: 256, + Timeout: 30 * time.Second, + Mailer: backend, +}) +if err != nil { + return err +} + +service.Start() +defer service.Stop() // graceful: drains the queue and waits for workers +``` + +### Shutdown semantics + +| Action | Effect | +| ------ | ------ | +| `Stop()` | Stops accepting work, closes the queue, **drains queued messages**, waits for workers. Idempotent. | +| Cancel `Ctx` | Stops workers promptly **without** draining. Use for hard shutdown. | +| `Enqueue()` after `Stop()` | Returns `ErrServiceStopped`. | +| `Enqueue()` after `Ctx` cancelled | Returns the context error. | + +`Start()`, `Stop()`, and `Enqueue()` are all safe for concurrent use. + +## 3. Back-pressure and queue sizing + +The queue is a bounded, buffered channel with capacity `QueueSize` (defaults to `WorkerCount`). When it is full, `Enqueue` **blocks** until a worker frees a slot or the context is cancelled. This is deliberate back-pressure — the queue never grows unbounded. + +- Size `QueueSize` to absorb your expected burst without blocking request handlers. +- If you cannot tolerate blocking on the request path, enqueue from a background goroutine, or wrap `Enqueue` with a bounded `select`/timeout at the call site. + +## 4. Worker sizing + +Choose `WorkerCount` based on volume and remote latency: + +- Small services: 2–4 workers. +- Medium services: 4–8 workers. +- High-throughput systems: 8+ workers, validated with load testing. + +A good starting point is the SMTP concurrency your provider allows. Most providers rate-limit concurrent connections — do not exceed that. + +## 5. Per-message timeout + +Set `Timeout` to bound each `Send`. Every delivery then runs with a context derived from the worker context and cancelled after the deadline, so a slow or hung SMTP server cannot stall a worker indefinitely. + +## 6. SMTP transport and TLS + +`MailerSMTP` supports both TLS modes: + +- **Implicit TLS (SMTPS)** — set `ImplicitTLS: true` (implied for port `465`). TLS is negotiated before any SMTP command. +- **STARTTLS** — on other ports the client upgrades the plaintext connection when the server advertises `STARTTLS`. + +Recommendations: + +- Set `RequireTLS: true` so delivery fails instead of sending credentials or content over an unencrypted connection. +- Load credentials from environment variables or a secrets manager, never source control. +- Provide a custom `TLSConfig` only when you need pinning or a specific `MinVersion`; the default verifies the server against `SMTPHost` and requires TLS 1.2+. +- Tune `DialTimeout` to match your SLA and set `LocalName` if your provider validates the EHLO name. + +```go +backend, err := mailer.NewMailerSMTP(mailer.MailerSMTPConf{ + SMTPHost: "smtp.example.com", + SMTPPort: 587, + Username: os.Getenv("SMTP_USER"), + Password: os.Getenv("SMTP_PASS"), + RequireTLS: true, + DialTimeout: 10 * time.Second, + LocalName: "app-1.example.com", +}) +``` + +## 7. Content validation and injection safety + +`Build()` validates sender/recipient names and addresses, MIME type, subject, and body lengths, and **rejects CR/LF and NUL bytes in header fields** to prevent SMTP header/command injection. Always construct messages through the builder rather than assembling raw content, and treat a `*MailerError` from `Build()` as a client-side validation failure (HTTP 4xx), not a server error. + +## 8. Error handling + +- `Enqueue` errors mean the service is stopped (`ErrServiceStopped`) or the context is cancelled — surface them to the caller. +- `Send` errors are logged by the workers via `slog`. All transport errors are `*MailerError` and wrap the underlying cause, so `errors.Is`/`errors.As` work: + +```go +var mErr *mailer.MailerError +if errors.As(err, &mErr) { + // inspect mErr.Message / errors.Unwrap(mErr) +} +``` + +If you need per-message success/failure handling (retries, dead-letter queues), implement it inside a custom `MailerService` that wraps `MailerSMTP`. + +## 9. Observability + +The package logs through the standard `log/slog` default logger. Configure a handler at startup: + +```go +slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))) +``` + +Track, from your own instrumentation around `Enqueue`/`Send`: queue occupancy, enqueue rejections, send failures, and send latency. + +## 10. Testing + +The package ships unit tests covering the builder, service lifecycle, concurrency (with `-race`), and the SMTP transport against an in-process SMTP server (plain, STARTTLS, and implicit TLS). For your integration, add tests for: + +- graceful shutdown with messages still queued, +- rejection of `Enqueue` after `Stop`, +- context cancellation during send, +- transport errors from your provider. + +Use a mock `MailerService` for fast unit tests and a container such as [MailHog](https://github.com/mailhog/MailHog) for local end-to-end runs. + +## 11. Deployment checklist + +- [ ] SMTP credentials are loaded from a secret store. +- [ ] `RequireTLS` is enabled. +- [ ] `WorkerCount` and `QueueSize` are tuned for your workload and provider limits. +- [ ] `Timeout` is set to bound individual sends. +- [ ] The application has a graceful shutdown path that calls `Stop()`. +- [ ] A `slog` handler is configured and logs are shipped. +- [ ] You can observe queue depth and failure rates. diff --git a/example/main.go b/example/main.go index a09cdae..ee56b8b 100644 --- a/example/main.go +++ b/example/main.go @@ -1,4 +1,6 @@ -// filepath: example/main.go +// Command example demonstrates how to wire the mailer package into a service: +// configure an SMTP transport, run a worker pool, enqueue validated messages, +// and shut down gracefully on SIGINT/SIGTERM. package main import ( @@ -10,107 +12,73 @@ import ( "syscall" "time" - "github.com/slashdevops/mailer" // Import the library + "github.com/slashdevops/mailer" ) func main() { - // --- Configuration --- - // Configure the SMTP backend - smtpConf := mailer.MailerSMTPConf{ - SMTPHost: os.Getenv("SMTP_HOST"), // Use environment variables for sensitive data - SMTPPort: 587, // Or get from env - Username: os.Getenv("SMTP_USER"), - Password: os.Getenv("SMTP_PASS"), - } - - smtpMailer, err := mailer.NewMailerSMTP(smtpConf) + // Configure the SMTP transport. Credentials come from the environment so + // they never end up in source control. RequireTLS refuses to send unless the + // connection is secured (implicit TLS on 465 or STARTTLS otherwise). + smtpMailer, err := mailer.NewMailerSMTP(mailer.MailerSMTPConf{ + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: 587, + Username: os.Getenv("SMTP_USER"), + Password: os.Getenv("SMTP_PASS"), + RequireTLS: true, + }) if err != nil { - slog.Error("Failed to configure SMTP mailer", "error", err) + slog.Error("failed to configure SMTP mailer", "error", err) os.Exit(1) } - // Create a context that handles OS interrupt signals for graceful shutdown + // Cancel the worker context when an interrupt signal arrives. appCtx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() // Ensure cancellation propagates - - // Configure the mail service - mailServiceConf := &mailer.MailServiceConfig{ - Ctx: appCtx, // Pass the application context - WorkerCount: 5, // Set desired number of concurrent workers - Mailer: smtpMailer, // Provide the configured SMTP mailer backend - } - - mailService, err := mailer.NewMailService(mailServiceConf) + defer cancel() + + mailService, err := mailer.NewMailService(&mailer.MailServiceConfig{ + Ctx: appCtx, + WorkerCount: 5, + QueueSize: 256, + Timeout: 30 * time.Second, + Mailer: smtpMailer, + }) if err != nil { - slog.Error("Failed to create mail service", "error", err) + slog.Error("failed to create mail service", "error", err) os.Exit(1) } - // --- Start the Service --- - // Start the mail service workers. They will listen on the internal channel. - mailService.Start() // Pass the context again for workers - slog.Info("Mail service started. Press Ctrl+C to stop.") + mailService.Start() + slog.Info("mail service started, press Ctrl+C to stop") - // --- Enqueue Emails (Example in a Goroutine) --- + // Enqueue a few sample messages from a background goroutine. go func() { - // Example loop to enqueue emails - for i := 0; i < 10; i++ { - select { - case <-appCtx.Done(): // Check if shutdown signal received before enqueuing - slog.Info("Enqueue loop: Shutdown signal received, stopping.") - return - default: - // Proceed to build and enqueue - } - - subject := fmt.Sprintf("Test Email %d", i+1) - body := fmt.Sprintf("This is the body of test email #%d.", i+1) - - // Use the builder to create and validate email content - content, err := (&mailer.MailContentBuilder{}). + for i := range 10 { + content, err := mailer.NewMailContentBuilder(). WithFromName("Awesome Sender"). - WithFromAddress("sender@example.com"). // Use your actual sender address + WithFromAddress("sender@example.com"). WithToName("Valued Recipient"). - WithToAddress("recipient@example.com"). // Replace with a valid recipient - WithMimeType(mailer.MimeTypeTextPlain). // Use constants if available, otherwise "text/plain" - WithSubject(subject). - WithBody(body). + WithToAddress("recipient@example.com"). + WithMimeType(mailer.MimeTypeTextPlain). + WithSubject(fmt.Sprintf("Test Email %d", i+1)). + WithBody(fmt.Sprintf("This is the body of test email #%d.", i+1)). Build() if err != nil { - slog.Error("Failed to build mail content", "email_index", i, "error", err) - continue // Skip this email if content is invalid + slog.Error("failed to build mail content", "index", i, "error", err) + continue } - // Enqueue the validated content. This might block if the queue is full. - // It also respects the context cancellation. - err = mailService.Enqueue(content) - if err != nil { - // Error could be due to context cancellation during enqueue attempt - slog.Error("Failed to enqueue email", "subject", subject, "error", err) - // If context is cancelled, stop trying to enqueue more - if appCtx.Err() != nil { - slog.Warn("Enqueue loop: Context cancelled, stopping enqueue attempts.") - return - } - } else { - slog.Info("Email enqueued successfully", "subject", subject) + if err := mailService.Enqueue(content); err != nil { + slog.Error("failed to enqueue email", "index", i, "error", err) + return } - // Simulate some delay or work between enqueuing emails - time.Sleep(500 * time.Millisecond) + slog.Info("email enqueued", "index", i) } - slog.Info("Finished enqueuing sample emails.") }() - // --- Wait for Shutdown Signal --- - // Block main goroutine until the context is cancelled (e.g., by Ctrl+C) <-appCtx.Done() + slog.Info("shutdown signal received, stopping mail service") - slog.Info("Shutdown signal received. Stopping mail service...") - - // --- Stop the Service Gracefully --- - // Stop accepting new emails by closing the channel and wait for workers - // to finish processing any remaining emails in the queue. - mailService.Stop() // This calls Wait() internally - - slog.Info("Mail service stopped gracefully.") + // Stop stops accepting new work, drains the queue, and waits for workers. + mailService.Stop() + slog.Info("mail service stopped gracefully") } diff --git a/go.mod b/go.mod index a5241d0..5902d5e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/slashdevops/mailer -go 1.24.3 +go 1.25.0 diff --git a/mailer.go b/mailer.go index e4faefd..863ffc2 100644 --- a/mailer.go +++ b/mailer.go @@ -3,33 +3,39 @@ package mailer import ( "context" "fmt" - "slices" + "net/mail" "strings" ) +// Validation bounds for MailContent fields. They guard against obviously +// malformed input and keep messages within sane limits before they reach a +// transport. const ( ValidMinFromNameLength = 1 ValidMaxFromNameLength = 100 - ValidMinFromAddressLength = 5 - ValidMaxFromAddressLength = 50 + ValidMinFromAddressLength = 3 + ValidMaxFromAddressLength = 254 ValidMinToNameLength = 1 ValidMaxToNameLength = 100 - ValidMinToAddressLength = 5 - ValidMaxToAddressLength = 50 - ValidMimeType = "text/plain|text/html" + ValidMinToAddressLength = 3 + ValidMaxToAddressLength = 254 ValidMinSubjectLength = 1 ValidMaxSubjectLength = 255 ValidMinBodyLength = 1 - ValidMaxBodyLength = 20000 + ValidMaxBodyLength = 1 << 18 // 256 KiB, large enough for rich HTML bodies. ) -// MailerService is an interface must be implemented by any mailer service -// that is used to send emails. +// ValidMimeType lists the accepted MIME types separated by "|". +const ValidMimeType = "text/plain|text/html" + +// MailerService is implemented by any transport capable of delivering a message. +// Implementations must honour the provided context (cancellation/deadline) and +// read the message through the exported MailContent accessors. type MailerService interface { Send(ctx context.Context, content MailContent) error } -// MimeType is a custom type for MIME types. +// MimeType is a custom type for the supported MIME types. type MimeType string const ( @@ -41,149 +47,186 @@ const ( ) // String returns the string representation of the MimeType. -func (m MimeType) String() string { - return string(m) -} +func (m MimeType) String() string { return string(m) } -// IsValid checks if the MimeType is valid. +// IsValid reports whether the MimeType is one of the supported values. func (m MimeType) IsValid() bool { return m == MimeTypeTextPlain || m == MimeTypeTextHTML } -// MailerError is a custom error type for mailer-related errors. -// It implements the error interface. +// MailerError is a validation or transport error produced by the package. type MailerError struct { Message string + // Err is the wrapped underlying error, if any. It enables errors.Is/As. + Err error } // Error implements the error interface for MailerError. func (e *MailerError) Error() string { + if e.Err != nil { + return e.Message + ": " + e.Err.Error() + } return e.Message } -// MailContent represents the content of an email. -type MailContent struct { - // fromName: The name of the sender - // e.g. "John Doe" - fromName string +// Unwrap exposes the wrapped error for errors.Is/errors.As. +func (e *MailerError) Unwrap() error { return e.Err } - // fromAddress: The email address of the sender - // e.g. "john.doe@example.com" +// MailContent is an immutable, validated email message. Instances are created +// exclusively through MailContentBuilder so that every field is validated before +// it can be dispatched. The fields are unexported to keep the value immutable; +// transports read them through the accessor methods. +type MailContent struct { + fromName string fromAddress string + toName string + toAddress string + mimeType MimeType + subject string + body string +} - // toName: The name of the recipient - // e.g. "Jane Doe" - toName string +// FromName returns the sender display name. +func (c MailContent) FromName() string { return c.fromName } - // toAddress: The email address of the recipient - // e.g. "jane.doe@example.com" - toAddress string +// FromAddress returns the sender email address. +func (c MailContent) FromAddress() string { return c.fromAddress } - // mimeType: The MIME type of the email - // e.g. "text/plain" or "text/html" - mimeType MimeType +// ToName returns the recipient display name. +func (c MailContent) ToName() string { return c.toName } - // subject is the subject of the email - subject string +// ToAddress returns the recipient email address. +func (c MailContent) ToAddress() string { return c.toAddress } - // Body is the body of the email - body string -} +// MimeType returns the message MIME type. +func (c MailContent) MimeType() MimeType { return c.mimeType } + +// Subject returns the message subject. +func (c MailContent) Subject() string { return c.subject } + +// Body returns the message body. +func (c MailContent) Body() string { return c.body } +// MailContentBuilder builds and validates MailContent using a fluent API. type MailContentBuilder struct { mailContent MailContent } +// NewMailContentBuilder returns a builder pre-configured with a text/plain MIME type. func NewMailContentBuilder() *MailContentBuilder { - return &MailContentBuilder{ - mailContent: MailContent{ - fromName: "", - fromAddress: "", - toName: "", - toAddress: "", - mimeType: MimeTypeTextPlain, - subject: "", - body: "", - }, - } + return &MailContentBuilder{mailContent: MailContent{mimeType: MimeTypeTextPlain}} } +// WithFromName sets the sender display name. func (b *MailContentBuilder) WithFromName(name string) *MailContentBuilder { b.mailContent.fromName = name return b } +// WithFromAddress sets the sender email address. func (b *MailContentBuilder) WithFromAddress(address string) *MailContentBuilder { b.mailContent.fromAddress = address return b } +// WithToName sets the recipient display name. func (b *MailContentBuilder) WithToName(name string) *MailContentBuilder { b.mailContent.toName = name return b } +// WithToAddress sets the recipient email address. func (b *MailContentBuilder) WithToAddress(address string) *MailContentBuilder { b.mailContent.toAddress = address return b } +// WithMimeType sets the MIME type. func (b *MailContentBuilder) WithMimeType(mimeType MimeType) *MailContentBuilder { - // Remove the defaulting logic. Validation happens in Build(). b.mailContent.mimeType = mimeType return b } +// WithMimeTypeAsString sets the MIME type from a raw string. func (b *MailContentBuilder) WithMimeTypeAsString(mimeType string) *MailContentBuilder { return b.WithMimeType(MimeType(mimeType)) } +// WithSubject sets the subject line. func (b *MailContentBuilder) WithSubject(subject string) *MailContentBuilder { b.mailContent.subject = subject return b } +// WithBody sets the message body. func (b *MailContentBuilder) WithBody(body string) *MailContentBuilder { b.mailContent.body = body return b } +// Build validates every field and returns an immutable MailContent, or a +// *MailerError describing the first validation failure. func (b *MailContentBuilder) Build() (MailContent, error) { - if len(b.mailContent.fromName) < ValidMinFromNameLength || len(b.mailContent.fromName) > ValidMaxFromNameLength { - return MailContent{}, &MailerError{ - Message: fmt.Sprintf("fromName must be between %d and %d characters", ValidMinFromNameLength, ValidMaxFromNameLength), - } + c := b.mailContent + + if err := validateLength("fromName", c.fromName, ValidMinFromNameLength, ValidMaxFromNameLength); err != nil { + return MailContent{}, err + } + if err := validateHeaderSafe("fromName", c.fromName); err != nil { + return MailContent{}, err } - if len(b.mailContent.toName) < ValidMinToNameLength || len(b.mailContent.toName) > ValidMaxToNameLength { - return MailContent{}, &MailerError{ - Message: fmt.Sprintf("toName must be between %d and %d characters", ValidMinToNameLength, ValidMaxToNameLength), - } + if err := validateLength("fromAddress", c.fromAddress, ValidMinFromAddressLength, ValidMaxFromAddressLength); err != nil { + return MailContent{}, err + } + if _, err := mail.ParseAddress(c.fromAddress); err != nil { + return MailContent{}, &MailerError{Message: "fromAddress must be a valid email address", Err: err} + } + + if err := validateLength("toName", c.toName, ValidMinToNameLength, ValidMaxToNameLength); err != nil { + return MailContent{}, err + } + if err := validateHeaderSafe("toName", c.toName); err != nil { + return MailContent{}, err + } + + if err := validateLength("toAddress", c.toAddress, ValidMinToAddressLength, ValidMaxToAddressLength); err != nil { + return MailContent{}, err + } + if _, err := mail.ParseAddress(c.toAddress); err != nil { + return MailContent{}, &MailerError{Message: "toAddress must be a valid email address", Err: err} } - if len(b.mailContent.toAddress) < ValidMinToAddressLength || len(b.mailContent.toAddress) > ValidMaxToAddressLength { - return MailContent{}, &MailerError{ - Message: fmt.Sprintf("toAddress must be between %d and %d characters", ValidMinToAddressLength, ValidMaxToAddressLength), - } + if !c.mimeType.IsValid() { + return MailContent{}, &MailerError{Message: fmt.Sprintf("mimeType must be one of the following: %s", ValidMimeType)} } - if !slices.Contains(strings.Split(ValidMimeType, "|"), b.mailContent.mimeType.String()) { - return MailContent{}, &MailerError{ - Message: fmt.Sprintf("mimeType must be one of the following: %s", ValidMimeType), - } + if err := validateLength("subject", c.subject, ValidMinSubjectLength, ValidMaxSubjectLength); err != nil { + return MailContent{}, err + } + if err := validateHeaderSafe("subject", c.subject); err != nil { + return MailContent{}, err } - if len(b.mailContent.subject) < ValidMinSubjectLength || len(b.mailContent.subject) > ValidMaxSubjectLength { - return MailContent{}, &MailerError{ - Message: fmt.Sprintf("subject must be between %d and %d characters", ValidMinSubjectLength, ValidMaxSubjectLength), - } + if err := validateLength("body", c.body, ValidMinBodyLength, ValidMaxBodyLength); err != nil { + return MailContent{}, err } - if len(b.mailContent.body) < ValidMinBodyLength || len(b.mailContent.body) > ValidMaxBodyLength { - return MailContent{}, &MailerError{ - Message: fmt.Sprintf("body must be between %d and %d characters", ValidMinBodyLength, ValidMaxBodyLength), - } + return c, nil +} + +func validateLength(field, value string, minLen, maxLen int) error { + if len(value) < minLen || len(value) > maxLen { + return &MailerError{Message: fmt.Sprintf("%s must be between %d and %d characters", field, minLen, maxLen)} } + return nil +} - return b.mailContent, nil +// validateHeaderSafe rejects CR/LF (and NUL) in fields that become email +// headers, preventing SMTP header/command injection. +func validateHeaderSafe(field, value string) error { + if strings.ContainsAny(value, "\r\n\x00") { + return &MailerError{Message: fmt.Sprintf("%s must not contain line breaks or null bytes", field)} + } + return nil } diff --git a/mailer_test.go b/mailer_test.go index ecfbfce..16ce943 100644 --- a/mailer_test.go +++ b/mailer_test.go @@ -1,59 +1,75 @@ package mailer import ( + "errors" "fmt" "strings" "testing" ) func TestMailContentBuilder_Build_Valid(t *testing.T) { - builder := NewMailContentBuilder() // Use constructor - content, err := builder. + content, err := NewMailContentBuilder(). WithFromName("John Doe"). - WithFromAddress("john.doe@example.com"). // Added FromAddress + WithFromAddress("john.doe@example.com"). WithToName("Jane Doe"). WithToAddress("jane.doe@example.com"). - WithMimeType(MimeTypeTextPlain). // Use MimeType constant + WithMimeType(MimeTypeTextPlain). WithSubject("Test Subject"). WithBody("Test Body"). Build() if err != nil { - t.Errorf("Expected no error, but got: %v", err) + t.Fatalf("Expected no error, but got: %v", err) } - // Basic checks to ensure fields are set (more detailed checks could be added) - if content.fromName != "John Doe" { - t.Errorf("Expected fromName 'John Doe', got '%s'", content.fromName) + // Exercise the exported accessors that external transports rely on. + if got := content.FromName(); got != "John Doe" { + t.Errorf("FromName() = %q, want %q", got, "John Doe") } - if content.fromAddress != "john.doe@example.com" { - t.Errorf("Expected fromAddress 'john.doe@example.com', got '%s'", content.fromAddress) + if got := content.FromAddress(); got != "john.doe@example.com" { + t.Errorf("FromAddress() = %q, want %q", got, "john.doe@example.com") } - if content.toName != "Jane Doe" { - t.Errorf("Expected toName 'Jane Doe', got '%s'", content.toName) + if got := content.ToName(); got != "Jane Doe" { + t.Errorf("ToName() = %q, want %q", got, "Jane Doe") } - if content.toAddress != "jane.doe@example.com" { - t.Errorf("Expected toAddress 'jane.doe@example.com', got '%s'", content.toAddress) + if got := content.ToAddress(); got != "jane.doe@example.com" { + t.Errorf("ToAddress() = %q, want %q", got, "jane.doe@example.com") } - if content.mimeType != MimeTypeTextPlain { - t.Errorf("Expected mimeType '%s', got '%s'", MimeTypeTextPlain.String(), content.mimeType.String()) + if got := content.MimeType(); got != MimeTypeTextPlain { + t.Errorf("MimeType() = %q, want %q", got, MimeTypeTextPlain) } - if content.subject != "Test Subject" { - t.Errorf("Expected subject 'Test Subject', got '%s'", content.subject) + if got := content.Subject(); got != "Test Subject" { + t.Errorf("Subject() = %q, want %q", got, "Test Subject") } - if content.body != "Test Body" { - t.Errorf("Expected body 'Test Body', got '%s'", content.body) + if got := content.Body(); got != "Test Body" { + t.Errorf("Body() = %q, want %q", got, "Test Body") + } +} + +func TestMailContentBuilder_DefaultsToTextPlain(t *testing.T) { + content, err := NewMailContentBuilder(). + WithFromName("John Doe"). + WithFromAddress("john.doe@example.com"). + WithToName("Jane Doe"). + WithToAddress("jane.doe@example.com"). + WithSubject("Test Subject"). + WithBody("Test Body"). + Build() + if err != nil { + t.Fatalf("Expected no error, but got: %v", err) + } + if content.MimeType() != MimeTypeTextPlain { + t.Errorf("expected default MIME type text/plain, got %q", content.MimeType()) } } func TestMailContentBuilder_Build_Invalid(t *testing.T) { validBuilder := func() *MailContentBuilder { - b := NewMailContentBuilder() // Use constructor - return b. + return NewMailContentBuilder(). WithFromName("John Doe"). WithFromAddress("john.doe@example.com"). WithToName("Jane Doe"). WithToAddress("jane.doe@example.com"). - WithMimeType(MimeTypeTextPlain). // Use MimeType constant + WithMimeType(MimeTypeTextPlain). WithSubject("Test Subject"). WithBody("Test Body") } @@ -73,40 +89,35 @@ func TestMailContentBuilder_Build_Invalid(t *testing.T) { modifier: func(b *MailContentBuilder) { b.WithFromName(strings.Repeat("a", ValidMaxFromNameLength+1)) }, expectedError: fmt.Sprintf("fromName must be between %d and %d characters", ValidMinFromNameLength, ValidMaxFromNameLength), }, - // Note: fromAddress validation was missing in the original Build method, added it to the builder logic. - // Let's assume we add fromAddress validation similar to toAddress for the test. - // If fromAddress validation is not intended, these tests would fail or need removal. - // { - // name: "FromAddress too short", - // modifier: func(b *MailContentBuilder) { b.WithFromAddress("a@b") }, - // expectedError: fmt.Sprintf("fromAddress must be between %d and %d characters", ValidMinFromAddressLength, ValidMaxFromAddressLength), - // }, - // { - // name: "FromAddress too long", - // modifier: func(b *MailContentBuilder) { b.WithFromAddress(strings.Repeat("a", ValidMaxFromAddressLength+1) + "@example.com") }, - // expectedError: fmt.Sprintf("fromAddress must be between %d and %d characters", ValidMinFromAddressLength, ValidMaxFromAddressLength), - // }, { - name: "ToName too short", - modifier: func(b *MailContentBuilder) { b.WithToName("") }, - expectedError: fmt.Sprintf("toName must be between %d and %d characters", ValidMinToNameLength, ValidMaxToNameLength), + name: "FromName with newline", + modifier: func(b *MailContentBuilder) { b.WithFromName("Evil\r\nBcc: victim@example.com") }, + expectedError: "fromName must not contain line breaks or null bytes", }, { - name: "ToName too long", - modifier: func(b *MailContentBuilder) { b.WithToName(strings.Repeat("a", ValidMaxToNameLength+1)) }, + name: "FromAddress too short", + modifier: func(b *MailContentBuilder) { b.WithFromAddress("a") }, + expectedError: fmt.Sprintf("fromAddress must be between %d and %d characters", ValidMinFromAddressLength, ValidMaxFromAddressLength), + }, + { + name: "FromAddress invalid", + modifier: func(b *MailContentBuilder) { b.WithFromAddress("not-an-email") }, + expectedError: "fromAddress must be a valid email address", + }, + { + name: "ToName too short", + modifier: func(b *MailContentBuilder) { b.WithToName("") }, expectedError: fmt.Sprintf("toName must be between %d and %d characters", ValidMinToNameLength, ValidMaxToNameLength), }, { - name: "ToAddress too short", - modifier: func(b *MailContentBuilder) { b.WithToAddress("a@b") }, - expectedError: fmt.Sprintf("toAddress must be between %d and %d characters", ValidMinToAddressLength, ValidMaxToAddressLength), + name: "ToName with newline", + modifier: func(b *MailContentBuilder) { b.WithToName("Bob\nSubject: hijacked") }, + expectedError: "toName must not contain line breaks or null bytes", }, { - name: "ToAddress too long", - modifier: func(b *MailContentBuilder) { - b.WithToAddress(strings.Repeat("a", ValidMaxToAddressLength-10) + "@example.com") - }, // Adjusted to fit within typical email length limits but exceed validation - expectedError: fmt.Sprintf("toAddress must be between %d and %d characters", ValidMinToAddressLength, ValidMaxToAddressLength), + name: "ToAddress invalid", + modifier: func(b *MailContentBuilder) { b.WithToAddress("nope") }, + expectedError: "toAddress must be a valid email address", }, { name: "Invalid MimeType", @@ -123,6 +134,11 @@ func TestMailContentBuilder_Build_Invalid(t *testing.T) { modifier: func(b *MailContentBuilder) { b.WithSubject(strings.Repeat("a", ValidMaxSubjectLength+1)) }, expectedError: fmt.Sprintf("subject must be between %d and %d characters", ValidMinSubjectLength, ValidMaxSubjectLength), }, + { + name: "Subject with newline", + modifier: func(b *MailContentBuilder) { b.WithSubject("Hi\r\nInjected: yes") }, + expectedError: "subject must not contain line breaks or null bytes", + }, { name: "Body too short", modifier: func(b *MailContentBuilder) { b.WithBody("") }, @@ -142,55 +158,47 @@ func TestMailContentBuilder_Build_Invalid(t *testing.T) { _, err := builder.Build() if err == nil { - t.Fatalf("Expected error '%s', but got nil", tc.expectedError) + t.Fatalf("Expected error %q, but got nil", tc.expectedError) } - - mailerErr, ok := err.(*MailerError) - if !ok { - t.Fatalf("Expected error type *MailerError, but got %T", err) + var mailerErr *MailerError + if !errors.As(err, &mailerErr) { + t.Fatalf("Expected *MailerError, but got %T", err) } - if mailerErr.Message != tc.expectedError { - t.Errorf("Expected error message '%s', but got '%s'", tc.expectedError, mailerErr.Message) + t.Errorf("Expected error message %q, but got %q", tc.expectedError, mailerErr.Message) } }) } } func TestMailerError_Error(t *testing.T) { - errMsg := "This is a test error" - err := &MailerError{Message: errMsg} - if err.Error() != errMsg { - t.Errorf("Expected error message '%s', but got '%s'", errMsg, err.Error()) + err := &MailerError{Message: "boom"} + if err.Error() != "boom" { + t.Errorf("Error() = %q, want %q", err.Error(), "boom") } -} -// Helper function to check if fromAddress validation should be added -func TestFromAddressValidationMissing(t *testing.T) { - // This test checks if the fromAddress validation is indeed missing as observed. - // If this test fails, it means the validation was added to MailContentBuilder.Build() - // and the corresponding tests in TestMailContentBuilder_Build_Invalid should be uncommented. - builder := NewMailContentBuilder() // Use constructor - _, err := builder. - WithFromName("John Doe"). - WithFromAddress("a@b"). // Invalid length - WithToName("Jane Doe"). - WithToAddress("jane.doe@example.com"). - WithMimeType(MimeTypeTextPlain). // Use MimeType constant - WithSubject("Test Subject"). - WithBody("Test Body"). - Build() + wrapped := &MailerError{Message: "outer", Err: errors.New("inner")} + if wrapped.Error() != "outer: inner" { + t.Errorf("Error() = %q, want %q", wrapped.Error(), "outer: inner") + } + if !errors.Is(wrapped, wrapped.Err) { + t.Error("expected errors.Is to unwrap to the inner error") + } +} - if err != nil { - // Check if the error is specifically about fromAddress length - expectedErrorSubstr := "fromAddress must be between" - if strings.Contains(err.Error(), expectedErrorSubstr) { - t.Logf("Detected fromAddress validation. Consider uncommenting fromAddress tests in TestMailContentBuilder_Build_Invalid.") - } else { - // If error is not nil, but not about fromAddress, report it - t.Errorf("Expected no error related to fromAddress validation, but got: %v", err) +func TestMimeType_IsValid(t *testing.T) { + cases := map[MimeType]bool{ + MimeTypeTextPlain: true, + MimeTypeTextHTML: true, + MimeType("text/xml"): false, + MimeType(""): false, + } + for mt, want := range cases { + if got := mt.IsValid(); got != want { + t.Errorf("MimeType(%q).IsValid() = %v, want %v", mt, got, want) } - } else { - t.Log("Confirmed: fromAddress validation is currently missing in Build method.") + } + if MimeTypeTextHTML.String() != "text/html" { + t.Errorf("String() = %q, want %q", MimeTypeTextHTML.String(), "text/html") } } diff --git a/service.go b/service.go index a3093e8..22effbf 100644 --- a/service.go +++ b/service.go @@ -6,71 +6,98 @@ import ( "fmt" "log/slog" "sync" + "sync/atomic" "time" ) +// ErrServiceStopped is returned by Enqueue when the service has been stopped. +var ErrServiceStopped = errors.New("mailer service is stopped") + const ( + // ValidMaxWorkerCount is the maximum number of concurrent workers allowed. ValidMaxWorkerCount = 100 + // ValidMinWorkerCount is the minimum number of concurrent workers allowed. ValidMinWorkerCount = 1 ) +// MailQueueError is a configuration/validation error for the queue service. type MailQueueError struct { Message string } -func (e *MailQueueError) Error() string { - return e.Message -} +func (e *MailQueueError) Error() string { return e.Message } +// MailQueueService is the public contract implemented by MailService. It lets +// callers depend on the queueing behaviour without binding to the concrete type. type MailQueueService interface { Enqueue(content MailContent) error } +// MailServiceConfig configures a MailService. type MailServiceConfig struct { + // Ctx is the base context that governs the lifetime of the workers. + // When it is cancelled every worker stops as soon as it observes the + // cancellation. If nil, context.Background() is used. Ctx context.Context - // WorkerCount is the number of concurrent workers to process the mail queue. - // It must be between 1 and 100. + // WorkerCount is the number of concurrent workers that process the queue. + // It must be between ValidMinWorkerCount and ValidMaxWorkerCount. WorkerCount int - // Timeout is the duration for which the service will wait for a worker to finish processing before timing out. - // This is currently not used in the core service logic but can be implemented in the future. + // QueueSize is the capacity of the internal buffered queue. When zero it + // defaults to WorkerCount. A larger queue absorbs bursts before Enqueue + // starts to block (back-pressure). + QueueSize int + + // Timeout, when greater than zero, bounds every individual Send call with a + // per-message deadline derived from the worker context. Timeout time.Duration - // Mailer is the service responsible for sending emails. - // It must not be nil. + // Mailer is the transport responsible for delivering emails. Must not be nil. Mailer MailerService } +// MailService is a concurrent, queue-backed email dispatcher. Callers Enqueue +// validated MailContent and a pool of workers delivers it through the configured +// MailerService. It is safe for concurrent use by multiple goroutines. type MailService struct { ctx context.Context workerCount int + timeout time.Duration content chan MailContent mailer MailerService wg sync.WaitGroup - mu sync.RWMutex // Mutex to protect access to ctx + + // mu guards the stopped flag and, critically, serialises the close of the + // content channel against in-flight sends. Enqueue takes the read lock for + // the duration of a send so that Stop (which takes the write lock) can never + // close the channel while a send is in progress. This prevents both the + // "send on closed channel" panic and the previous dead-lock where a blocked + // Enqueue held an exclusive lock that Start/Stop needed. + mu sync.RWMutex + stopped bool + started atomic.Bool + stopOnce sync.Once } +// NewMailService validates the configuration and returns a ready-to-start service. func NewMailService(conf *MailServiceConfig) (*MailService, error) { if conf == nil { - return nil, &MailQueueError{ - Message: "MailServiceConfig cannot be nil", - } + return nil, &MailQueueError{Message: "MailServiceConfig cannot be nil"} } if conf.Mailer == nil { - return nil, &MailQueueError{ - Message: "Mailer cannot be nil", - } + return nil, &MailQueueError{Message: "Mailer cannot be nil"} } if conf.WorkerCount < ValidMinWorkerCount || conf.WorkerCount > ValidMaxWorkerCount { - return nil, &MailQueueError{ - Message: fmt.Sprintf("WorkerCount must be between %d and %d", ValidMinWorkerCount, ValidMaxWorkerCount), - } + return nil, &MailQueueError{Message: fmt.Sprintf("WorkerCount must be between %d and %d", ValidMinWorkerCount, ValidMaxWorkerCount)} } - contentChan := make(chan MailContent, conf.WorkerCount) + queueSize := conf.QueueSize + if queueSize <= 0 { + queueSize = conf.WorkerCount + } internalCtx := conf.Ctx if internalCtx == nil { @@ -80,88 +107,103 @@ func NewMailService(conf *MailServiceConfig) (*MailService, error) { return &MailService{ ctx: internalCtx, workerCount: conf.WorkerCount, - content: contentChan, + timeout: conf.Timeout, + content: make(chan MailContent, queueSize), mailer: conf.Mailer, - wg: sync.WaitGroup{}, }, nil } -// Start initializes the worker goroutines that will process the mail queue. -// Each worker will listen on the content channel for new mail content to process. -// The workers will run concurrently, and the number of workers is determined by the WorkerCount field. -// The context provided in the configuration is used to manage the lifecycle of the workers. -// If the context is cancelled, all workers will stop processing and exit gracefully. -// The workers will log their status and any errors encountered during the email sending process. +// Start launches the worker goroutines. It is idempotent: calling it more than +// once has no additional effect. func (ref *MailService) Start() { + if !ref.started.CompareAndSwap(false, true) { + return + } + for i := 1; i <= ref.workerCount; i++ { ref.wg.Add(1) + go ref.worker(i) + } +} - go func(workerNum int) { - defer ref.wg.Done() - - ref.mu.RLock() - workerCtx := ref.ctx - ref.mu.RUnlock() - - slog.Debug("Worker started", "worker", workerNum) - for { - select { - case <-workerCtx.Done(): - slog.Warn("Context done, worker stopping", "worker", workerNum) - return - case content, ok := <-ref.content: - if !ok { - slog.Warn("Content channel closed, worker stopping", "worker", workerNum) - return - } - - slog.Debug("Sending email", "worker", workerNum) - err := ref.mailer.Send(workerCtx, content) - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - slog.Warn("Email sending cancelled by context", "worker", workerNum, "error", err) - } else { - slog.Error("Error sending email", "worker", workerNum, "error", err) - } - } - } +func (ref *MailService) worker(workerNum int) { + defer ref.wg.Done() + slog.Debug("worker started", "worker", workerNum) + + for { + select { + case <-ref.ctx.Done(): + slog.Debug("context done, worker stopping", "worker", workerNum) + return + case content, ok := <-ref.content: + if !ok { + slog.Debug("queue drained, worker stopping", "worker", workerNum) + return } - }(i) + ref.deliver(workerNum, content) + } } } -// Stop closes the content channel and waits for all workers to finish processing. -func (ref *MailService) Stop() { - slog.Warn("Stopping mail service, closing content channel...") +func (ref *MailService) deliver(workerNum int, content MailContent) { + sendCtx := ref.ctx + if ref.timeout > 0 { + var cancel context.CancelFunc + sendCtx, cancel = context.WithTimeout(ref.ctx, ref.timeout) + defer cancel() + } - close(ref.content) + slog.Debug("sending email", "worker", workerNum) + if err := ref.mailer.Send(sendCtx, content); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + slog.Warn("email sending cancelled by context", "worker", workerNum, "error", err) + } else { + slog.Error("error sending email", "worker", workerNum, "error", err) + } + } +} - slog.Warn("Waiting for workers to finish...") - ref.wg.Wait() - slog.Warn("All workers finished.") +// Stop stops accepting new work, closes the queue, and waits for the workers to +// drain any already-queued messages. It is safe to call multiple times and from +// multiple goroutines. +func (ref *MailService) Stop() { + ref.stopOnce.Do(func() { + ref.mu.Lock() + ref.stopped = true + close(ref.content) + ref.mu.Unlock() + + slog.Debug("mailer service stopping, waiting for workers to drain the queue") + ref.wg.Wait() + slog.Debug("mailer service stopped, all workers finished") + }) } -// Wait blocks until all worker goroutines have exited. -// Useful if you cancel the context and want to ensure workers stopped -// without necessarily closing the channel via Stop(). +// Wait blocks until every worker goroutine has exited. Unlike Stop it does not +// close the queue; it is useful when workers are expected to exit because the +// context was cancelled. func (ref *MailService) Wait() { ref.wg.Wait() } -// Enqueue adds mail content to the queue. It returns an error if the service's context is cancelled during the attempt. +// Enqueue adds a message to the queue. It blocks while the queue is full +// (back-pressure) and returns an error if the service has been stopped or the +// context is cancelled before the message can be queued. func (ref *MailService) Enqueue(content MailContent) error { ref.mu.RLock() - ctxDone := ref.ctx.Done() - ctxErr := ref.ctx.Err() - ref.mu.RUnlock() + defer ref.mu.RUnlock() + + if ref.stopped { + slog.Debug("failed to enqueue email, service stopped") + return ErrServiceStopped + } select { + case <-ref.ctx.Done(): + slog.Debug("failed to enqueue email, context cancelled", "error", ref.ctx.Err()) + return ref.ctx.Err() case ref.content <- content: - slog.Debug("Email enqueued successfully") - + slog.Debug("email enqueued successfully") return nil - case <-ctxDone: - slog.Warn("Failed to enqueue email, context cancelled", "error", ctxErr) - return ctxErr } } diff --git a/service_test.go b/service_test.go index fbca71f..53832eb 100644 --- a/service_test.go +++ b/service_test.go @@ -5,105 +5,96 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "testing" "time" ) -// Helper to create valid MailContent using the builder +// createTestMail builds a valid MailContent for tests, panicking on failure +// since a failure here means the test setup itself is broken. func createTestMail(subject string) MailContent { - // Use the builder and provide valid minimal values for required fields - builder := MailContentBuilder{} - // Note: Assuming fromAddress validation requires a basic format. Adjust if needed. - content, err := builder. + content, err := NewMailContentBuilder(). WithFromName("Test Sender"). WithFromAddress("sender@example.com"). WithToName("Test Recipient"). WithToAddress("recipient@example.com"). - WithMimeType("text/plain"). // Use a valid MIME type from constants + WithMimeType(MimeTypeTextPlain). WithSubject(subject). - WithBody("Test body content that is long enough."). // Ensure body meets min length + WithBody("Test body content that is long enough."). Build() if err != nil { - // In a test helper, panicking is often acceptable if creation MUST succeed - // for the test setup. - panic(fmt.Sprintf("createTestMail failed for subject '%s': %v", subject, err)) + panic(fmt.Sprintf("createTestMail failed for subject %q: %v", subject, err)) } return content } -// MockMailerService is a mock implementation of MailerService for testing. +// MockMailerService is a configurable MailerService test double. type MockMailerService struct { mu sync.Mutex - SentMail []MailContent + sent []MailContent SendFunc func(ctx context.Context, content MailContent) error - SendErr error // Predefined error to return from Send + SendErr error } -// Send records the mail content and returns a predefined error or the result of SendFunc. func (m *MockMailerService) Send(ctx context.Context, content MailContent) error { - m.mu.Lock() - defer m.mu.Unlock() - - // Simulate some work - time.Sleep(5 * time.Millisecond) - - m.SentMail = append(m.SentMail, content) - if m.SendFunc != nil { return m.SendFunc(ctx, content) } - return m.SendErr -} - -// Reset clears the recorded sent mail. -func (m *MockMailerService) Reset() { m.mu.Lock() - defer m.mu.Unlock() - m.SentMail = nil - m.SendErr = nil - m.SendFunc = nil + m.sent = append(m.sent, content) + m.mu.Unlock() + return m.SendErr } -// Count returns the number of emails "sent". func (m *MockMailerService) Count() int { m.mu.Lock() defer m.mu.Unlock() - return len(m.SentMail) + return len(m.sent) } -// --- Test Cases --- +func TestMailQueueError_Error(t *testing.T) { + err := &MailQueueError{Message: "bad config"} + if err.Error() != "bad config" { + t.Errorf("Error() = %q, want %q", err.Error(), "bad config") + } +} func TestNewMailService(t *testing.T) { mockMailer := &MockMailerService{} - ctx := context.Background() tests := []struct { name string config *MailServiceConfig expectError bool - checkFunc func(*testing.T, *MailService) // Optional check on the created service + checkFunc func(*testing.T, *MailService) }{ { name: "Valid config", config: &MailServiceConfig{ - Ctx: ctx, + Ctx: context.Background(), WorkerCount: 5, Timeout: 10 * time.Second, Mailer: mockMailer, }, - expectError: false, checkFunc: func(t *testing.T, s *MailService) { - if s == nil { - t.Fatal("Expected non-nil MailService") - } if s.workerCount != 5 { - t.Errorf("Expected workerCount 5, got %d", s.workerCount) + t.Errorf("workerCount = %d, want 5", s.workerCount) } if cap(s.content) != 5 { - t.Errorf("Expected channel capacity 5, got %d", cap(s.content)) + t.Errorf("queue capacity = %d, want 5 (defaults to WorkerCount)", cap(s.content)) } - if s.mailer != mockMailer { - t.Error("Mailer not set correctly") + }, + }, + { + name: "Custom queue size", + config: &MailServiceConfig{ + WorkerCount: 2, + QueueSize: 64, + Mailer: mockMailer, + }, + checkFunc: func(t *testing.T, s *MailService) { + if cap(s.content) != 64 { + t.Errorf("queue capacity = %d, want 64", cap(s.content)) } }, }, @@ -113,30 +104,18 @@ func TestNewMailService(t *testing.T) { expectError: true, }, { - name: "Nil mailer", - config: &MailServiceConfig{ - Ctx: ctx, - WorkerCount: 1, - Mailer: nil, - }, + name: "Nil mailer", + config: &MailServiceConfig{WorkerCount: 1}, expectError: true, }, { - name: "Worker count too low", - config: &MailServiceConfig{ - Ctx: ctx, - WorkerCount: 0, - Mailer: mockMailer, - }, + name: "Worker count too low", + config: &MailServiceConfig{WorkerCount: 0, Mailer: mockMailer}, expectError: true, }, { - name: "Worker count too high", - config: &MailServiceConfig{ - Ctx: ctx, - WorkerCount: ValidMaxWorkerCount + 1, - Mailer: mockMailer, - }, + name: "Worker count too high", + config: &MailServiceConfig{WorkerCount: ValidMaxWorkerCount + 1, Mailer: mockMailer}, expectError: true, }, } @@ -146,19 +125,19 @@ func TestNewMailService(t *testing.T) { service, err := NewMailService(tt.config) if tt.expectError { if err == nil { - t.Errorf("Expected an error, but got nil") + t.Fatal("expected an error, got nil") } - // Check if it's the specific error type if needed - if _, ok := err.(*MailQueueError); !ok && err != nil { - t.Errorf("Expected error of type *MailQueueError, got %T", err) - } - } else { - if err != nil { - t.Errorf("Did not expect an error, but got: %v", err) - } - if tt.checkFunc != nil { - tt.checkFunc(t, service) + var qErr *MailQueueError + if !errors.As(err, &qErr) { + t.Errorf("expected *MailQueueError, got %T", err) } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.checkFunc != nil { + tt.checkFunc(t, service) } }) } @@ -166,261 +145,227 @@ func TestNewMailService(t *testing.T) { func TestMailService_StartStopEnqueue(t *testing.T) { mockMailer := &MockMailerService{} - config := &MailServiceConfig{ - WorkerCount: 3, - Mailer: mockMailer, - } - service, err := NewMailService(config) + service, err := NewMailService(&MailServiceConfig{WorkerCount: 3, Mailer: mockMailer}) if err != nil { - t.Fatalf("Failed to create MailService: %v", err) + t.Fatalf("failed to create service: %v", err) } service.Start() - // Enqueue some emails - numEmails := 10 + const numEmails = 10 for i := range numEmails { - service.Enqueue(createTestMail(fmt.Sprintf("Test %d", i))) + if err := service.Enqueue(createTestMail(fmt.Sprintf("Test %d", i))); err != nil { + t.Fatalf("Enqueue failed: %v", err) + } } - // Stop the service. This will close the channel and wait (using the internal wg) - // for all workers to finish processing the enqueued items. - service.Stop() // This now blocks until workers are done. + // Stop drains the queue and waits for workers before returning. + service.Stop() - // Final verification - workers should have processed all items before Stop returned. if mockMailer.Count() != numEmails { - t.Errorf("Expected %d emails to be sent, but final count is %d", numEmails, mockMailer.Count()) + t.Errorf("sent %d emails, want %d", mockMailer.Count(), numEmails) } } -func TestMailService_ContextCancellation(t *testing.T) { +func TestMailService_StartIsIdempotent(t *testing.T) { mockMailer := &MockMailerService{} - processedCount := 0 - var countMu sync.Mutex - - // Simplified SendFunc: Simulate work but respect context cancellation. - mockMailer.SendFunc = func(ctx context.Context, content MailContent) error { - // Simulate work that can be interrupted by context cancellation - select { - case <-time.After(25 * time.Millisecond): // Simulate slightly longer work - // Work completed without cancellation - t.Logf("Send completed work for mail content") - case <-ctx.Done(): - // Work interrupted by cancellation - t.Logf("Send cancelled during work for mail content") - return ctx.Err() // Return context error if cancelled - } + service, err := NewMailService(&MailServiceConfig{WorkerCount: 2, Mailer: mockMailer}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + service.Start() + service.Start() // second call must be a no-op - // If work completed, record the send - mockMailer.mu.Lock() - mockMailer.SentMail = append(mockMailer.SentMail, content) - countMu.Lock() - processedCount++ - countMu.Unlock() - mockMailer.mu.Unlock() - t.Logf("Send completed for mail content") // Removed content.Subject - return nil + if err := service.Enqueue(createTestMail("idem")); err != nil { + t.Fatalf("Enqueue failed: %v", err) } + service.Stop() - ctx, cancel := context.WithCancel(context.Background()) + if mockMailer.Count() != 1 { + t.Errorf("sent %d emails, want 1", mockMailer.Count()) + } +} - config := &MailServiceConfig{ - Ctx: ctx, - WorkerCount: 2, // Fewer workers than emails - Mailer: mockMailer, +func TestMailService_StopIsIdempotent(t *testing.T) { + service, err := NewMailService(&MailServiceConfig{WorkerCount: 1, Mailer: &MockMailerService{}}) + if err != nil { + t.Fatalf("failed to create service: %v", err) } - service, err := NewMailService(config) + service.Start() + service.Stop() + service.Stop() // must not panic on a double close +} + +func TestMailService_StopRejectsEnqueue(t *testing.T) { + service, err := NewMailService(&MailServiceConfig{WorkerCount: 1, Mailer: &MockMailerService{}}) if err != nil { - t.Fatalf("Failed to create MailService: %v", err) + t.Fatalf("failed to create service: %v", err) } - // We need a separate context for the enqueue goroutine that we don't cancel immediately - // Or simply let the enqueue goroutine run until it blocks or finishes. - service.Start() // Workers use the cancellable context - - // Run enqueue loop in a separate goroutine. - var enqueueWg sync.WaitGroup - enqueueWg.Add(1) - go func() { - defer enqueueWg.Done() - const numEmails = 5 // Define numEmails inside the goroutine - enqueuedCount := 0 - for i := range numEmails { - // Use the service's context for enqueueing, so it respects cancellation. - err := service.Enqueue(createTestMail(fmt.Sprintf("CtxTest %d", i))) - if err != nil { - // Log expected errors due to cancellation/blocking - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - t.Logf("Enqueue goroutine: Enqueue failed as expected due to context cancellation: %v", err) - } else { - // Log unexpected errors but don't fail the test here, let main check handle final state - t.Logf("Enqueue goroutine: Unexpected error during enqueue %d: %v", i, err) - } - break // Stop trying to enqueue more emails if an error occurs - } - enqueuedCount++ - } - t.Logf("Enqueue goroutine: Finished attempting to enqueue %d emails.", enqueuedCount) - }() + service.Start() + service.Stop() - // Wait a tiny bit for workers to pick up initial tasks AND for enqueue goroutine to potentially block - time.Sleep(20 * time.Millisecond) + err = service.Enqueue(createTestMail("after-stop")) + if !errors.Is(err, ErrServiceStopped) { + t.Fatalf("expected ErrServiceStopped, got %v", err) + } +} - // Cancel the context - t.Log("Cancelling context...") - cancel() - t.Log("Context cancelled.") - - // No blocker channel to close anymore. - - // Wait for workers to exit due to context cancellation using the service's WaitGroup. - // This ensures we check the state *after* workers have reacted to the cancellation. - service.Wait() // Wait for workers to finish without closing the channel. - t.Log("Workers finished after context cancellation.") - - // Check that not all emails were processed - // Note: We can't use numEmails here as it's defined in the goroutine. - // The assertion should focus on whether *some* processing happened but was likely cut short. - // A more robust check might involve knowing exactly how many *should* have completed before cancellation. - // For now, we check if the count is less than the intended total. - const expectedTotalEmails = 5 // Re-declare for assertion clarity - countMu.Lock() - finalSentCount := processedCount // Use the counter updated within SendFunc - countMu.Unlock() - - if finalSentCount == expectedTotalEmails { - t.Errorf("Expected fewer than %d emails sent due to context cancellation, but got %d", expectedTotalEmails, finalSentCount) +func TestMailService_EnqueueContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + // WorkerCount 1, queue size 1, never started, so the queue fills and the + // next Enqueue blocks until the context is cancelled. + service, err := NewMailService(&MailServiceConfig{Ctx: ctx, WorkerCount: 1, Mailer: &MockMailerService{}}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + if err := service.Enqueue(createTestMail("fills-buffer")); err != nil { + t.Fatalf("first Enqueue failed: %v", err) } - if finalSentCount == 0 && expectedTotalEmails > 0 { - t.Logf("Warning: 0 emails sent, cancellation might have been very quick or blocking logic needs review.") - } else if finalSentCount > 0 { - t.Logf("Successfully processed %d emails before/during context cancellation.", finalSentCount) - } else { - t.Logf("Processed count is %d", finalSentCount) + + errCh := make(chan error, 1) + go func() { errCh <- service.Enqueue(createTestMail("blocks")) }() + + // Give the goroutine time to block on the full queue, then cancel. + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Enqueue did not unblock after context cancellation") } } -// Test for enqueue blocking when buffer is full -func TestMailService_EnqueueBlocksWhenFull(t *testing.T) { - mockMailer := &MockMailerService{} - workerCount := 1 // Use a small buffer size (equal to worker count) - config := &MailServiceConfig{ - Ctx: t.Context(), - WorkerCount: workerCount, - Mailer: mockMailer, +func TestMailService_Timeout(t *testing.T) { + var deadlineSeen atomic.Bool + mockMailer := &MockMailerService{ + SendFunc: func(ctx context.Context, _ MailContent) error { + if _, ok := ctx.Deadline(); ok { + deadlineSeen.Store(true) + } + return nil + }, } - service, err := NewMailService(config) + + service, err := NewMailService(&MailServiceConfig{ + WorkerCount: 1, + Timeout: time.Second, + Mailer: mockMailer, + }) if err != nil { - t.Fatalf("Failed to create MailService: %v", err) + t.Fatalf("failed to create service: %v", err) + } + + service.Start() + if err := service.Enqueue(createTestMail("with-timeout")); err != nil { + t.Fatalf("Enqueue failed: %v", err) + } + service.Stop() + + if !deadlineSeen.Load() { + t.Error("expected Send to receive a context with a deadline") } +} - // DO NOT START the service, so workers don't consume from the channel. - // This ensures the channel buffer can be filled. +func TestMailService_ContextCancellationStopsWorkers(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + mockMailer := &MockMailerService{ + SendFunc: func(ctx context.Context, _ MailContent) error { + <-ctx.Done() + return ctx.Err() + }, + } - // Fill the buffer completely - t.Logf("Filling buffer with capacity %d", workerCount) - for i := range workerCount { - mail := createTestMail(fmt.Sprintf("Fill %d", i)) - service.Enqueue(mail) - t.Logf("Enqueued mail %d", i) // Removed mail.Subject + service, err := NewMailService(&MailServiceConfig{Ctx: ctx, WorkerCount: 2, Mailer: mockMailer}) + if err != nil { + t.Fatalf("failed to create service: %v", err) } - t.Logf("Buffer should now be full.") - - // Try to enqueue one more item. This should block because the buffer is full - // and no workers are running to consume items. - enqueueDone := make(chan bool) - t.Logf("Attempting to enqueue one more item (expected to block)...") - go func() { - mail := createTestMail("Blocking") - service.Enqueue(mail) - // Cannot log mail.Subject here as it's unexported - t.Logf("Enqueued blocking mail (THIS SHOULD NOT HAPPEN if blocking works)") - close(enqueueDone) // Signal completion ONLY if Enqueue returns (which it shouldn't) - }() - - // Wait for a short period. If enqueueDone is closed, it means Enqueue didn't block. - // If the timeout expires, it means Enqueue blocked as expected. + service.Start() + + _ = service.Enqueue(createTestMail("blocked")) + cancel() + + done := make(chan struct{}) + go func() { service.Wait(); close(done) }() + select { - case <-enqueueDone: - t.Errorf("Enqueue did not block when the buffer was full and no workers were running") - case <-time.After(100 * time.Millisecond): - // Expected behavior: Enqueue blocked. - t.Log("Enqueue correctly blocked as expected.") + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("workers did not exit after context cancellation") } +} - // Cleanup: Start and stop the service to drain the channel and allow the blocked goroutine to potentially finish. - // This prevents the test leaving a goroutine blocked on the channel send. - t.Log("Cleaning up: Starting service to drain channel...") +func TestMailService_ConcurrentEnqueue(t *testing.T) { + mockMailer := &MockMailerService{} + service, err := NewMailService(&MailServiceConfig{WorkerCount: 8, QueueSize: 128, Mailer: mockMailer}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } service.Start() - // Allow time for the worker to potentially process the items - time.Sleep(50 * time.Millisecond) - t.Log("Cleaning up: Stopping service...") + + const producers, perProducer = 10, 20 + var wg sync.WaitGroup + for p := range producers { + wg.Add(1) + go func(p int) { + defer wg.Done() + for i := range perProducer { + if err := service.Enqueue(createTestMail(fmt.Sprintf("p%d-%d", p, i))); err != nil { + t.Errorf("Enqueue failed: %v", err) + return + } + } + }(p) + } + wg.Wait() service.Stop() - // Allow time for worker shutdown - time.Sleep(50 * time.Millisecond) - t.Log("Cleanup complete.") + + if want := producers * perProducer; mockMailer.Count() != want { + t.Errorf("sent %d emails, want %d", mockMailer.Count(), want) + } } -// ExampleMailService_Enqueue demonstrates how to create a MailService, -// enqueue an email, and have it processed by a mock mailer. +// ExampleMailService_Enqueue demonstrates creating a service, enqueuing an +// email, and having it processed by a mock mailer. func ExampleMailService_Enqueue() { - // Use a mock mailer for demonstration purposes mockMailer := &MockMailerService{} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() // Ensure context is cancelled eventually - - // Configure the mail service - config := &MailServiceConfig{ - Ctx: ctx, - WorkerCount: 1, // One worker is sufficient for the example - Mailer: mockMailer, - } - service, err := NewMailService(config) + service, err := NewMailService(&MailServiceConfig{WorkerCount: 1, Mailer: mockMailer}) if err != nil { - fmt.Printf("Error creating service: %v\n", err) + fmt.Printf("error creating service: %v\n", err) return } service.Start() - // Create email content using the builder - content, err := (&MailContentBuilder{}). + content, err := NewMailContentBuilder(). WithFromName("Example Sender"). WithFromAddress("sender@example.net"). WithToName("Example Recipient"). WithToAddress("recipient@example.net"). - WithMimeType("text/plain"). + WithMimeType(MimeTypeTextPlain). WithSubject("Example Subject"). WithBody("This is the example email body."). Build() if err != nil { - fmt.Printf("Error building content: %v\n", err) + fmt.Printf("error building content: %v\n", err) return } - // Enqueue the email - err = service.Enqueue(content) - if err != nil { - fmt.Printf("Error enqueuing email: %v\n", err) - // Don't return here, allow Stop to run for cleanup + if err := service.Enqueue(content); err != nil { + fmt.Printf("error enqueuing email: %v\n", err) } - // Stop the service gracefully. This ensures the enqueued item is processed - // by the worker before the example finishes. + // Stop drains the queue, guaranteeing the message is processed first. service.Stop() - // Check if the mock mailer received the email - if mockMailer.Count() > 0 { - // Accessing unexported fields like subject directly isn't possible here. - // We'll print a confirmation message based on the count. - // In a real test, you might check mockMailer.SentMail[0].subject if it were exported - // or add a method to MockMailerService to get sent subjects. - fmt.Printf("Mock mailer received %d email(s).\n", mockMailer.Count()) - } else { - fmt.Println("Mock mailer did not receive the email.") - } - + fmt.Printf("Mock mailer received %d email(s).\n", mockMailer.Count()) // Output: // Mock mailer received 1 email(s). } diff --git a/smtp.go b/smtp.go index a3d2623..df2c6db 100644 --- a/smtp.go +++ b/smtp.go @@ -2,111 +2,240 @@ package mailer import ( "context" + "crypto/tls" "fmt" + "net" "net/smtp" - "slices" "strings" + "time" ) +// SMTP validation bounds and defaults. const ( - ValidMinSMTPHostLength = 5 - ValidMaxSMTPHostLength = 100 - ValidSMTPPorts = "25|465|587|1025|8025" - ValidMinUsernameLength = 1 - ValidMaxUsernameLength = 100 - ValidMinPasswordLength = 3 - ValidMaxPasswordLength = 100 + ValidMinSMTPHostLength = 1 + ValidMaxSMTPHostLength = 255 + ValidMinSMTPPort = 1 + ValidMaxSMTPPort = 65535 + ValidMinUsernameLength = 0 + ValidMaxUsernameLength = 255 + ValidMinPasswordLength = 0 + ValidMaxPasswordLength = 255 + + // DefaultSMTPDialTimeout is used when MailerSMTPConf.DialTimeout is zero. + DefaultSMTPDialTimeout = 10 * time.Second + // DefaultSMTPLocalName is the HELO/EHLO name used when none is configured. + DefaultSMTPLocalName = "localhost" + // implicitTLSPort is the well-known port that expects TLS from the first byte. + implicitTLSPort = 465 ) +// MailerSMTPConf configures the SMTP transport. type MailerSMTPConf struct { - // SMTPHost is the hostname of the SMTP server (5-100 chars). *Required*. - // It must be a valid hostname or IP address. + // SMTPHost is the mail server host name. It is also used as the TLS server + // name for certificate verification. SMTPHost string - - // SMTPPort is the port of the SMTP server (must be 25, 465, or 587). *Required*. - // Valid ports are 25 (SMTP), 465 (SMTPS), 587 (Submission), 1025, and 8025. - // Ports 1025 and 8025 are less common and may be used in specific configurations, such as testing or non-standard setups. + // SMTPPort is the mail server port (1-65535). Port 465 uses implicit TLS; + // other ports negotiate STARTTLS opportunistically (see RequireTLS). SMTPPort int - - // Username is the SMTP username for authentication (1-100 chars). *Required*. + // ImplicitTLS forces a TLS handshake immediately after connecting, before any + // SMTP command (SMTPS). It is implied for port 465. + ImplicitTLS bool + // Username and Password are the credentials for PLAIN authentication. Leave + // both empty to send without authentication. Username string - - // Password is the SMTP password for authentication (3-100 chars). *Required*. - // It should be kept secret and not logged or exposed. Password string + + // DialTimeout bounds the TCP connection setup. Defaults to DefaultSMTPDialTimeout. + DialTimeout time.Duration + // LocalName is the name announced in the EHLO/HELO command. Defaults to + // DefaultSMTPLocalName. + LocalName string + // RequireTLS makes delivery fail if the connection cannot be secured with + // TLS (implicit TLS or STARTTLS). Recommended for production. + RequireTLS bool + // TLSConfig is an optional custom TLS configuration. When nil a default + // config verifying the server against SMTPHost is used. + TLSConfig *tls.Config } +// MailerSMTP is an SMTP implementation of MailerService. type MailerSMTP struct { - smtpHost string - smtpPort int - username string - password string + smtpHost string + smtpPort int + username string + password string + dialTimeout time.Duration + localName string + requireTLS bool + implicitTLS bool + tlsConfig *tls.Config } +// NewMailerSMTP validates the configuration and returns an SMTP transport. func NewMailerSMTP(conf MailerSMTPConf) (*MailerSMTP, error) { - if len(conf.SMTPHost) < ValidMinSMTPHostLength || len(conf.SMTPHost) > ValidMaxSMTPHostLength { - return nil, &MailerError{ - Message: fmt.Sprintf("SMTPHost must be between %d and %d characters", ValidMinSMTPHostLength, ValidMaxSMTPHostLength), - } + if l := len(conf.SMTPHost); l < ValidMinSMTPHostLength || l > ValidMaxSMTPHostLength { + return nil, &MailerError{Message: fmt.Sprintf("SMTPHost must be between %d and %d characters", ValidMinSMTPHostLength, ValidMaxSMTPHostLength)} } - if !slices.Contains(strings.Split(ValidSMTPPorts, "|"), fmt.Sprint(conf.SMTPPort)) { - return nil, &MailerError{ - Message: fmt.Sprintf("SMTPPort must be one of the following: %s", ValidSMTPPorts), - } + if conf.SMTPPort < ValidMinSMTPPort || conf.SMTPPort > ValidMaxSMTPPort { + return nil, &MailerError{Message: fmt.Sprintf("SMTPPort must be between %d and %d", ValidMinSMTPPort, ValidMaxSMTPPort)} } - if len(conf.Username) < ValidMinUsernameLength || len(conf.Username) > ValidMaxUsernameLength { - return nil, &MailerError{ - Message: fmt.Sprintf("Username must be between %d and %d characters", ValidMinUsernameLength, ValidMaxUsernameLength), - } + if l := len(conf.Username); l > ValidMaxUsernameLength { + return nil, &MailerError{Message: fmt.Sprintf("Username must be at most %d characters", ValidMaxUsernameLength)} } - if len(conf.Password) < ValidMinPasswordLength || len(conf.Password) > ValidMaxPasswordLength { - return nil, &MailerError{ - Message: fmt.Sprintf("Password must be between %d and %d characters", ValidMinPasswordLength, ValidMaxPasswordLength), - } + if l := len(conf.Password); l > ValidMaxPasswordLength { + return nil, &MailerError{Message: fmt.Sprintf("Password must be at most %d characters", ValidMaxPasswordLength)} + } + + dialTimeout := conf.DialTimeout + if dialTimeout <= 0 { + dialTimeout = DefaultSMTPDialTimeout + } + + localName := conf.LocalName + if localName == "" { + localName = DefaultSMTPLocalName } return &MailerSMTP{ - smtpHost: conf.SMTPHost, - smtpPort: conf.SMTPPort, - username: conf.Username, - password: conf.Password, + smtpHost: conf.SMTPHost, + smtpPort: conf.SMTPPort, + username: conf.Username, + password: conf.Password, + dialTimeout: dialTimeout, + localName: localName, + requireTLS: conf.RequireTLS, + implicitTLS: conf.ImplicitTLS || conf.SMTPPort == implicitTLSPort, + tlsConfig: conf.TLSConfig, }, nil } +// Send delivers a single message. It honours the context for connection setup +// and returns a *MailerError wrapping the underlying failure. func (m *MailerSMTP) Send(ctx context.Context, content MailContent) error { - msgTpl := `From: %s <%s> -To: %s <%s> -Subject: %s -MIME-version: 1.0; -Content-Type: %s; -%s - -` - msg := fmt.Sprintf( - msgTpl, - content.fromName, - content.fromAddress, - content.toName, - content.toAddress, - content.subject, - content.mimeType, - content.body, - ) - - if err := smtp.SendMail( - fmt.Sprintf("%s:%d", m.smtpHost, m.smtpPort), - smtp.PlainAuth("", m.username, m.password, m.smtpHost), - m.username, - []string{content.toAddress}, - []byte(msg), - ); err != nil { - return &MailerError{ - Message: fmt.Sprintf("Failed to send email: %v", err), + if err := ctx.Err(); err != nil { + return err + } + + client, err := m.dial(ctx) + if err != nil { + return err + } + defer client.Close() + + if err := client.Hello(m.localName); err != nil { + return &MailerError{Message: "failed to send SMTP EHLO", Err: err} + } + + if err := m.maybeStartTLS(client); err != nil { + return err + } + + if m.username != "" || m.password != "" { + auth := smtp.PlainAuth("", m.username, m.password, m.smtpHost) + if err := client.Auth(auth); err != nil { + return &MailerError{Message: "failed to authenticate with SMTP server", Err: err} } } + if err := client.Mail(content.fromAddress); err != nil { + return &MailerError{Message: "failed to set SMTP sender", Err: err} + } + if err := client.Rcpt(content.toAddress); err != nil { + return &MailerError{Message: "failed to set SMTP recipient", Err: err} + } + + writer, err := client.Data() + if err != nil { + return &MailerError{Message: "failed to start SMTP data transfer", Err: err} + } + if _, err := writer.Write(buildMessage(content)); err != nil { + return &MailerError{Message: "failed to write SMTP message", Err: err} + } + if err := writer.Close(); err != nil { + return &MailerError{Message: "failed to finish SMTP data transfer", Err: err} + } + if err := client.Quit(); err != nil { + return &MailerError{Message: "failed to close SMTP session", Err: err} + } + + return nil +} + +// dial establishes the transport connection and wraps it in an smtp.Client, +// using implicit TLS on port 465 and a plain TCP connection otherwise. +func (m *MailerSMTP) dial(ctx context.Context) (*smtp.Client, error) { + addr := fmt.Sprintf("%s:%d", m.smtpHost, m.smtpPort) + dialer := &net.Dialer{Timeout: m.dialTimeout} + + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, &MailerError{Message: "failed to connect to SMTP server", Err: err} + } + + if m.implicitTLS { + tlsConn := tls.Client(conn, m.tlsClientConfig()) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, &MailerError{Message: "failed TLS handshake with SMTP server", Err: err} + } + conn = tlsConn + } + + client, err := smtp.NewClient(conn, m.smtpHost) + if err != nil { + conn.Close() + return nil, &MailerError{Message: "failed to initialize SMTP client", Err: err} + } + return client, nil +} + +// maybeStartTLS upgrades the connection with STARTTLS when the server advertises +// it. When RequireTLS is set and the connection is not (or cannot be) secured, +// it returns an error rather than sending credentials in the clear. +func (m *MailerSMTP) maybeStartTLS(client *smtp.Client) error { + if _, isTLS := client.TLSConnectionState(); isTLS { + return nil // already secured (implicit TLS on 465). + } + + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(m.tlsClientConfig()); err != nil { + return &MailerError{Message: "failed to start TLS with SMTP server", Err: err} + } + return nil + } + + if m.requireTLS { + return &MailerError{Message: "SMTP server does not support STARTTLS but RequireTLS is set"} + } return nil } + +func (m *MailerSMTP) tlsClientConfig() *tls.Config { + if m.tlsConfig != nil { + return m.tlsConfig.Clone() + } + return &tls.Config{ServerName: m.smtpHost, MinVersion: tls.VersionTLS12} +} + +// buildMessage renders RFC 5322 headers and body using CRLF line endings. +func buildMessage(content MailContent) []byte { + var b strings.Builder + writeHeader(&b, "From", fmt.Sprintf("%s <%s>", content.fromName, content.fromAddress)) + writeHeader(&b, "To", fmt.Sprintf("%s <%s>", content.toName, content.toAddress)) + writeHeader(&b, "Subject", content.subject) + writeHeader(&b, "MIME-Version", "1.0") + writeHeader(&b, "Content-Type", content.mimeType.String()+"; charset=UTF-8") + b.WriteString("\r\n") + b.WriteString(strings.ReplaceAll(content.body, "\n", "\r\n")) + return []byte(b.String()) +} + +func writeHeader(b *strings.Builder, key, value string) { + b.WriteString(key) + b.WriteString(": ") + b.WriteString(value) + b.WriteString("\r\n") +} diff --git a/smtp_server_test.go b/smtp_server_test.go new file mode 100644 index 0000000..dc6f7f8 --- /dev/null +++ b/smtp_server_test.go @@ -0,0 +1,210 @@ +package mailer + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "strings" + "testing" + "time" +) + +// receivedMail captures what a fakeSMTPServer observed for a single delivery. +type receivedMail struct { + from string + to []string + data string + authed bool + usedTLS bool +} + +// fakeSMTPServer is a minimal, in-process SMTP server used to exercise the +// MailerSMTP transport end to end without reaching a real mail server. +type fakeSMTPServer struct { + t *testing.T + ln net.Listener + tlsConfig *tls.Config + offerTLS bool // advertise STARTTLS + offerAuth bool // advertise AUTH PLAIN + implicit bool // serve TLS immediately (SMTPS) + + received chan receivedMail +} + +// newFakeSMTPServer starts a server on an ephemeral loopback port and returns it. +func newFakeSMTPServer(t *testing.T, opts fakeSMTPServer) *fakeSMTPServer { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + + s := &fakeSMTPServer{ + t: t, + ln: ln, + tlsConfig: testServerTLSConfig(t), + offerTLS: opts.offerTLS, + offerAuth: opts.offerAuth, + implicit: opts.implicit, + received: make(chan receivedMail, 8), + } + + go s.serve() + t.Cleanup(func() { _ = ln.Close() }) + return s +} + +func (s *fakeSMTPServer) host() string { + h, _, _ := net.SplitHostPort(s.ln.Addr().String()) + return h +} + +func (s *fakeSMTPServer) port() int { + return s.ln.Addr().(*net.TCPAddr).Port +} + +func (s *fakeSMTPServer) serve() { + for { + conn, err := s.ln.Accept() + if err != nil { + return // listener closed + } + go s.handle(conn) + } +} + +func (s *fakeSMTPServer) handle(conn net.Conn) { + defer conn.Close() + + if s.implicit { + tlsConn := tls.Server(conn, s.tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return + } + conn = tlsConn + } + + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + write := func(line string) { + _, _ = w.WriteString(line + "\r\n") + _ = w.Flush() + } + + _, usedTLS := conn.(*tls.Conn) + var rec receivedMail + rec.usedTLS = usedTLS + + write("220 fake ESMTP ready") + + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + cmd := strings.ToUpper(line) + + switch { + case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"): + write("250-fake greets you") + if s.offerTLS && !usedTLS { + write("250-STARTTLS") + } + if s.offerAuth { + write("250-AUTH PLAIN") + } + write("250 SMTPUTF8") + + case strings.HasPrefix(cmd, "STARTTLS"): + write("220 ready to start TLS") + tlsConn := tls.Server(conn, s.tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return + } + conn = tlsConn + usedTLS = true + rec.usedTLS = true + r = bufio.NewReader(conn) + w = bufio.NewWriter(conn) + write = func(line string) { + _, _ = w.WriteString(line + "\r\n") + _ = w.Flush() + } + + case strings.HasPrefix(cmd, "AUTH"): + rec.authed = true + write("235 2.7.0 authentication succeeded") + + case strings.HasPrefix(cmd, "MAIL FROM:"): + rec.from = strings.TrimPrefix(line[len("MAIL FROM:"):], " ") + write("250 2.1.0 OK") + + case strings.HasPrefix(cmd, "RCPT TO:"): + rec.to = append(rec.to, strings.TrimPrefix(line[len("RCPT TO:"):], " ")) + write("250 2.1.5 OK") + + case strings.HasPrefix(cmd, "DATA"): + write("354 end data with .") + var body strings.Builder + for { + dl, err := r.ReadString('\n') + if err != nil { + return + } + if dl == ".\r\n" || dl == ".\n" { + break + } + body.WriteString(dl) + } + rec.data = body.String() + write("250 2.0.0 OK: queued") + + case strings.HasPrefix(cmd, "QUIT"): + write("221 2.0.0 Bye") + s.received <- rec + return + + case strings.HasPrefix(cmd, "RSET"), strings.HasPrefix(cmd, "NOOP"): + write("250 2.0.0 OK") + + default: + write("500 5.5.1 command not recognized") + } + } +} + +// testServerTLSConfig builds a throwaway self-signed certificate for the server. +func testServerTLSConfig(t *testing.T) *tls.Config { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("failed to create certificate: %v", err) + } + + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv}}, + } +} diff --git a/smtp_test.go b/smtp_test.go index 9efc6ee..5708100 100644 --- a/smtp_test.go +++ b/smtp_test.go @@ -2,12 +2,31 @@ package mailer import ( "context" + "crypto/tls" + "errors" "fmt" "strings" "testing" - // "net/smtp" // Import needed if mocking smtp.SendMail + "time" ) +func testMailContent(t *testing.T) MailContent { + t.Helper() + content, err := NewMailContentBuilder(). + WithFromName("Test Sender"). + WithFromAddress("sender@example.com"). + WithToName("Test Recipient"). + WithToAddress("recipient@example.com"). + WithMimeType(MimeTypeTextPlain). + WithSubject("Basic Send Test"). + WithBody("This is a basic test."). + Build() + if err != nil { + t.Fatalf("failed to build mail content: %v", err) + } + return content +} + func TestNewMailerSMTP_Valid(t *testing.T) { validConf := MailerSMTPConf{ SMTPHost: "smtp.example.com", @@ -16,27 +35,46 @@ func TestNewMailerSMTP_Valid(t *testing.T) { Password: "password123", } - mailer, err := NewMailerSMTP(validConf) + m, err := NewMailerSMTP(validConf) if err != nil { t.Fatalf("Expected no error for valid config, but got: %v", err) } - - if mailer == nil { + if m == nil { t.Fatal("Expected mailer instance, but got nil") } - // Check if fields are set correctly - if mailer.smtpHost != validConf.SMTPHost { - t.Errorf("Expected smtpHost '%s', got '%s'", validConf.SMTPHost, mailer.smtpHost) + if m.smtpHost != validConf.SMTPHost { + t.Errorf("Expected smtpHost %q, got %q", validConf.SMTPHost, m.smtpHost) + } + if m.smtpPort != validConf.SMTPPort { + t.Errorf("Expected smtpPort %d, got %d", validConf.SMTPPort, m.smtpPort) + } + if m.dialTimeout != DefaultSMTPDialTimeout { + t.Errorf("Expected default dial timeout %v, got %v", DefaultSMTPDialTimeout, m.dialTimeout) + } + if m.localName != DefaultSMTPLocalName { + t.Errorf("Expected default local name %q, got %q", DefaultSMTPLocalName, m.localName) + } +} + +func TestNewMailerSMTP_DefaultsOverridable(t *testing.T) { + m, err := NewMailerSMTP(MailerSMTPConf{ + SMTPHost: "smtp.example.com", + SMTPPort: 465, + DialTimeout: 3 * time.Second, + LocalName: "mail.example.com", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if mailer.smtpPort != validConf.SMTPPort { - t.Errorf("Expected smtpPort %d, got %d", validConf.SMTPPort, mailer.smtpPort) + if m.dialTimeout != 3*time.Second { + t.Errorf("expected dial timeout 3s, got %v", m.dialTimeout) } - if mailer.username != validConf.Username { - t.Errorf("Expected username '%s', got '%s'", validConf.Username, mailer.username) + if m.localName != "mail.example.com" { + t.Errorf("expected custom local name, got %q", m.localName) } - if mailer.password != validConf.Password { - t.Errorf("Expected password '%s', got '%s'", validConf.Password, mailer.password) + if !m.implicitTLS { + t.Error("expected implicit TLS to be enabled for port 465") } } @@ -56,8 +94,8 @@ func TestNewMailerSMTP_Invalid(t *testing.T) { expectedError string }{ { - name: "SMTPHost too short", - modifier: func(c *MailerSMTPConf) { c.SMTPHost = "a.b" }, + name: "SMTPHost empty", + modifier: func(c *MailerSMTPConf) { c.SMTPHost = "" }, expectedError: fmt.Sprintf("SMTPHost must be between %d and %d characters", ValidMinSMTPHostLength, ValidMaxSMTPHostLength), }, { @@ -66,29 +104,24 @@ func TestNewMailerSMTP_Invalid(t *testing.T) { expectedError: fmt.Sprintf("SMTPHost must be between %d and %d characters", ValidMinSMTPHostLength, ValidMaxSMTPHostLength), }, { - name: "Invalid SMTPPort", - modifier: func(c *MailerSMTPConf) { c.SMTPPort = 26 }, - expectedError: fmt.Sprintf("SMTPPort must be one of the following: %s", ValidSMTPPorts), + name: "SMTPPort too low", + modifier: func(c *MailerSMTPConf) { c.SMTPPort = 0 }, + expectedError: fmt.Sprintf("SMTPPort must be between %d and %d", ValidMinSMTPPort, ValidMaxSMTPPort), }, { - name: "Username too short", - modifier: func(c *MailerSMTPConf) { c.Username = "" }, - expectedError: fmt.Sprintf("Username must be between %d and %d characters", ValidMinUsernameLength, ValidMaxUsernameLength), + name: "SMTPPort too high", + modifier: func(c *MailerSMTPConf) { c.SMTPPort = 70000 }, + expectedError: fmt.Sprintf("SMTPPort must be between %d and %d", ValidMinSMTPPort, ValidMaxSMTPPort), }, { name: "Username too long", modifier: func(c *MailerSMTPConf) { c.Username = strings.Repeat("a", ValidMaxUsernameLength+1) }, - expectedError: fmt.Sprintf("Username must be between %d and %d characters", ValidMinUsernameLength, ValidMaxUsernameLength), - }, - { - name: "Password too short", - modifier: func(c *MailerSMTPConf) { c.Password = "pw" }, - expectedError: fmt.Sprintf("Password must be between %d and %d characters", ValidMinPasswordLength, ValidMaxPasswordLength), + expectedError: fmt.Sprintf("Username must be at most %d characters", ValidMaxUsernameLength), }, { name: "Password too long", modifier: func(c *MailerSMTPConf) { c.Password = strings.Repeat("a", ValidMaxPasswordLength+1) }, - expectedError: fmt.Sprintf("Password must be between %d and %d characters", ValidMinPasswordLength, ValidMaxPasswordLength), + expectedError: fmt.Sprintf("Password must be at most %d characters", ValidMaxPasswordLength), }, } @@ -99,73 +132,186 @@ func TestNewMailerSMTP_Invalid(t *testing.T) { _, err := NewMailerSMTP(conf) if err == nil { - t.Fatalf("Expected error '%s', but got nil", tc.expectedError) + t.Fatalf("Expected error %q, but got nil", tc.expectedError) } - - mailerErr, ok := err.(*MailerError) - if !ok { - t.Fatalf("Expected error type *MailerError, but got %T", err) + var mailerErr *MailerError + if !errors.As(err, &mailerErr) { + t.Fatalf("Expected *MailerError, but got %T", err) } - if mailerErr.Message != tc.expectedError { - t.Errorf("Expected error message '%s', but got '%s'", tc.expectedError, mailerErr.Message) + t.Errorf("Expected error message %q, but got %q", tc.expectedError, mailerErr.Message) } }) } } -// TestSendBasic checks the Send method structure but does not actually send an email. -// Proper testing of Send requires mocking net/smtp.SendMail or using a test SMTP server. -func TestSendBasic(t *testing.T) { - conf := MailerSMTPConf{ - SMTPHost: "smtp.example.com", // Use a dummy host - SMTPPort: 587, - Username: "user", - Password: "password", +func TestMailerSMTP_Send_NoAuth(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPServer{}) + + m, err := NewMailerSMTP(MailerSMTPConf{SMTPHost: server.host(), SMTPPort: server.port()}) + if err != nil { + t.Fatalf("failed to create mailer: %v", err) + } + + if err := m.Send(context.Background(), testMailContent(t)); err != nil { + t.Fatalf("Send returned error: %v", err) + } + + rec := waitForMail(t, server) + if rec.usedTLS { + t.Error("did not expect TLS on a plain server") + } + if !strings.Contains(rec.from, "sender@example.com") { + t.Errorf("unexpected MAIL FROM: %q", rec.from) + } + if len(rec.to) != 1 || !strings.Contains(rec.to[0], "recipient@example.com") { + t.Errorf("unexpected RCPT TO: %v", rec.to) } - mailer, err := NewMailerSMTP(conf) + if !strings.Contains(rec.data, "Subject: Basic Send Test") { + t.Errorf("message missing subject header:\n%s", rec.data) + } + if !strings.Contains(rec.data, "\r\n") { + t.Error("message should use CRLF line endings") + } +} + +func TestMailerSMTP_Send_STARTTLSAndAuth(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPServer{offerTLS: true, offerAuth: true}) + + m, err := NewMailerSMTP(MailerSMTPConf{ + SMTPHost: server.host(), + SMTPPort: server.port(), + Username: "user", + Password: "secret", + RequireTLS: true, + TLSConfig: &tls.Config{InsecureSkipVerify: true}, + }) if err != nil { - t.Fatalf("Failed to create mailer for basic Send test: %v", err) + t.Fatalf("failed to create mailer: %v", err) } - contentBuilder := MailContentBuilder{} - content, err := contentBuilder. - WithFromName("Test Sender"). - WithFromAddress("sender@example.com"). - WithToName("Test Recipient"). - WithToAddress("recipient@example.com"). - WithMimeType("text/plain"). - WithSubject("Basic Send Test"). - WithBody("This is a basic test."). - Build() + if err := m.Send(context.Background(), testMailContent(t)); err != nil { + t.Fatalf("Send returned error: %v", err) + } + + rec := waitForMail(t, server) + if !rec.usedTLS { + t.Error("expected STARTTLS to secure the connection") + } + if !rec.authed { + t.Error("expected authentication to occur") + } +} + +func TestMailerSMTP_Send_ImplicitTLS(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPServer{implicit: true, offerAuth: true}) + + m, err := NewMailerSMTP(MailerSMTPConf{ + SMTPHost: server.host(), + SMTPPort: server.port(), + Username: "user", + Password: "secret", + ImplicitTLS: true, + TLSConfig: &tls.Config{InsecureSkipVerify: true}, + }) if err != nil { - t.Fatalf("Failed to build mail content for basic Send test: %v", err) + t.Fatalf("failed to create mailer: %v", err) } - // We expect Send to fail because it can't connect to "smtp.example.com", - // but we are checking that it doesn't panic and returns a MailerError. - err = mailer.Send(context.Background(), content) + if err := m.Send(context.Background(), testMailContent(t)); err != nil { + t.Fatalf("Send returned error: %v", err) + } + rec := waitForMail(t, server) + if !rec.usedTLS { + t.Error("expected implicit TLS connection") + } +} + +func TestMailerSMTP_Send_RequireTLSFailsWithoutSupport(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPServer{offerTLS: false}) + + m, err := NewMailerSMTP(MailerSMTPConf{ + SMTPHost: server.host(), + SMTPPort: server.port(), + RequireTLS: true, + }) + if err != nil { + t.Fatalf("failed to create mailer: %v", err) + } + + err = m.Send(context.Background(), testMailContent(t)) if err == nil { - t.Error("Expected an error from Send due to invalid host/credentials, but got nil") - } else { - _, ok := err.(*MailerError) - if !ok { - t.Errorf("Expected error type *MailerError from Send, but got %T (%v)", err, err) - } else { - // Optionally check if the error message indicates a failure to send - if !strings.Contains(err.Error(), "Failed to send email") { - t.Errorf("Expected error message to contain 'Failed to send email', but got: %s", err.Error()) - } + t.Fatal("expected an error when TLS is required but unsupported") + } + if !strings.Contains(err.Error(), "STARTTLS") { + t.Errorf("expected STARTTLS-related error, got %v", err) + } +} + +func TestMailerSMTP_Send_ContextCancelled(t *testing.T) { + server := newFakeSMTPServer(t, fakeSMTPServer{}) + m, err := NewMailerSMTP(MailerSMTPConf{SMTPHost: server.host(), SMTPPort: server.port()}) + if err != nil { + t.Fatalf("failed to create mailer: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err = m.Send(ctx, testMailContent(t)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestMailerSMTP_Send_ConnectionFailure(t *testing.T) { + m, err := NewMailerSMTP(MailerSMTPConf{ + SMTPHost: "127.0.0.1", + SMTPPort: 1, // nothing is listening here + DialTimeout: 500 * time.Millisecond, + }) + if err != nil { + t.Fatalf("failed to create mailer: %v", err) + } + + err = m.Send(context.Background(), testMailContent(t)) + if err == nil { + t.Fatal("expected a connection error") + } + var mailerErr *MailerError + if !errors.As(err, &mailerErr) { + t.Fatalf("expected *MailerError, got %T", err) + } + if !strings.Contains(mailerErr.Message, "failed to connect") { + t.Errorf("expected connect failure message, got %q", mailerErr.Message) + } +} + +func TestBuildMessage(t *testing.T) { + content := testMailContent(t) + msg := string(buildMessage(content)) + + for _, want := range []string{ + "From: Test Sender \r\n", + "To: Test Recipient \r\n", + "Subject: Basic Send Test\r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: text/plain; charset=UTF-8\r\n", + } { + if !strings.Contains(msg, want) { + t.Errorf("message missing %q in:\n%s", want, msg) } } +} - // Note: To truly test the Send logic (formatting, auth), mocking smtp.SendMail is necessary. - // Example (conceptual): - // mockSendMail := func(addr string, a smtp.Auth, from string, to []string, msg []byte) error { - // // Add checks here for addr, auth, from, to, msg content - // fmt.Println("Mock SendMail called!") - // return nil // or return an error for testing error handling - // } - // // Inject mockSendMail (requires modifying MailerSMTP or using interfaces/dependency injection) +func waitForMail(t *testing.T, s *fakeSMTPServer) receivedMail { + t.Helper() + select { + case rec := <-s.received: + return rec + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the server to receive mail") + return receivedMail{} + } }