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.