From 7ef2793b4ee54c7e3ef373098fbf2fa80120c177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 11 Jul 2026 15:14:48 +0200 Subject: [PATCH] docs: add Mermaid diagrams to README and docs Replace ASCII diagrams with GitHub-rendered Mermaid and add new ones: - README: architecture flowchart, message-flow sequence diagram, and a service-lifecycle state diagram. - docs/production-guide.md: layered-architecture flowchart, shutdown state diagram, Enqueue back-pressure decision flowchart, and an SMTP TLS/STARTTLS negotiation flowchart. - docs/examples/basic.md: five-step flow diagram. - docs/examples/custom-backend.md: MailerService class diagram plus a RetryMailer wrapper example that the diagram references. Docs-only change; no code modified. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 86 ++++++++++++++++++++++++++++++--- docs/examples/basic.md | 6 +++ docs/examples/custom-backend.md | 66 +++++++++++++++++++++++++ docs/production-guide.md | 70 ++++++++++++++++++++++++++- 4 files changed, 220 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c7bc527..6d886f1 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,32 @@ `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 +```mermaid +flowchart LR + P["Producers
(HTTP handlers, jobs)"] + B["MailContentBuilder
validate & build"] + Q(["Buffered queue
cap = QueueSize"]) + subgraph Pool["Worker pool (WorkerCount)"] + W1["worker 1"] + W2["worker 2"] + WN["worker N"] + end + T{{"MailerService
Send(ctx, MailContent)"}} + SMTP["MailerSMTP
(TLS / STARTTLS)"] + CUS["Custom backend
(SES, SendGrid, …)"] + + P -->|"NewMailContentBuilder()"| B + B -->|"MailContent"| P + P -->|"Enqueue() — blocks when full"| Q + Q --> W1 & W2 & WN + W1 & W2 & WN --> T + T --> SMTP + T --> CUS + + classDef q fill:#fde68a,stroke:#b45309,color:#000; + classDef t fill:#bfdbfe,stroke:#1d4ed8,color:#000; + class Q q; + class T t; ``` ## Features @@ -144,8 +164,62 @@ A complete, runnable program lives in [example/main.go](example/main.go). | `DialTimeout` | `10s` | TCP connection timeout. | | `LocalName` | `localhost` | Name announced in EHLO/HELO. | +## How a message flows + +```mermaid +sequenceDiagram + autonumber + actor App as Application + participant Svc as MailService + participant Q as Queue (channel) + participant W as Worker + participant M as MailerService (SMTP) + participant Srv as Mail server + + App->>Svc: Start() + Svc->>W: spawn WorkerCount goroutines + App->>Svc: Enqueue(content) + alt queue has room + Svc->>Q: content + Svc-->>App: nil + else queue full + Note over Svc,Q: Enqueue blocks (back-pressure)
until a slot frees or ctx is cancelled + end + W->>Q: receive content + W->>M: Send(ctx, content) + M->>Srv: dial + (STARTTLS/TLS) + AUTH + DATA + Srv-->>M: 250 OK + M-->>W: nil / *MailerError + App->>Svc: Stop() + Svc->>Q: close() + W->>Q: drain remaining + W-->>Svc: workers exit + Svc-->>App: returns after drain +``` + ## Lifecycle & shutdown semantics +```mermaid +stateDiagram-v2 + [*] --> Ready: NewMailService() + Ready --> Running: Start() + Running --> Running: Enqueue() / Send() + Running --> Draining: Stop() + Draining --> Stopped: queue drained, workers exited + Running --> Cancelled: ctx cancelled + Cancelled --> Stopped: workers exit (no drain) + Stopped --> [*] + + note right of Draining + graceful: queued + messages are sent + end note + note right of Cancelled + hard stop: in-flight + sends observe ctx.Done() + end note +``` + - `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. diff --git a/docs/examples/basic.md b/docs/examples/basic.md index c156a67..4a7286c 100644 --- a/docs/examples/basic.md +++ b/docs/examples/basic.md @@ -2,6 +2,12 @@ This example shows how to wire `mailer` into a small service, step by step. +```mermaid +flowchart LR + S1["1 · NewMailerSMTP
(transport)"] --> S2["2 · NewMailService
+ Start()"] + S2 --> S3["3 · Build message
(validated)"] --> S4["4 · Enqueue()"] --> S5["5 · Stop()
(drain + wait)"] +``` + ## Step 1: Create the SMTP backend `RequireTLS` makes delivery fail rather than send credentials over an unencrypted diff --git a/docs/examples/custom-backend.md b/docs/examples/custom-backend.md index 14ededf..754bf1a 100644 --- a/docs/examples/custom-backend.md +++ b/docs/examples/custom-backend.md @@ -11,6 +11,39 @@ type MailerService interface { } ``` +```mermaid +classDiagram + class MailerService { + <> + +Send(ctx, MailContent) error + } + class MailContent { + +FromName() string + +FromAddress() string + +ToName() string + +ToAddress() string + +MimeType() MimeType + +Subject() string + +Body() string + } + class MailerSMTP + class APIMailer + class RecordingMailer + class RetryMailer { + -next MailerService + } + + MailerService <|.. MailerSMTP : implements + MailerService <|.. APIMailer : implements + MailerService <|.. RecordingMailer : implements + MailerService <|.. RetryMailer : implements + RetryMailer o-- MailerService : wraps + MailerService ..> MailContent : reads +``` + +The `RetryMailer` above shows the recommended way to layer policy: a +`MailerService` that **wraps** another one (see "Layer policy by wrapping"). + `MailContent` is immutable and exposes read accessors, so your backend can read every field it needs: @@ -96,6 +129,39 @@ service, err := mailer.NewMailService(&mailer.MailServiceConfig{ - **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 retrying wrapper + +```go +// RetryMailer retries a wrapped MailerService with a fixed backoff. +type RetryMailer struct { + Next mailer.MailerService + Attempts int + Backoff time.Duration +} + +func (m *RetryMailer) Send(ctx context.Context, c mailer.MailContent) error { + var err error + for attempt := 1; attempt <= m.Attempts; attempt++ { + if err = m.Next.Send(ctx, c); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(m.Backoff): + } + } + return err +} +``` + +Compose it around any transport — the queue is unaware of the added policy: + +```go +backend := &RetryMailer{Next: smtpMailer, Attempts: 3, Backoff: time.Second} +service, _ := mailer.NewMailService(&mailer.MailServiceConfig{WorkerCount: 4, Mailer: backend}) +``` + ## Example: a test double ```go diff --git a/docs/production-guide.md b/docs/production-guide.md index 6396ebb..5037257 100644 --- a/docs/production-guide.md +++ b/docs/production-guide.md @@ -10,8 +10,24 @@ The package is built around three layers: 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 +```mermaid +flowchart LR + subgraph L1["1 · Construction"] + B["MailContentBuilder"] --> C["MailContent
(immutable, validated)"] + end + subgraph L2["2 · Queue + workers"] + E["MailService.Enqueue"] --> Q(["buffered queue"]) + Q --> WP["worker pool"] + end + subgraph L3["3 · Transport"] + I["MailerService.Send"] + end + C --> E + WP --> I + I --> S["SMTP / provider API"] + + classDef c fill:#dcfce7,stroke:#15803d,color:#000; + class C c; ``` ## 2. Service lifecycle @@ -53,10 +69,38 @@ defer service.Stop() // graceful: drains the queue and waits for workers `Start()`, `Stop()`, and `Enqueue()` are all safe for concurrent use. +```mermaid +stateDiagram-v2 + direction LR + [*] --> Ready + Ready --> Running: Start() + Running --> Draining: Stop() + Running --> Cancelled: ctx cancelled + Draining --> Stopped: queue drained + Cancelled --> Stopped: workers exit + Stopped --> [*] +``` + ## 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. +```mermaid +flowchart TD + A["Enqueue(content)"] --> B{"service
stopped?"} + B -- yes --> E1["return ErrServiceStopped"] + B -- no --> C{"queue
has room?"} + C -- yes --> D["deliver to queue"] --> OK["return nil"] + C -- no --> W{"wait for..."} + W -- "slot frees" --> D + W -- "ctx cancelled" --> E2["return ctx.Err()"] + + classDef err fill:#fecaca,stroke:#b91c1c,color:#000; + classDef ok fill:#dcfce7,stroke:#15803d,color:#000; + class E1,E2 err; + class OK ok; +``` + - 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. @@ -81,6 +125,28 @@ Set `Timeout` to bound each `Send`. Every delivery then runs with a context deri - **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`. +```mermaid +flowchart TD + START(["Send()"]) --> DIAL["TCP dial (DialTimeout)"] + DIAL --> IMP{"ImplicitTLS
or port 465?"} + IMP -- yes --> TLS["TLS handshake now"] + IMP -- no --> EHLO + TLS --> EHLO["EHLO LocalName"] + EHLO --> HAS{"server offers
STARTTLS?"} + HAS -- "yes" --> STLS["StartTLS()"] --> AUTH + HAS -- "no" --> REQ{"RequireTLS?"} + REQ -- "yes" --> FAIL["fail: TLS required
but unavailable"] + REQ -- "no" --> AUTH + AUTH{"credentials set?"} -- yes --> DOAUTH["AUTH PLAIN"] --> SEND + AUTH -- no --> SEND["MAIL / RCPT / DATA / QUIT"] + SEND --> DONE(["nil"]) + + classDef sec fill:#bfdbfe,stroke:#1d4ed8,color:#000; + classDef err fill:#fecaca,stroke:#b91c1c,color:#000; + class TLS,STLS sec; + class FAIL err; +``` + Recommendations: - Set `RequireTLS: true` so delivery fails instead of sending credentials or content over an unencrypted connection.