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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ go build -o 5kmcli ./cmd/cli
| Flag | Default | Description |
| -------------------- | ----------------------- | --------------------------------------- |
| `--server URL` | `http://localhost:9000` | Server base URL |
| `--private-key-path` | | Path to Ed25519 private key for signing |
| `--private-key-path` | - | Path to Ed25519 private key for signing |

**Commands**

Expand Down
44 changes: 35 additions & 9 deletions api/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,23 @@ type Subscriber interface {
Unsubscribe(ctx context.Context, unsubscribeToken string) error
}

type RedirectPages struct {
SubscribeSuccess string
SubscribeError string
ConfirmSuccess string
ConfirmError string
UnsubscribeSuccess string
UnsubscribeError string
}

type PublicHandler struct {
subscriptions Subscriber
redirects RedirectPages
logger *slog.Logger
}

func NewPublicHandler(subscriptions Subscriber, logger *slog.Logger) *PublicHandler {
return &PublicHandler{subscriptions: subscriptions, logger: logger}
func NewPublicHandler(subscriptions Subscriber, redirects RedirectPages, logger *slog.Logger) *PublicHandler {
return &PublicHandler{subscriptions: subscriptions, redirects: redirects, logger: logger}
}

func (h *PublicHandler) Routes() *http.ServeMux {
Expand All @@ -39,7 +49,7 @@ func (h *PublicHandler) handleSubscribe(w http.ResponseWriter, r *http.Request)

name, email, err := parseSubscribeBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
redirectOrError(w, r, h.redirects.SubscribeError, http.StatusBadRequest, err.Error())
return
}

Expand All @@ -49,11 +59,11 @@ func (h *PublicHandler) handleSubscribe(w http.ResponseWriter, r *http.Request)
slog.String("email", email),
slog.Any("error", err),
)
writeError(w, http.StatusInternalServerError, "subscription failed")
redirectOrError(w, r, h.redirects.SubscribeError, http.StatusInternalServerError, "subscription failed")
return
}

writeJSON(w, http.StatusAccepted, map[string]string{"message": "check your email for a confirmation link"})
redirectOrJSON(w, r, h.redirects.SubscribeSuccess, http.StatusAccepted, map[string]string{"message": "check your email for a confirmation link"})
}

func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) {
Expand All @@ -64,11 +74,11 @@ func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) {
slog.String("token", token),
slog.Any("error", err),
)
writeError(w, http.StatusBadRequest, "invalid or expired confirmation token")
redirectOrError(w, r, h.redirects.ConfirmError, http.StatusBadRequest, "invalid or expired confirmation token")
return
}

writeJSON(w, http.StatusOK, map[string]string{"message": "your subscription has been confirmed"})
redirectOrJSON(w, r, h.redirects.ConfirmSuccess, http.StatusOK, map[string]string{"message": "your subscription has been confirmed"})
}

func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
Expand All @@ -79,11 +89,27 @@ func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request
slog.String("token", token),
slog.Any("error", err),
)
writeError(w, http.StatusBadRequest, "invalid or expired unsubscribe token")
redirectOrError(w, r, h.redirects.UnsubscribeError, http.StatusBadRequest, "invalid or expired unsubscribe token")
return
}

redirectOrJSON(w, r, h.redirects.UnsubscribeSuccess, http.StatusOK, map[string]string{"message": "you have been unsubscribed"})
}

