Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 80 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>(HTTP handlers, jobs)"]
B["MailContentBuilder<br/>validate & build"]
Q(["Buffered queue<br/>cap = QueueSize"])
subgraph Pool["Worker pool (WorkerCount)"]
W1["worker 1"]
W2["worker 2"]
WN["worker N"]
end
T{{"MailerService<br/>Send(ctx, MailContent)"}}
SMTP["MailerSMTP<br/>(TLS / STARTTLS)"]
CUS["Custom backend<br/>(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
Expand Down Expand Up @@ -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)<br/>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.
Expand Down
6 changes: 6 additions & 0 deletions docs/examples/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

This example shows how to wire `mailer` into a small service, step by step.

```mermaid
flowchart LR
S1["1 · NewMailerSMTP<br/>(transport)"] --> S2["2 · NewMailService<br/>+ Start()"]
S2 --> S3["3 · Build message<br/>(validated)"] --> S4["4 · Enqueue()"] --> S5["5 · Stop()<br/>(drain + wait)"]
```

## Step 1: Create the SMTP backend

`RequireTLS` makes delivery fail rather than send credentials over an unencrypted
Expand Down
66 changes: 66 additions & 0 deletions docs/examples/custom-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ type MailerService interface {
}
```

```mermaid
classDiagram
class MailerService {
<<interface>>
+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:

Expand Down Expand Up @@ -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
Expand Down
70 changes: 68 additions & 2 deletions docs/production-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>(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
Expand Down Expand Up @@ -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<br/>stopped?"}
B -- yes --> E1["return ErrServiceStopped"]
B -- no --> C{"queue<br/>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.

Expand All @@ -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<br/>or port 465?"}
IMP -- yes --> TLS["TLS handshake now"]
IMP -- no --> EHLO
TLS --> EHLO["EHLO LocalName"]
EHLO --> HAS{"server offers<br/>STARTTLS?"}
HAS -- "yes" --> STLS["StartTLS()"] --> AUTH
HAS -- "no" --> REQ{"RequireTLS?"}
REQ -- "yes" --> FAIL["fail: TLS required<br/>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.
Expand Down
Loading