From 252c19190def46202378d5f34dba7b17ce7684c5 Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Tue, 14 Apr 2026 12:52:04 +0200 Subject: [PATCH 1/2] add redirects --- api/public.go | 44 +++++++++++++---- api/public_test.go | 120 ++++++++++++++++++++++++++++++++++++++++++++- config.example.yml | 8 +++ config/config.go | 11 +++++ main.go | 9 +++- 5 files changed, 181 insertions(+), 11 deletions(-) diff --git a/api/public.go b/api/public.go index 9041cd1..3ae7f60 100644 --- a/api/public.go +++ b/api/public.go @@ -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 { @@ -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 } @@ -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) { @@ -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) { @@ -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) { diff --git a/api/public_test.go b/api/public_test.go index 9c3a088..648e01c 100644 --- a/api/public_test.go +++ b/api/public_test.go @@ -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) { @@ -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) + } + }) +} diff --git a/config.example.yml b/config.example.yml index 2bd51b3..16479f6 100644 --- a/config.example.yml +++ b/config.example.yml @@ -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 diff --git a/config/config.go b/config/config.go index ddbf28d..f30d590 100644 --- a/config/config.go +++ b/config/config.go @@ -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"` @@ -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"` diff --git a/main.go b/main.go index cecde84..80d265a 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,14 @@ func main() { listSvc := service.NewListService(repo, repo) mailSvc := service.NewMailService(repo, repo, rndr, sender) - publicHandler := api.NewPublicHandler(subscriptionSvc, logger) + publicHandler := api.NewPublicHandler(subscriptionSvc, api.RedirectPages{ + SubscribeSuccess: cfg.Redirects.SubscribeSuccess, + SubscribeError: cfg.Redirects.SubscribeError, + ConfirmSuccess: cfg.Redirects.ConfirmSuccess, + ConfirmError: cfg.Redirects.ConfirmError, + UnsubscribeSuccess: cfg.Redirects.UnsubscribeSuccess, + UnsubscribeError: cfg.Redirects.UnsubscribeError, + }, logger) var publicKey ed25519.PublicKey if cfg.Auth.PublicKeyPath != "" { From e483891623b4a469770090cdc294a61fb81c0607 Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Tue, 14 Apr 2026 13:05:56 +0200 Subject: [PATCH 2/2] documentation starting point --- README.md | 2 +- API.md => docs/API.md | 36 +++++----- docs/CONFIG.md | 138 ++++++++++++++++++++++++++++++++++++ docs/SETUP.md | 158 ++++++++++++++++++++++++++++++++++++++++++ docs/USAGE.md | 143 ++++++++++++++++++++++++++++++++++++++ main.go | 2 +- static/confirm.md | 2 +- 7 files changed, 461 insertions(+), 20 deletions(-) rename API.md => docs/API.md (87%) create mode 100644 docs/CONFIG.md create mode 100644 docs/SETUP.md create mode 100644 docs/USAGE.md diff --git a/README.md b/README.md index 11c624f..92e25ff 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/API.md b/docs/API.md similarity index 87% rename from API.md rename to docs/API.md index 20dd3f0..96df7d9 100644 --- a/API.md +++ b/docs/API.md @@ -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 | |---------|--------|----------| @@ -26,6 +26,8 @@ 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}` @@ -33,7 +35,7 @@ Subscribe a user to the named mailing list. Triggers a double opt-in confirmatio Complete double opt-in using the token from the confirmation email. **Path params** -- `token` — 64-char hex token +- `token` - 64-char hex token **Responses** @@ -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** @@ -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 | |--------|--------|----------| @@ -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** @@ -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 | |--------|--------|----------| @@ -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** @@ -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** @@ -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 | |--------|--------|----------|-----------------------------------------------| @@ -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 | |-------------------|--------|----------|-------------------------| @@ -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 | diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..1c59dd2 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,138 @@ +# Configuration Reference + +Configuration is loaded in two passes: + +1. Environment variables are read first. +2. The YAML file at `CONFIG_PATH` (default: `config.yml`) is then parsed and merged on top. + +YAML takes precedence over environment variables for every field that has both a `yaml:` tag and an `env:` tag. The config file path itself can only be set via `CONFIG_PATH`. If the file is not found, the server starts with environment-variable values only. + +Path values (template, theme, confirm-mail) accept either a local filesystem path or an `http(s)://` URL; the server fetches remote resources at startup. + +--- + +## Top-level + +| YAML key | Environment variable | Default | Description | +|----------------|----------------------|---------------------------|--------------------------------------------------------------| +| `public-addr` | `PUBLIC_ADDR` | `:8080` | Listen address for the public API (subscribe/confirm/unsub) | +| `private-addr` | `PRIVATE_ADDR` | `:9000` | Listen address for the management API | +| `base-url` | `BASE_URL` | `http://localhost:8080` | Public base URL; used to build confirmation links in emails | + +--- + +## `smtp` + +| YAML key | Environment variable | Default | Description | +|----------------|-----------------------|----------------------|----------------------------------------------------------| +| `host` | `SMTP_HOST` | - | SMTP server hostname | +| `port` | `SMTP_PORT` | `587` | SMTP server port | +| `username` | `SMTP_USERNAME` | - | SMTP authentication username | +| `password` | `SMTP_PASSWORD` | - | SMTP authentication password | +| `sender-email` | `SMTP_SENDER_EMAIL` | - | From address used for all outgoing mail | +| `tls-policy` | `SMTP_TLS_POLICY` | `TLSOpportunistic` | One of `TLSMandatory`, `TLSOpportunistic`, or `NoTLS` | + +--- + +## `db` + +| YAML key | Environment variable | Default | Description | +|----------|----------------------|-----------------|--------------------------------------------------------------| +| `type` | `DB_TYPE` | `sqlite` | Database driver: `sqlite` or `postgres` | +| `dsn` | `DB_DSN` | `5000mails.db` | SQLite file path, or a Postgres connection string | + +**Postgres DSN example** + +``` +host=db port=5432 user=mails password=secret dbname=mails sslmode=disable +``` + +--- + +## `auth` + +| YAML key | Environment variable | Default | Description | +|--------------------|------------------------|---------|-------------------------------------------------------------------------------------------| +| `public-key-path` | `AUTH_PUBLIC_KEY_PATH` | - | Path to an Ed25519 public key. Leave empty to disable request signing on the management API. | + +Generate a key pair with the CLI: + +```sh +5kmcli keys generate --out-dir ~/.config/5kmcli +# writes 5kmcli.key (private) and 5kmcli.pub (public) +``` + +Set `auth.public-key-path` to the `.pub` file and pass `--private-key-path` to every CLI invocation. + +--- + +## `paths` + +All values accept a local path **or** an `http(s)://` URL. Remote resources are fetched once at startup. + +| YAML key | Environment variable | Default (remote) | Description | +|----------------|------------------------|---------------------------------------------------------------------------------------|-------------------------------------------------------------| +| `template` | `TEMPLATE_PATH` | `https://github.com/5000K/5000mails/releases/latest/download/template.html` | HTML wrapper rendered around every markdown newsletter | +| `theme` | `THEME_PATH` | `https://github.com/5000K/5000mails/releases/latest/download/theme.example.css` | CSS injected into the HTML template | +| `confirm-mail` | `CONFIRM_MAIL_PATH` | `https://github.com/5000K/5000mails/releases/latest/download/confirm.md` | Markdown template for the double opt-in confirmation email | + +The `confirm-mail` template receives the following template variables: + +| Variable | Value | +|-------------------|----------------------------------------------| +| `ConfirmationURL` | Full URL the subscriber must visit to confirm | +| `Name` | Subscriber display name | +| `Email` | Subscriber email address | + +--- + +## `redirects` + +When set, the public API issues a `303 See Other` redirect to the configured URL instead of returning a JSON response. Leave any field blank to keep the default JSON behaviour for that outcome. + +| YAML key | Environment variable | Default | Description | +|-----------------------|-----------------------------------|---------|--------------------------------------------------------| +| `subscribe-success` | `REDIRECT_SUBSCRIBE_SUCCESS` | - | Redirect after a successful subscription request | +| `subscribe-error` | `REDIRECT_SUBSCRIBE_ERROR` | - | Redirect on a bad subscription request or server error | +| `confirm-success` | `REDIRECT_CONFIRM_SUCCESS` | - | Redirect after a token is confirmed | +| `confirm-error` | `REDIRECT_CONFIRM_ERROR` | - | Redirect on an invalid or expired confirmation token | +| `unsubscribe-success` | `REDIRECT_UNSUBSCRIBE_SUCCESS` | - | Redirect after a successful unsubscribe | +| `unsubscribe-error` | `REDIRECT_UNSUBSCRIBE_ERROR` | - | Redirect on an invalid or expired unsubscribe token | + +--- + +## Full example + +```yaml +public-addr: ":8080" +private-addr: ":9000" +base-url: "https://newsletter.yoursite.com" + +smtp: + host: "smtp.example.com" + port: 587 + username: "you@example.com" + password: "secret" + sender-email: "newsletter@yoursite.com" + tls-policy: "TLSOpportunistic" + +db: + type: "sqlite" + dsn: "5000mails.db" + +auth: + public-key-path: "/etc/5000mails/5kmcli.pub" + +paths: + template: "./static/template.html" + theme: "./static/theme.css" + confirm-mail: "./static/confirm.md" + +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" +``` diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..06ec7c5 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,158 @@ +# Setup + +## Prerequisites + +You need an SMTP server to send mail. All other dependencies (database, templates) are handled by the server itself. + +--- + +## Docker + +```sh +docker run -d \ + --name 5000mails \ + -p 8080:8080 \ + -p 9000:9000 \ + -v $(pwd)/config.yml:/config.yml \ + -v $(pwd)/data:/data \ + -e CONFIG_PATH=/config.yml \ + ghcr.io/5000k/5000mails:latest +``` + +Mount a directory for the SQLite database file or point `db.dsn` at a Postgres instance. + +--- + +## Docker Compose + +```yaml +services: + 5000mails: + image: ghcr.io/5000k/5000mails:latest + restart: unless-stopped + ports: + - "8080:8080" # public API + - "9000:9000" # management API (keep this private) + volumes: + - ./config.yml:/config.yml + - ./data:/data + environment: + CONFIG_PATH: /config.yml +``` + +Create a `config.yml` next to the compose file (see [CONFIG.md](CONFIG.md) for all options). A minimal working example: + +```yaml +base-url: "https://newsletter.yoursite.com" + +smtp: + host: "smtp.example.com" + port: 587 + username: "you@example.com" + password: "secret" + sender-email: "newsletter@yoursite.com" + +db: + type: "sqlite" + dsn: "/data/5000mails.db" +``` + +Then start: + +```sh +docker compose up -d +``` + +--- + +## Binary + +Prebuilt binaries are available for every release at: + +**** + +| Platform | Architecture | File | +|-----------------|--------------|-----------------------------------------| +| Linux | x86\_64 | `5000mails-linux-amd64` | +| Linux | AArch64 | `5000mails-linux-arm64` | +| Windows | x86\_64 | `5000mails-windows-amd64.exe` | +| Windows | AArch64 | `5000mails-windows-arm64.exe` | + +The `5kmcli` management client is published alongside the server binary. + +### Linux quickstart + +```sh +# Download the server binary +curl -Lo 5000mails https://github.com/5000K/5000mails/releases/latest/download/5000mails-linux-amd64 +chmod +x 5000mails + +# Download the CLI +curl -Lo 5kmcli https://github.com/5000K/5000mails/releases/latest/download/5kmcli-linux-amd64 +chmod +x 5kmcli + +# Create a minimal config +cat > config.yml <<'EOF' +base-url: "http://localhost:8080" + +smtp: + host: "smtp.example.com" + port: 587 + username: "you@example.com" + password: "secret" + sender-email: "newsletter@yoursite.com" +EOF + +# Run +CONFIG_PATH=config.yml ./5000mails +``` + +### Running as a systemd service + +```ini +[Unit] +Description=5000mails newsletter server +After=network.target + +[Service] +ExecStart=/usr/local/bin/5000mails +Environment=CONFIG_PATH=/etc/5000mails/config.yml +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +```sh +sudo cp 5000mails /usr/local/bin/ +sudo mkdir -p /etc/5000mails +sudo cp config.yml /etc/5000mails/ +sudo systemctl daemon-reload +sudo systemctl enable --now 5000mails +``` + +--- + +## Authentication setup + +The management API supports optional Ed25519 request signing. Generate a key pair with the CLI and configure the public key on the server. + +```sh +./5kmcli keys generate --out-dir ~/.config/5kmcli +# Writes: +# ~/.config/5kmcli/5kmcli.key (private - keep this secret) +# ~/.config/5kmcli/5kmcli.pub (public - put this on the server) +``` + +Add to `config.yml`: + +```yaml +auth: + public-key-path: "/etc/5000mails/5kmcli.pub" +``` + +All subsequent CLI invocations need `--private-key-path`: + +```sh +./5kmcli --private-key-path ~/.config/5kmcli/5kmcli.key list all +``` diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..5032114 --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,143 @@ +# Usage + +All management operations go through `5kmcli`, the command-line client for the management API. + +**Global flags** (available on every command): + +| Flag | Default | Description | +|-------------------------|---------------------------|------------------------------------------| +| `--server URL` | `http://localhost:9000` | Management API base URL | +| `--private-key-path` | - | Path to Ed25519 private key for signing | + +For brevity the examples below omit `--server` and `--private-key-path`. Add them as needed: + +```sh +5kmcli --server https://mails.yoursite.com \ + --private-key-path ~/.config/5kmcli/5kmcli.key \ + list all +``` + +--- + +## Managing mailing lists + +### Create a list + +```sh +5kmcli list create --name weekly +``` + +### List all lists + +```sh +5kmcli list all +``` + +### Get details and subscriber counts + +```sh +5kmcli list get --name weekly +``` + +### Rename a list + +```sh +5kmcli list rename --name weekly --new-name monthly +``` + +### Delete a list + +```sh +5kmcli list delete --name weekly +``` + +### View subscribers + +```sh +5kmcli list users --name weekly +``` + +--- + +## Authoring a newsletter + +Newsletters are written in Markdown. Create a file, e.g. `issue-42.md`: + +```markdown +# Issue 42 - {{.title}} + +Hello {{.name}}, + +This is the latest edition of the newsletter. + +[Read more on the blog](https://yoursite.com/posts/42) +``` + +Template variables are injected with `--data KEY=VALUE` at send time. Any key from `--data` is available in the template as `{{.KEY}}`. + +--- + +## Sending a test mail + +Always send a test before dispatching to the full list. + +```sh +5kmcli send test \ + --email you@yoursite.com \ + --name "Your Name" \ + --raw-path issue-42.md \ + --data title="April Update" \ + --data name="Friend" +``` + +The test mail is rendered and delivered immediately to the single recipient without touching any list. + +--- + +## Sending to a list + +Once the test looks good, send to all confirmed subscribers: + +```sh +5kmcli send list \ + --list weekly \ + --raw-path issue-42.md \ + --data title="April Update" \ + --data name="Friend" +``` + +Only subscribers who completed the double opt-in confirmation are included. + +--- + +## Subscribe form (HTML) + +The public API accepts both JSON and standard HTML form submissions, so you can embed a plain `
` on any page with no JavaScript required. + +Replace `https://newsletter.yoursite.com` with your actual `base-url` and `weekly` with your list name. + +```html + + + + + + + + +
+``` + +After submitting: + +- **Without redirects configured** - the server returns a JSON `202 Accepted` response. You should display a success message with JavaScript or redirect the user yourself. +- **With redirects configured** - the server issues a `303 See Other` to the URL you set in `redirects.subscribe-success` (or `redirects.subscribe-error` on failure), so the browser lands on your custom page automatically. + +See [CONFIG.md](CONFIG.md#redirects) for redirect configuration. + +The confirmation email is sent automatically. The subscriber clicks the link in that email, which hits `GET /confirm/{token}`, and their subscription becomes active. + +Every newsletter automatically includes an unsubscribe link that hits `GET /unsubscribe/{token}`. diff --git a/main.go b/main.go index 80d265a..de23ad1 100644 --- a/main.go +++ b/main.go @@ -92,7 +92,7 @@ func main() { } logger.Info("private API authentication enabled") } else { - logger.Warn("private API authentication disabled — no public key configured") + logger.Warn("private API authentication disabled - no public key configured") } privateHandler := api.NewPrivateHandler(listSvc, mailSvc, publicKey, logger) diff --git a/static/confirm.md b/static/confirm.md index b20beff..67715b3 100644 --- a/static/confirm.md +++ b/static/confirm.md @@ -8,7 +8,7 @@ Thanks for signing up! Click the button below to confirm your email address and [Confirm my subscription]({{.confirmURL}}) -If you did not sign up, you can safely ignore this email — nothing will change. +If you did not sign up, you can safely ignore this email - nothing will change. ---