func redirectOrJSON(w http.ResponseWriter, r *http.Request, redirectURL string, status int, v any) {
if redirectURL != "" {
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
writeJSON(w, status, v)
}

writeJSON(w, http.StatusOK, map[string]string{"message": "you have been unsubscribed"})
func redirectOrError(w http.ResponseWriter, r *http.Request, redirectURL string, status int, msg string) {
if redirectURL != "" {
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
writeError(w, status, msg)
}

func parseSubscribeBody(r *http.Request) (name, email string, err error) {
Expand Down
120 changes: 119 additions & 1 deletion api/public_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ func (f *fakeSubscriber) Unsubscribe(_ context.Context, token string) error {
}

func newTestHandler(sub *fakeSubscriber) *PublicHandler {
return NewPublicHandler(sub, slog.Default())
return NewPublicHandler(sub, RedirectPages{}, slog.Default())
}

func newTestHandlerWithRedirects(sub *fakeSubscriber, redirects RedirectPages) *PublicHandler {
return NewPublicHandler(sub, redirects, slog.Default())
}

func TestHandleSubscribe(t *testing.T) {
Expand Down Expand Up @@ -261,3 +265,117 @@ func TestRoutes(t *testing.T) {
}
})
}

func TestRedirectPages(t *testing.T) {
redirects := RedirectPages{
SubscribeSuccess: "https://example.com/subscribe/success",
SubscribeError: "https://example.com/subscribe/error",
ConfirmSuccess: "https://example.com/confirm/success",
ConfirmError: "https://example.com/confirm/error",
UnsubscribeSuccess: "https://example.com/unsubscribe/success",
UnsubscribeError: "https://example.com/unsubscribe/error",
}

t.Run("subscribe success redirects", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects)
body := `{"name":"Alice","email":"alice@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("listName", "weekly")
w := httptest.NewRecorder()
h.handleSubscribe(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.SubscribeSuccess {
t.Errorf("expected Location %q, got %q", redirects.SubscribeSuccess, loc)
}
})

t.Run("subscribe error redirects on bad request", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects)
req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(`{"name":"Alice"}`))
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("listName", "weekly")
w := httptest.NewRecorder()
h.handleSubscribe(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.SubscribeError {
t.Errorf("expected Location %q, got %q", redirects.SubscribeError, loc)
}
})

t.Run("subscribe error redirects on service error", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{subscribeErr: errors.New("db down")}, redirects)
body := `{"name":"Alice","email":"alice@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("listName", "weekly")
w := httptest.NewRecorder()
h.handleSubscribe(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.SubscribeError {
t.Errorf("expected Location %q, got %q", redirects.SubscribeError, loc)
}
})

t.Run("confirm success redirects", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects)
req := httptest.NewRequest(http.MethodGet, "/confirm/abc123", nil)
req.SetPathValue("token", "abc123")
w := httptest.NewRecorder()
h.handleConfirm(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.ConfirmSuccess {
t.Errorf("expected Location %q, got %q", redirects.ConfirmSuccess, loc)
}
})

t.Run("confirm error redirects", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{confirmeErr: errors.New("bad token")}, redirects)
req := httptest.NewRequest(http.MethodGet, "/confirm/bad", nil)
req.SetPathValue("token", "bad")
w := httptest.NewRecorder()
h.handleConfirm(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.ConfirmError {
t.Errorf("expected Location %q, got %q", redirects.ConfirmError, loc)
}
})

t.Run("unsubscribe success redirects", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects)
req := httptest.NewRequest(http.MethodGet, "/unsubscribe/tok123", nil)
req.SetPathValue("token", "tok123")
w := httptest.NewRecorder()
h.handleUnsubscribe(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.UnsubscribeSuccess {
t.Errorf("expected Location %q, got %q", redirects.UnsubscribeSuccess, loc)
}
})

t.Run("unsubscribe error redirects", func(t *testing.T) {
h := newTestHandlerWithRedirects(&fakeSubscriber{unsubscribeErr: errors.New("bad token")}, redirects)
req := httptest.NewRequest(http.MethodGet, "/unsubscribe/bad", nil)
req.SetPathValue("token", "bad")
w := httptest.NewRecorder()
h.handleUnsubscribe(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("expected 303, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != redirects.UnsubscribeError {
t.Errorf("expected Location %q, got %q", redirects.UnsubscribeError, loc)
}
})
}
8 changes: 8 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ paths:
template: "./static/template.html" # HTML wrapper rendered around markdown content
theme: "./static/theme.example.css" # CSS injected into the template
confirm-mail: "./static/confirm.md" # markdown template for the double opt-in email

redirects:
subscribe-success: "" # https://yoursite.com/subscribed
subscribe-error: "" # https://yoursite.com/subscribe-failed
confirm-success: "" # https://yoursite.com/confirmed
confirm-error: "" # https://yoursite.com/confirm-failed
unsubscribe-success: "" # https://yoursite.com/unsubscribed
unsubscribe-error: "" # https://yoursite.com/unsubscribe-failed
11 changes: 11 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ type SmtpConfig struct {
TLSPolicy TLSPolicy `env:"SMTP_TLS_POLICY" env-default:"TLSOpportunistic" yaml:"tls-policy"`
}

type RedirectPages struct {
SubscribeSuccess string `env:"REDIRECT_SUBSCRIBE_SUCCESS" yaml:"subscribe-success"`
SubscribeError string `env:"REDIRECT_SUBSCRIBE_ERROR" yaml:"subscribe-error"`
ConfirmSuccess string `env:"REDIRECT_CONFIRM_SUCCESS" yaml:"confirm-success"`
ConfirmError string `env:"REDIRECT_CONFIRM_ERROR" yaml:"confirm-error"`
UnsubscribeSuccess string `env:"REDIRECT_UNSUBSCRIBE_SUCCESS" yaml:"unsubscribe-success"`
UnsubscribeError string `env:"REDIRECT_UNSUBSCRIBE_ERROR" yaml:"unsubscribe-error"`
}

type Config struct {
PublicAddr string `env:"PUBLIC_ADDR" env-default:":8080" yaml:"public-addr"`
PrivateAddr string `env:"PRIVATE_ADDR" env-default:":9000" yaml:"private-addr"`
Expand All @@ -44,6 +53,8 @@ type Config struct {
PublicKeyPath string `env:"AUTH_PUBLIC_KEY_PATH" yaml:"public-key-path"`
} `yaml:"auth"`

Redirects RedirectPages `yaml:"redirects"`

Paths struct {
Config string `env:"CONFIG_PATH" env-default:"config.yml"`
Template string `env:"TEMPLATE_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/template.html" yaml:"template"`
Expand Down
36 changes: 19 additions & 17 deletions API.md → docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ All responses: `Content-Type: application/json`
Subscribe a user to the named mailing list. Triggers a double opt-in confirmation email.

**Path params**
- `listName` name of the mailing list
- `listName` - name of the mailing list

**Body** `application/json` or `application/x-www-form-urlencoded`
**Body** - `application/json` or `application/x-www-form-urlencoded`

| Field | Type | Required |
|---------|--------|----------|
Expand All @@ -26,14 +26,16 @@ Subscribe a user to the named mailing list. Triggers a double opt-in confirmatio
| `400` | Missing/invalid fields |
| `500` | Internal error |

When redirect pages are configured, all outcomes issue a `303 See Other` instead of a JSON body. See [CONFIG.md](CONFIG.md#redirects).

---

## GET `/confirm/{token}`

Complete double opt-in using the token from the confirmation email.

**Path params**
- `token` 64-char hex token
- `token` - 64-char hex token

**Responses**

Expand All @@ -49,7 +51,7 @@ Complete double opt-in using the token from the confirmation email.
Remove a subscriber using their per-subscription unsubscribe token (included in every newsletter).

**Path params**
- `token` 64-char hex unsubscribe token (unique per subscription)
- `token` - 64-char hex unsubscribe token (unique per subscription)

**Responses**

Expand Down Expand Up @@ -85,7 +87,7 @@ Requests whose timestamp differs from the server's clock by more than 5 minutes

Create a new mailing list.

**Body** `application/json`
**Body** - `application/json`

| Field | Type | Required |
|--------|--------|----------|
Expand All @@ -111,7 +113,7 @@ Create a new mailing list.
Get list details including subscriber counts.

**Path params**
- `id` numeric list ID
- `id` - numeric list ID

**Responses**

Expand All @@ -137,9 +139,9 @@ Get list details including subscriber counts.
Rename a mailing list.

**Path params**
- `id` numeric list ID
- `id` - numeric list ID

**Body** `application/json`
**Body** - `application/json`

| Field | Type | Required |
|--------|--------|----------|
Expand All @@ -160,7 +162,7 @@ Rename a mailing list.
Delete a mailing list and all its subscribers.

**Path params**
- `id` numeric list ID
- `id` - numeric list ID

**Responses**

Expand All @@ -177,7 +179,7 @@ Delete a mailing list and all its subscribers.
List all subscribers of a mailing list.

**Path params**
- `id` numeric list ID
- `id` - numeric list ID

**Responses**

Expand All @@ -202,9 +204,9 @@ List all subscribers of a mailing list.
Render a markdown newsletter and send it to all confirmed subscribers of the named list.

**Path params**
- `name` list name
- `name` - list name

**Body** `application/json`
**Body** - `application/json`

| Field | Type | Required | Description |
|--------|--------|----------|-----------------------------------------------|
Expand All @@ -225,7 +227,7 @@ Render a markdown newsletter and send it to all confirmed subscribers of the nam

Send a rendered test mail to a single recipient without touching any list.

**Body** `application/json`
**Body** - `application/json`

| Field | Type | Required | Description |
|-------------------|--------|----------|-------------------------|
Expand All @@ -236,8 +238,8 @@ Send a rendered test mail to a single recipient without touching any list.

**Responses**

| Status | Meaning |
|--------|-----------------------------------|
| `200` | Test mail sent |
| Status | Meaning |
|--------|------------------------------------|
| `200` | Test mail sent |
| `400` | Missing `recipient.email` or `raw` |
| `500` | Internal error |
| `500` | Internal error |
Loading
Loading