diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5fe4aac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g. v1.2.3)" + required: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build binaries + run: | + mkdir dist + platforms=("linux/amd64" "linux/arm64" "windows/amd64" "windows/arm64") + for platform in "${platforms[@]}"; do + GOOS="${platform%/*}" + GOARCH="${platform#*/}" + ext="" + [ "$GOOS" = "windows" ] && ext=".exe" + GOOS=$GOOS GOARCH=$GOARCH go build -o "dist/5000mails-${GOOS}-${GOARCH}${ext}" . + GOOS=$GOOS GOARCH=$GOARCH go build -o "dist/5kmcli-${GOOS}-${GOARCH}${ext}" ./cmd/cli + done + + - name: Tag and publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + git tag "${{ inputs.version }}" + git push origin "${{ inputs.version }}" + gh release create "${{ inputs.version }}" \ + --title "${{ inputs.version }}" \ + --generate-notes \ + dist/* \ + static/confirm.md \ + static/template.html \ + static/theme.example.css + + docker: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Lowercase image name + run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bb04f47 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5abcea6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ + +# Files marked local +*.local.* + +# development artifacts +5000mails.db +5kmcli \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..20dd3f0 --- /dev/null +++ b/API.md @@ -0,0 +1,243 @@ +# Public API + +All responses: `Content-Type: application/json` + +--- + +## POST `/{listName}/subscribe` + +Subscribe a user to the named mailing list. Triggers a double opt-in confirmation email. + +**Path params** +- `listName` — name of the mailing list + +**Body** — `application/json` or `application/x-www-form-urlencoded` + +| Field | Type | Required | +|---------|--------|----------| +| `name` | string | yes | +| `email` | string | yes | + +**Responses** + +| Status | Meaning | +|--------|------------------------| +| `202` | Confirmation email sent | +| `400` | Missing/invalid fields | +| `500` | Internal error | + +--- + +## GET `/confirm/{token}` + +Complete double opt-in using the token from the confirmation email. + +**Path params** +- `token` — 64-char hex token + +**Responses** + +| Status | Meaning | +|--------|----------------------------------| +| `200` | Subscription confirmed | +| `400` | Token invalid or already used | + +--- + +## GET `/unsubscribe/{token}` + +Remove a subscriber using their per-subscription unsubscribe token (included in every newsletter). + +**Path params** +- `token` — 64-char hex unsubscribe token (unique per subscription) + +**Responses** + +| Status | Meaning | +|--------|----------------------------------| +| `200` | Unsubscribed | +| `400` | Token invalid or not found | + +--- + +# Management API + +Grants full access to sending mails and managing lists. A cli client is provided. Third-party frontends should be reasonably easy to set up by using the following documentation. + +All management endpoints require Ed25519 request signing when a public key is configured on the server. +This API should not be exposed publicly, even if it is authenticated. Prefer some kind of private tunneling/VPN, or using it right on the machine the server runs on via ssh. +The request signing is intended to be additional hardening, not the main security measure. + +**Required headers** + +| Header | Value | +|---------------|----------------------------------------------------------------------------| +| `X-Timestamp` | Unix timestamp (seconds) of the request | +| `X-Signature` | Hex-encoded Ed25519 signature over `timestamp\nMETHOD\npath\nbodyHash` | + +The signed message is: `\n\n\n` + +Requests whose timestamp differs from the server's clock by more than 5 minutes are rejected. + +--- + +## POST `/lists` + +Create a new mailing list. + +**Body** — `application/json` + +| Field | Type | Required | +|--------|--------|----------| +| `name` | string | yes | + +**Responses** + +| Status | Meaning | +|--------|----------------------| +| `201` | List created | +| `400` | Missing/invalid name | +| `500` | Internal error | + +**Response body** +```json +{ "id": 1, "name": "my-list" } +``` + +--- + +## GET `/lists/{id}` + +Get list details including subscriber counts. + +**Path params** +- `id` — numeric list ID + +**Responses** + +| Status | Meaning | +|--------|----------------| +| `200` | List details | +| `400` | Invalid ID | +| `404` | List not found | + +**Response body** +```json +{ + "id": 1, + "name": "my-list", + "subscribers": { "total": 42, "confirmed": 38 } +} +``` + +--- + +## PUT `/lists/{id}` + +Rename a mailing list. + +**Path params** +- `id` — numeric list ID + +**Body** — `application/json` + +| Field | Type | Required | +|--------|--------|----------| +| `name` | string | yes | + +**Responses** + +| Status | Meaning | +|--------|----------------------| +| `200` | Renamed list | +| `400` | Missing/invalid name | +| `500` | Internal error | + +--- + +## DELETE `/lists/{id}` + +Delete a mailing list and all its subscribers. + +**Path params** +- `id` — numeric list ID + +**Responses** + +| Status | Meaning | +|--------|----------------| +| `204` | Deleted | +| `400` | Invalid ID | +| `500` | Internal error | + +--- + +## GET `/lists/{id}/users` + +List all subscribers of a mailing list. + +**Path params** +- `id` — numeric list ID + +**Responses** + +| Status | Meaning | +|--------|-----------------| +| `200` | Subscriber list | +| `400` | Invalid ID | +| `500` | Internal error | + +**Response body** +```json +[ + { "id": 1, "name": "Alice", "email": "alice@example.com", "confirmed": true }, + { "id": 2, "name": "Bob", "email": "bob@example.com", "confirmed": false } +] +``` + +--- + +## POST `/lists/{name}/send` + +Render a markdown newsletter and send it to all confirmed subscribers of the named list. + +**Path params** +- `name` — list name + +**Body** — `application/json` + +| Field | Type | Required | Description | +|--------|--------|----------|-----------------------------------------------| +| `raw` | string | yes | Raw markdown content of the mail | +| `data` | object | no | Template variables injected into the markdown | + +**Responses** + +| Status | Meaning | +|--------|-----------------| +| `200` | Mail dispatched | +| `400` | Missing `raw` | +| `500` | Internal error | + +--- + +## POST `/mail/test` + +Send a rendered test mail to a single recipient without touching any list. + +**Body** — `application/json` + +| Field | Type | Required | Description | +|-------------------|--------|----------|-------------------------| +| `recipient.name` | string | no | Recipient display name | +| `recipient.email` | string | yes | Recipient email address | +| `raw` | string | yes | Raw markdown content | +| `data` | object | no | Template variables | + +**Responses** + +| Status | Meaning | +|--------|-----------------------------------| +| `200` | Test mail sent | +| `400` | Missing `recipient.email` or `raw` | +| `500` | Internal error | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..645050d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:latest AS builder + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -o /5000mails . + +FROM alpine:latest + +COPY --from=builder /5000mails /5000mails + +WORKDIR / + +EXPOSE 8080 +ENTRYPOINT ["/5000mails"] \ No newline at end of file diff --git a/README.md b/README.md index 0fc00ee..8ca0bf2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ # 5000mails -A markdown-oriented newsletter server. Signups, double opt-in, test-mails, markdown-based newsletters. \ No newline at end of file + +A markdown-oriented newsletter server. Signups, double opt-in, test-mails, markdown-based newsletters. + +Uses the same base conventions as [5000blogs](https://github.com/5000K/5000blogs) for rendering and templating, so that the same markdown-practices can be reused for your newsletter as well. + +## CLI + +`5kmcli` is the command-line client for the private API. Build it with: + +```sh +go build -o 5kmcli ./cmd/cli +``` + +**Global flags** + +| Flag | Default | Description | +| -------------------- | ----------------------- | --------------------------------------- | +| `--server URL` | `http://localhost:9000` | Server base URL | +| `--private-key-path` | — | Path to Ed25519 private key for signing | + +**Commands** + +```sh +# Mailing lists +5kmcli list create --name NAME +5kmcli list get --id ID +5kmcli list rename --id ID --name NAME +5kmcli list delete --id ID +5kmcli list users --id ID + +# Send newsletters +5kmcli send list --list NAME --raw-path PATH [--data KEY=VALUE ...] +5kmcli send test --email EMAIL --raw-path PATH [--name NAME] [--data KEY=VALUE ...] + +# Key management +5kmcli keys generate [--out-dir DIR] +``` + +`--data` can be repeated to inject multiple template variables, e.g. `--data title=Hello --data month=April`. + +**Authentication** + +Generate a key pair and configure the public key on the server: + +```sh +5kmcli keys generate --out-dir ~/.config/5kmcli +``` + +Pass `--private-key-path ~/.config/5kmcli/5kmcli.key` on every subsequent call to sign requests automatically. diff --git a/api/private.go b/api/private.go new file mode 100644 index 0000000..8f7049b --- /dev/null +++ b/api/private.go @@ -0,0 +1,315 @@ +package api + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/5000K/5000mails/domain" +) + +// ListManager is the private API's view of the list service. +type ListManager interface { + Create(ctx context.Context, name string) (*domain.MailingList, error) + Get(ctx context.Context, id uint) (*domain.MailingList, error) + Rename(ctx context.Context, id uint, newName string) (*domain.MailingList, error) + Delete(ctx context.Context, id uint) error + CountUsers(ctx context.Context, listID uint) (domain.UserCounts, error) + Users(ctx context.Context, listID uint) ([]domain.User, error) +} + +// MailDispatcher is the private API's view of the mail service. +type MailDispatcher interface { + SendToList(ctx context.Context, listName string, raw string, data map[string]any) error + SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error +} + +// PrivateHandler serves the private admin API. +// When publicKey is non-nil, every request must carry a valid Ed25519 signature. +type PrivateHandler struct { + lists ListManager + mail MailDispatcher + publicKey ed25519.PublicKey + logger *slog.Logger +} + +// NewPrivateHandler creates a new PrivateHandler. +// Pass a nil publicKey to disable request authentication. +func NewPrivateHandler(lists ListManager, mail MailDispatcher, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler { + return &PrivateHandler{lists: lists, mail: mail, publicKey: publicKey, logger: logger} +} + +// Routes returns the mux for all private API endpoints. +func (h *PrivateHandler) Routes() *http.ServeMux { + mux := http.NewServeMux() + mux.Handle("POST /lists", h.auth(h.handleCreateList)) + mux.Handle("GET /lists/{id}", h.auth(h.handleGetList)) + mux.Handle("PUT /lists/{id}", h.auth(h.handleRenameList)) + mux.Handle("DELETE /lists/{id}", h.auth(h.handleDeleteList)) + mux.Handle("GET /lists/{id}/users", h.auth(h.handleListUsers)) + mux.Handle("POST /lists/{name}/send", h.auth(h.handleSendToList)) + mux.Handle("POST /mail/test", h.auth(h.handleSendTestMail)) + return mux +} + +// --- request/response types --- + +type listResponse struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +type listDetailResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Subscribers struct { + Total int `json:"total"` + Confirmed int `json:"confirmed"` + } `json:"subscribers"` +} + +type userResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Confirmed bool `json:"confirmed"` +} + +type sendRequest struct { + Raw string `json:"raw"` + Data map[string]any `json:"data"` +} + +type testMailRequest struct { + Recipient struct { + Name string `json:"name"` + Email string `json:"email"` + } `json:"recipient"` + Raw string `json:"raw"` + Data map[string]any `json:"data"` +} + +// --- handlers --- + +func (h *PrivateHandler) handleCreateList(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + list, err := h.lists.Create(r.Context(), body.Name) + if err != nil { + h.logger.ErrorContext(r.Context(), "create list failed", slog.String("name", body.Name), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to create list") + return + } + + writeJSON(w, http.StatusCreated, listResponse{ID: list.ID, Name: list.Name}) +} + +func (h *PrivateHandler) handleGetList(w http.ResponseWriter, r *http.Request) { + id, ok := parseUintPath(w, r, "id") + if !ok { + return + } + + list, err := h.lists.Get(r.Context(), id) + if err != nil { + h.logger.ErrorContext(r.Context(), "get list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + writeError(w, http.StatusNotFound, "list not found") + return + } + + counts, err := h.lists.CountUsers(r.Context(), id) + if err != nil { + h.logger.ErrorContext(r.Context(), "count users failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load list stats") + return + } + + resp := listDetailResponse{ID: list.ID, Name: list.Name} + resp.Subscribers.Total = counts.Total + resp.Subscribers.Confirmed = counts.Confirmed + writeJSON(w, http.StatusOK, resp) +} + +func (h *PrivateHandler) handleRenameList(w http.ResponseWriter, r *http.Request) { + id, ok := parseUintPath(w, r, "id") + if !ok { + return + } + + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + list, err := h.lists.Rename(r.Context(), id, body.Name) + if err != nil { + h.logger.ErrorContext(r.Context(), "rename list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to rename list") + return + } + + writeJSON(w, http.StatusOK, listResponse{ID: list.ID, Name: list.Name}) +} + +func (h *PrivateHandler) handleDeleteList(w http.ResponseWriter, r *http.Request) { + id, ok := parseUintPath(w, r, "id") + if !ok { + return + } + + if err := h.lists.Delete(r.Context(), id); err != nil { + h.logger.ErrorContext(r.Context(), "delete list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to delete list") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *PrivateHandler) handleListUsers(w http.ResponseWriter, r *http.Request) { + id, ok := parseUintPath(w, r, "id") + if !ok { + return + } + + users, err := h.lists.Users(r.Context(), id) + if err != nil { + h.logger.ErrorContext(r.Context(), "list users failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load users") + return + } + + resp := make([]userResponse, len(users)) + for i, u := range users { + resp[i] = userResponse{ID: u.ID, Name: u.Name, Email: u.Email, Confirmed: u.IsConfirmed()} + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *PrivateHandler) handleSendToList(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + var body sendRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Raw == "" { + writeError(w, http.StatusBadRequest, "raw is required") + return + } + + if err := h.mail.SendToList(r.Context(), name, body.Raw, body.Data); err != nil { + h.logger.ErrorContext(r.Context(), "send to list failed", slog.String("list", name), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to send mail") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "mail dispatched"}) +} + +func (h *PrivateHandler) handleSendTestMail(w http.ResponseWriter, r *http.Request) { + var body testMailRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Recipient.Email == "" || body.Raw == "" { + writeError(w, http.StatusBadRequest, "recipient.email and raw are required") + return + } + + recipient := domain.User{Name: body.Recipient.Name, Email: body.Recipient.Email} + if err := h.mail.SendTestMail(r.Context(), recipient, body.Raw, body.Data); err != nil { + h.logger.ErrorContext(r.Context(), "send test mail failed", slog.String("email", body.Recipient.Email), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to send test mail") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "test mail sent"}) +} + +// --- auth middleware --- + +const signatureWindow = 5 * time.Minute + +// auth wraps a handler with Ed25519 signature verification when a public key +// is configured. Without a public key the handler is passed through unchanged. +func (h *PrivateHandler) auth(next http.HandlerFunc) http.Handler { + if len(h.publicKey) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := h.verifySignature(r); err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + next(w, r) + }) +} + +func (h *PrivateHandler) verifySignature(r *http.Request) error { + tsStr := r.Header.Get("X-Timestamp") + sigHex := r.Header.Get("X-Signature") + if tsStr == "" || sigHex == "" { + return fmt.Errorf("missing X-Timestamp or X-Signature header") + } + + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid X-Timestamp") + } + age := time.Since(time.Unix(ts, 0)) + if age < -signatureWindow || age > signatureWindow { + return fmt.Errorf("request timestamp out of acceptable window") + } + + sig, err := hex.DecodeString(sigHex) + if err != nil { + return fmt.Errorf("invalid X-Signature encoding") + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("reading request body: %w", err) + } + r.Body = io.NopCloser(strings.NewReader(string(body))) + + msg := buildSignedMessage(tsStr, r.Method, r.URL.Path, body) + if !ed25519.Verify(h.publicKey, msg, sig) { + return fmt.Errorf("invalid signature") + } + return nil +} + +func buildSignedMessage(timestamp, method, path string, body []byte) []byte { + sum := sha256.Sum256(body) + bodyHash := hex.EncodeToString(sum[:]) + return []byte(timestamp + "\n" + method + "\n" + path + "\n" + bodyHash) +} + +// --- helpers --- + +func parseUintPath(w http.ResponseWriter, r *http.Request, key string) (uint, bool) { + raw := r.PathValue(key) + n, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid %s", key)) + return 0, false + } + return uint(n), true +} diff --git a/api/private_client.go b/api/private_client.go new file mode 100644 index 0000000..82c4f2b --- /dev/null +++ b/api/private_client.go @@ -0,0 +1,223 @@ +package api + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// PrivateClient is an HTTP client for the private admin API. +// Supply a non-nil privateKey to have every request signed automatically. +type PrivateClient struct { + baseURL string + privateKey ed25519.PrivateKey + httpClient *http.Client +} + +// NewPrivateClient creates a new PrivateClient. +// Pass a nil privateKey when the server has authentication disabled. +func NewPrivateClient(baseURL string, privateKey ed25519.PrivateKey) *PrivateClient { + return &PrivateClient{ + baseURL: baseURL, + privateKey: privateKey, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ListResponse is returned by list creation and rename endpoints. +type ListResponse struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +// ListDetailResponse is returned by the get-list endpoint. +type ListDetailResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Subscribers struct { + Total int `json:"total"` + Confirmed int `json:"confirmed"` + } `json:"subscribers"` +} + +// UserItem describes a single subscriber as returned by the get-users endpoint. +type UserItem struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Confirmed bool `json:"confirmed"` +} + +// RecipientInput is the test-mail recipient payload. +type RecipientInput struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// CreateList creates a new mailing list. +func (c *PrivateClient) CreateList(ctx context.Context, name string) (*ListResponse, error) { + resp, err := c.do(ctx, http.MethodPost, "/lists", map[string]string{"name": name}) + if err != nil { + return nil, fmt.Errorf("create list: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusCreated); err != nil { + return nil, fmt.Errorf("create list: %w", err) + } + + var out ListResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("create list: decode response: %w", err) + } + return &out, nil +} + +// GetList returns the mailing list with the given id along with subscriber stats. +func (c *PrivateClient) GetList(ctx context.Context, id uint) (*ListDetailResponse, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%d", id), nil) + if err != nil { + return nil, fmt.Errorf("get list: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("get list: %w", err) + } + + var out ListDetailResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("get list: decode response: %w", err) + } + return &out, nil +} + +// RenameList renames the mailing list with the given id. +func (c *PrivateClient) RenameList(ctx context.Context, id uint, name string) (*ListResponse, error) { + resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/lists/%d", id), map[string]string{"name": name}) + if err != nil { + return nil, fmt.Errorf("rename list: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("rename list: %w", err) + } + + var out ListResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("rename list: decode response: %w", err) + } + return &out, nil +} + +// DeleteList deletes the mailing list with the given id. +func (c *PrivateClient) DeleteList(ctx context.Context, id uint) error { + resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/lists/%d", id), nil) + if err != nil { + return fmt.Errorf("delete list: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusNoContent); err != nil { + return fmt.Errorf("delete list: %w", err) + } + return nil +} + +// GetUsers returns all subscribers (confirmed or not) for the given list id. +func (c *PrivateClient) GetUsers(ctx context.Context, listID uint) ([]UserItem, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%d/users", listID), nil) + if err != nil { + return nil, fmt.Errorf("get users: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("get users: %w", err) + } + + var out []UserItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("get users: decode response: %w", err) + } + return out, nil +} + +// SendToList dispatches a rendered markdown mail to all confirmed subscribers +// of the named list. +func (c *PrivateClient) SendToList(ctx context.Context, listName string, raw string, data map[string]any) error { + resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/lists/%s/send", listName), map[string]any{"raw": raw, "data": data}) + if err != nil { + return fmt.Errorf("send to list: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return fmt.Errorf("send to list: %w", err) + } + return nil +} + +// SendTestMail dispatches a rendered markdown mail to a single arbitrary recipient. +func (c *PrivateClient) SendTestMail(ctx context.Context, recipient RecipientInput, raw string, data map[string]any) error { + payload := map[string]any{ + "recipient": recipient, + "raw": raw, + "data": data, + } + resp, err := c.do(ctx, http.MethodPost, "/mail/test", payload) + if err != nil { + return fmt.Errorf("send test mail: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return fmt.Errorf("send test mail: %w", err) + } + return nil +} + +// do builds and executes a signed HTTP request. +func (c *PrivateClient) do(ctx context.Context, method, path string, payload any) (*http.Response, error) { + var bodyBytes []byte + if payload != nil { + var err error + bodyBytes, err = json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + if len(c.privateKey) > 0 { + ts := strconv.FormatInt(time.Now().Unix(), 10) + msg := buildSignedMessage(ts, method, path, bodyBytes) + sig := ed25519.Sign(c.privateKey, msg) + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Signature", hex.EncodeToString(sig)) + } + + return c.httpClient.Do(req) +} + +func expectStatus(resp *http.Response, expected int) error { + if resp.StatusCode == expected { + return nil + } + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) +} diff --git a/api/private_test.go b/api/private_test.go new file mode 100644 index 0000000..0715aeb --- /dev/null +++ b/api/private_test.go @@ -0,0 +1,616 @@ +package api + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/5000K/5000mails/domain" +) + +// --- fakes --- + +type fakeListManager struct { + lists map[uint]*domain.MailingList + nextID uint + users []*domain.User + + createErr error + getErr error + renameErr error + deleteErr error + countUsersErr error + usersErr error +} + +func newFakeListManager(lists ...*domain.MailingList) *fakeListManager { + m := &fakeListManager{lists: make(map[uint]*domain.MailingList), nextID: 1} + for _, l := range lists { + m.lists[l.ID] = l + if l.ID >= m.nextID { + m.nextID = l.ID + 1 + } + } + return m +} + +func (f *fakeListManager) Create(_ context.Context, name string) (*domain.MailingList, error) { + if f.createErr != nil { + return nil, f.createErr + } + l := &domain.MailingList{ID: f.nextID, Name: name} + f.nextID++ + f.lists[l.ID] = l + return l, nil +} + +func (f *fakeListManager) Get(_ context.Context, id uint) (*domain.MailingList, error) { + if f.getErr != nil { + return nil, f.getErr + } + l, ok := f.lists[id] + if !ok { + return nil, fmt.Errorf("list %d not found", id) + } + return l, nil +} + +func (f *fakeListManager) Rename(_ context.Context, id uint, name string) (*domain.MailingList, error) { + if f.renameErr != nil { + return nil, f.renameErr + } + l, ok := f.lists[id] + if !ok { + return nil, fmt.Errorf("list %d not found", id) + } + l.Name = name + return l, nil +} + +func (f *fakeListManager) Delete(_ context.Context, id uint) error { + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.lists[id]; !ok { + return fmt.Errorf("list %d not found", id) + } + delete(f.lists, id) + return nil +} + +func (f *fakeListManager) CountUsers(_ context.Context, listID uint) (domain.UserCounts, error) { + if f.countUsersErr != nil { + return domain.UserCounts{}, f.countUsersErr + } + var total, confirmed int + for _, u := range f.users { + if u.MailingListID == listID { + total++ + if u.IsConfirmed() { + confirmed++ + } + } + } + return domain.UserCounts{Total: total, Confirmed: confirmed}, nil +} + +func (f *fakeListManager) Users(_ context.Context, listID uint) ([]domain.User, error) { + if f.usersErr != nil { + return nil, f.usersErr + } + var out []domain.User + for _, u := range f.users { + if u.MailingListID == listID { + out = append(out, *u) + } + } + return out, nil +} + +type fakeMailDispatcher struct { + sendToListErr error + sendTestMailErr error + + lastListName string + lastRaw string + lastRecipient domain.User +} + +func (f *fakeMailDispatcher) SendToList(_ context.Context, listName, raw string, _ map[string]any) error { + f.lastListName = listName + f.lastRaw = raw + return f.sendToListErr +} + +func (f *fakeMailDispatcher) SendTestMail(_ context.Context, recipient domain.User, raw string, _ map[string]any) error { + f.lastRecipient = recipient + f.lastRaw = raw + return f.sendTestMailErr +} + +// --- helpers --- + +func newPrivateTestHandler(lists *fakeListManager, mail *fakeMailDispatcher, pub ed25519.PublicKey) *PrivateHandler { + return NewPrivateHandler(lists, mail, pub, slog.Default()) +} + +func privateRequest(t *testing.T, h *PrivateHandler, method, target string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, target, &buf) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + return w +} + +func signedPrivateRequest(t *testing.T, h *PrivateHandler, priv ed25519.PrivateKey, method, target string, body any) *httptest.ResponseRecorder { + t.Helper() + var bodyBytes []byte + if body != nil { + var err error + bodyBytes, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, target, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + ts := fmt.Sprintf("%d", time.Now().Unix()) + msg := buildSignedMessage(ts, method, req.URL.Path, bodyBytes) + sig := ed25519.Sign(priv, msg) + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Signature", hex.EncodeToString(sig)) + + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + return w +} + +func decodeJSON(t *testing.T, w *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.NewDecoder(w.Body).Decode(v); err != nil { + t.Fatalf("decode response: %v (body: %s)", err, w.Body.String()) + } +} + +// --- list tests --- + +func TestPrivateHandler_CreateList(t *testing.T) { + t.Run("returns 201 with created list", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) + w := privateRequest(t, h, http.MethodPost, "/lists", map[string]string{"name": "weekly"}) + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + var resp listResponse + decodeJSON(t, w, &resp) + if resp.Name != "weekly" { + t.Errorf("expected name %q, got %q", "weekly", resp.Name) + } + }) + + t.Run("returns 400 when name is missing", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) + w := privateRequest(t, h, http.MethodPost, "/lists", map[string]string{}) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + m := newFakeListManager() + m.createErr = errors.New("db failure") + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + w := privateRequest(t, h, http.MethodPost, "/lists", map[string]string{"name": "weekly"}) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_GetList(t *testing.T) { + now := time.Now() + list := &domain.MailingList{ID: 1, Name: "weekly"} + + t.Run("returns list with stats", func(t *testing.T) { + m := newFakeListManager(list) + m.users = []*domain.User{ + {ID: 1, MailingListID: 1, Email: "a@test.com", ConfirmedAt: &now}, + {ID: 2, MailingListID: 1, Email: "b@test.com"}, + } + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodGet, "/lists/1", nil) + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleGetList(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp listDetailResponse + decodeJSON(t, w, &resp) + if resp.ID != 1 || resp.Name != "weekly" { + t.Errorf("unexpected list: %+v", resp) + } + if resp.Subscribers.Total != 2 || resp.Subscribers.Confirmed != 1 { + t.Errorf("unexpected counts: total=%d confirmed=%d", resp.Subscribers.Total, resp.Subscribers.Confirmed) + } + }) + + t.Run("returns 404 when list not found", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodGet, "/lists/99", nil) + req.SetPathValue("id", "99") + w := httptest.NewRecorder() + h.handleGetList(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_RenameList(t *testing.T) { + t.Run("returns updated list", func(t *testing.T) { + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "old"}) + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodPut, "/lists/1", jsonBody(t, map[string]string{"name": "new"})) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleRenameList(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp listResponse + decodeJSON(t, w, &resp) + if resp.Name != "new" { + t.Errorf("expected name %q, got %q", "new", resp.Name) + } + }) + + t.Run("returns 400 when name missing", func(t *testing.T) { + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "old"}) + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodPut, "/lists/1", jsonBody(t, map[string]string{})) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleRenameList(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_DeleteList(t *testing.T) { + t.Run("returns 204", func(t *testing.T) { + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "bye"}) + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodDelete, "/lists/1", nil) + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleDeleteList(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204, got %d", w.Code) + } + if _, exists := m.lists[1]; exists { + t.Error("list should have been deleted") + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "bye"}) + m.deleteErr = errors.New("db down") + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodDelete, "/lists/1", nil) + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleDeleteList(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_ListUsers(t *testing.T) { + now := time.Now() + list := &domain.MailingList{ID: 1, Name: "weekly"} + + t.Run("returns all users with confirmed flag", func(t *testing.T) { + m := newFakeListManager(list) + m.users = []*domain.User{ + {ID: 1, MailingListID: 1, Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, + {ID: 2, MailingListID: 1, Name: "Bob", Email: "b@test.com"}, + } + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodGet, "/lists/1/users", nil) + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleListUsers(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp []userResponse + decodeJSON(t, w, &resp) + if len(resp) != 2 { + t.Fatalf("expected 2 users, got %d", len(resp)) + } + if !resp[0].Confirmed { + t.Error("expected first user to be confirmed") + } + if resp[1].Confirmed { + t.Error("expected second user to be unconfirmed") + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + m := newFakeListManager(list) + m.usersErr = errors.New("db down") + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodGet, "/lists/1/users", nil) + req.SetPathValue("id", "1") + w := httptest.NewRecorder() + h.handleListUsers(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_SendToList(t *testing.T) { + t.Run("dispatches mail and returns 200", func(t *testing.T) { + mail := &fakeMailDispatcher{} + h := newPrivateTestHandler(newFakeListManager(), mail, nil) + req := httptest.NewRequest(http.MethodPost, "/lists/weekly/send", jsonBody(t, map[string]any{"raw": "# Hello"})) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "weekly") + w := httptest.NewRecorder() + h.handleSendToList(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if mail.lastListName != "weekly" { + t.Errorf("expected list %q, got %q", "weekly", mail.lastListName) + } + if mail.lastRaw != "# Hello" { + t.Errorf("unexpected raw: %q", mail.lastRaw) + } + }) + + t.Run("returns 400 when raw is missing", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodPost, "/lists/weekly/send", jsonBody(t, map[string]any{})) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "weekly") + w := httptest.NewRecorder() + h.handleSendToList(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + mail := &fakeMailDispatcher{sendToListErr: errors.New("smtp down")} + h := newPrivateTestHandler(newFakeListManager(), mail, nil) + req := httptest.NewRequest(http.MethodPost, "/lists/weekly/send", jsonBody(t, map[string]any{"raw": "# Hello"})) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("name", "weekly") + w := httptest.NewRecorder() + h.handleSendToList(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_SendTestMail(t *testing.T) { + t.Run("sends test mail and returns 200", func(t *testing.T) { + mail := &fakeMailDispatcher{} + h := newPrivateTestHandler(newFakeListManager(), mail, nil) + req := httptest.NewRequest(http.MethodPost, "/mail/test", jsonBody(t, map[string]any{ + "recipient": map[string]string{"name": "Alice", "email": "alice@test.com"}, + "raw": "# Hi", + })) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSendTestMail(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if mail.lastRecipient.Email != "alice@test.com" { + t.Errorf("expected email %q, got %q", "alice@test.com", mail.lastRecipient.Email) + } + }) + + t.Run("returns 400 when recipient email is missing", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) + req := httptest.NewRequest(http.MethodPost, "/mail/test", jsonBody(t, map[string]any{"raw": "# Hi"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleSendTestMail(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + +// --- authentication tests --- + +func TestPrivateHandler_Auth(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + t.Run("rejects unsigned request when key configured", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, pub) + w := privateRequest(t, h, http.MethodPost, "/lists", map[string]string{"name": "test"}) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } + }) + + t.Run("accepts correctly signed request", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, pub) + w := signedPrivateRequest(t, h, priv, http.MethodPost, "/lists", map[string]string{"name": "signed"}) + if w.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: %s", w.Code, w.Body) + } + }) + + t.Run("rejects tampered signature", func(t *testing.T) { + _, otherPriv, _ := ed25519.GenerateKey(rand.Reader) + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, pub) + w := signedPrivateRequest(t, h, otherPriv, http.MethodPost, "/lists", map[string]string{"name": "evil"}) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } + }) + + t.Run("allows requests without key configured", func(t *testing.T) { + h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) + w := privateRequest(t, h, http.MethodPost, "/lists", map[string]string{"name": "open"}) + if w.Code != http.StatusCreated { + t.Errorf("expected 201, got %d: %s", w.Code, w.Body) + } + }) +} + +// --- client integration test --- + +func TestPrivateClient_Integration(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + now := time.Now() + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "weekly"}) + m.users = []*domain.User{ + {ID: 1, MailingListID: 1, Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, + } + mail := &fakeMailDispatcher{} + + srv := httptest.NewServer(NewPrivateHandler(m, mail, pub, slog.Default()).Routes()) + defer srv.Close() + + client := NewPrivateClient(srv.URL, priv) + ctx := context.Background() + + t.Run("CreateList", func(t *testing.T) { + resp, err := client.CreateList(ctx, "monthly") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Name != "monthly" { + t.Errorf("expected %q, got %q", "monthly", resp.Name) + } + }) + + t.Run("GetList", func(t *testing.T) { + resp, err := client.GetList(ctx, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Name != "weekly" { + t.Errorf("expected %q, got %q", "weekly", resp.Name) + } + if resp.Subscribers.Total != 1 || resp.Subscribers.Confirmed != 1 { + t.Errorf("unexpected counts: %+v", resp.Subscribers) + } + }) + + t.Run("GetUsers", func(t *testing.T) { + users, err := client.GetUsers(ctx, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 1 || users[0].Email != "a@test.com" { + t.Errorf("unexpected users: %+v", users) + } + }) + + t.Run("SendToList", func(t *testing.T) { + if err := client.SendToList(ctx, "weekly", "# Hello", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mail.lastListName != "weekly" { + t.Errorf("expected list %q, got %q", "weekly", mail.lastListName) + } + }) + + t.Run("SendTestMail", func(t *testing.T) { + if err := client.SendTestMail(ctx, RecipientInput{Name: "Bob", Email: "bob@test.com"}, "# Test", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mail.lastRecipient.Email != "bob@test.com" { + t.Errorf("expected email %q, got %q", "bob@test.com", mail.lastRecipient.Email) + } + }) + + t.Run("RenameList", func(t *testing.T) { + resp, err := client.RenameList(ctx, 1, "renamed") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Name != "renamed" { + t.Errorf("expected %q, got %q", "renamed", resp.Name) + } + }) + + t.Run("DeleteList", func(t *testing.T) { + _, _ = client.CreateList(ctx, "todelete") + var deleteID uint + for id := range m.lists { + if m.lists[id].Name == "todelete" { + deleteID = id + } + } + if err := client.DeleteList(ctx, deleteID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, exists := m.lists[deleteID]; exists { + t.Error("list should have been deleted") + } + }) + + t.Run("unauthenticated client is rejected", func(t *testing.T) { + unauthClient := NewPrivateClient(srv.URL, nil) + _, err := unauthClient.CreateList(ctx, "nope") + if err == nil { + t.Error("expected error for unsigned request") + } + }) +} + +// --- helper --- + +func jsonBody(t *testing.T, v any) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(v); err != nil { + t.Fatal(err) + } + return &buf +} diff --git a/api/public.go b/api/public.go new file mode 100644 index 0000000..9041cd1 --- /dev/null +++ b/api/public.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/5000K/5000mails/domain" +) + +type Subscriber interface { + Subscribe(ctx context.Context, listName, userName, email string) (*domain.User, error) + Confirm(ctx context.Context, token string) error + Unsubscribe(ctx context.Context, unsubscribeToken string) error +} + +type PublicHandler struct { + subscriptions Subscriber + logger *slog.Logger +} + +func NewPublicHandler(subscriptions Subscriber, logger *slog.Logger) *PublicHandler { + return &PublicHandler{subscriptions: subscriptions, logger: logger} +} + +func (h *PublicHandler) Routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("POST /{listName}/subscribe", h.handleSubscribe) + mux.HandleFunc("GET /confirm/{token}", h.handleConfirm) + mux.HandleFunc("GET /unsubscribe/{token}", h.handleUnsubscribe) + return mux +} + +func (h *PublicHandler) handleSubscribe(w http.ResponseWriter, r *http.Request) { + listName := r.PathValue("listName") + + name, email, err := parseSubscribeBody(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if _, err := h.subscriptions.Subscribe(r.Context(), listName, name, email); err != nil { + h.logger.ErrorContext(r.Context(), "subscribe failed", + slog.String("list", listName), + slog.String("email", email), + slog.Any("error", err), + ) + writeError(w, http.StatusInternalServerError, "subscription failed") + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{"message": "check your email for a confirmation link"}) +} + +func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + if err := h.subscriptions.Confirm(r.Context(), token); err != nil { + h.logger.ErrorContext(r.Context(), "confirm failed", + slog.String("token", token), + slog.Any("error", err), + ) + writeError(w, http.StatusBadRequest, "invalid or expired confirmation token") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "your subscription has been confirmed"}) +} + +func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + if err := h.subscriptions.Unsubscribe(r.Context(), token); err != nil { + h.logger.ErrorContext(r.Context(), "unsubscribe failed", + slog.String("token", token), + slog.Any("error", err), + ) + writeError(w, http.StatusBadRequest, "invalid or expired unsubscribe token") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "you have been unsubscribed"}) +} + +func parseSubscribeBody(r *http.Request) (name, email string, err error) { + if strings.Contains(r.Header.Get("Content-Type"), "application/json") { + var body struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return "", "", fmt.Errorf("invalid request body") + } + name, email = body.Name, body.Email + } else { + if err := r.ParseForm(); err != nil { + return "", "", fmt.Errorf("invalid form data") + } + name, email = r.FormValue("name"), r.FormValue("email") + } + if name == "" || email == "" { + return "", "", fmt.Errorf("name and email are required") + } + return name, email, nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/api/public_test.go b/api/public_test.go new file mode 100644 index 0000000..9c3a088 --- /dev/null +++ b/api/public_test.go @@ -0,0 +1,263 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/5000K/5000mails/domain" +) + +type fakeSubscriber struct { + subscribeErr error + confirmeErr error + unsubscribeErr error + + lastListName string + lastToken string + lastEmail string + lastUnsubToken string +} + +func (f *fakeSubscriber) Subscribe(_ context.Context, listName, _, email string) (*domain.User, error) { + f.lastListName = listName + f.lastEmail = email + if f.subscribeErr != nil { + return nil, f.subscribeErr + } + return &domain.User{ID: 1, Name: "Alice", Email: email}, nil +} + +func (f *fakeSubscriber) Confirm(_ context.Context, token string) error { + f.lastToken = token + return f.confirmeErr +} + +func (f *fakeSubscriber) Unsubscribe(_ context.Context, token string) error { + f.lastUnsubToken = token + return f.unsubscribeErr +} + +func newTestHandler(sub *fakeSubscriber) *PublicHandler { + return NewPublicHandler(sub, slog.Default()) +} + +func TestHandleSubscribe(t *testing.T) { + t.Run("returns 202 on success", func(t *testing.T) { + sub := &fakeSubscriber{} + h := newTestHandler(sub) + + 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.StatusAccepted { + t.Errorf("expected 202, got %d", w.Code) + } + if sub.lastListName != "weekly" { + t.Errorf("expected listName %q, got %q", "weekly", sub.lastListName) + } + if sub.lastEmail != "alice@example.com" { + t.Errorf("expected email %q, got %q", "alice@example.com", sub.lastEmail) + } + }) + + t.Run("returns 400 on missing fields", func(t *testing.T) { + h := newTestHandler(&fakeSubscriber{}) + + req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(`{"name":"Alice"}`)) + req.SetPathValue("listName", "weekly") + w := httptest.NewRecorder() + + h.handleSubscribe(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) + + t.Run("returns 400 on invalid JSON", func(t *testing.T) { + h := newTestHandler(&fakeSubscriber{}) + + req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(`not-json`)) + req.SetPathValue("listName", "weekly") + w := httptest.NewRecorder() + + h.handleSubscribe(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) + + t.Run("accepts form data", func(t *testing.T) { + sub := &fakeSubscriber{} + h := newTestHandler(sub) + + form := url.Values{"name": {"Alice"}, "email": {"alice@example.com"}} + req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetPathValue("listName", "weekly") + w := httptest.NewRecorder() + + h.handleSubscribe(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("expected 202, got %d", w.Code) + } + if sub.lastEmail != "alice@example.com" { + t.Errorf("expected email %q, got %q", "alice@example.com", sub.lastEmail) + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + sub := &fakeSubscriber{subscribeErr: errors.New("db down")} + h := newTestHandler(sub) + + req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(`{"name":"Alice","email":"alice@example.com"}`)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("listName", "weekly") + w := httptest.NewRecorder() + + h.handleSubscribe(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestHandleConfirm(t *testing.T) { + t.Run("returns 200 on success", func(t *testing.T) { + sub := &fakeSubscriber{} + h := newTestHandler(sub) + + req := httptest.NewRequest(http.MethodGet, "/confirm/abc123", nil) + req.SetPathValue("token", "abc123") + w := httptest.NewRecorder() + + h.handleConfirm(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if sub.lastToken != "abc123" { + t.Errorf("expected token %q, got %q", "abc123", sub.lastToken) + } + }) + + t.Run("returns 400 on invalid token", func(t *testing.T) { + sub := &fakeSubscriber{confirmeErr: errors.New("token not found")} + h := newTestHandler(sub) + + req := httptest.NewRequest(http.MethodGet, "/confirm/bad", nil) + req.SetPathValue("token", "bad") + w := httptest.NewRecorder() + + h.handleConfirm(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + +func TestHandleUnsubscribe(t *testing.T) { + t.Run("returns 200 on success", func(t *testing.T) { + sub := &fakeSubscriber{} + h := newTestHandler(sub) + + req := httptest.NewRequest(http.MethodGet, "/unsubscribe/tok123", nil) + req.SetPathValue("token", "tok123") + w := httptest.NewRecorder() + + h.handleUnsubscribe(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if sub.lastUnsubToken != "tok123" { + t.Errorf("expected token %q, got %q", "tok123", sub.lastUnsubToken) + } + }) + + t.Run("returns 400 on invalid token", func(t *testing.T) { + sub := &fakeSubscriber{unsubscribeErr: errors.New("token not found")} + h := newTestHandler(sub) + + req := httptest.NewRequest(http.MethodGet, "/unsubscribe/bad", nil) + req.SetPathValue("token", "bad") + w := httptest.NewRecorder() + + h.handleUnsubscribe(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + +func TestRoutes(t *testing.T) { + sub := &fakeSubscriber{} + h := newTestHandler(sub) + mux := h.Routes() + + t.Run("POST /{listName}/subscribe is routed", func(t *testing.T) { + form := url.Values{"name": {"Alice"}, "email": {"alice@example.com"}} + req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusAccepted { + t.Errorf("expected 202, got %d", w.Code) + } + }) + + t.Run("GET /confirm/{token} is routed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/confirm/mytoken", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + }) + + t.Run("GET /unsubscribe/{token} is routed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/unsubscribe/sometoken", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + }) + + t.Run("response has application/json content type", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/unsubscribe/sometoken", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("expected application/json, got %q", ct) + } + }) + + t.Run("response body is valid JSON", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/unsubscribe/sometoken", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Errorf("expected valid JSON response: %v", err) + } + }) +} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..70475b8 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,357 @@ +package cli + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/5000K/5000mails/api" +) + +const usageHeader = `Usage: 5kmcli [global flags] [subcommand] [flags] + +Global flags: + --server URL Server base URL (default: http://localhost:9000) + --private-key-path PATH Path to Ed25519 private key file for authentication + +Commands: + list create --name NAME Create a mailing list + list get --id ID Get list details and stats + list rename --id ID --name NAME Rename a mailing list + list delete --id ID Delete a mailing list + list users --id ID List subscribers + + send list --list NAME --raw-path PATH Send mail to all confirmed subscribers + send test --name NAME --email EMAIL Send a test mail + --raw-path PATH + + keys generate [--out-dir DIR] Generate an Ed25519 key pair + +Options for send commands: + --data KEY=VALUE Template variable (repeatable) +` + +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) < 1 { + fmt.Fprint(stderr, usageHeader) + return 1 + } + + var serverURL string + var keyPath string + remaining := parseGlobalFlags(args, &serverURL, &keyPath) + + if serverURL == "" { + serverURL = "http://localhost:9000" + } + + if len(remaining) == 0 { + fmt.Fprint(stderr, usageHeader) + return 1 + } + + command := remaining[0] + rest := remaining[1:] + + switch command { + case "list": + return runList(rest, serverURL, keyPath, stdout, stderr) + case "send": + return runSend(rest, serverURL, keyPath, stdout, stderr) + case "keys": + return runKeys(rest, stdout, stderr) + case "help", "--help", "-h": + fmt.Fprint(stdout, usageHeader) + return 0 + default: + fmt.Fprintf(stderr, "unknown command: %s\n\n%s", command, usageHeader) + return 1 + } +} + +func runList(args []string, serverURL, keyPath string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "usage: 5kmcli list [flags]") + return 1 + } + + client, err := buildClient(serverURL, keyPath) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + sub := args[0] + flags := args[1:] + + switch sub { + case "create": + return listCreate(flags, client, stdout, stderr) + case "get": + return listGet(flags, client, stdout, stderr) + case "rename": + return listRename(flags, client, stdout, stderr) + case "delete": + return listDelete(flags, client, stdout, stderr) + case "users": + return listUsers(flags, client, stdout, stderr) + default: + fmt.Fprintf(stderr, "unknown list subcommand: %s\n", sub) + return 1 + } +} + +func runSend(args []string, serverURL, keyPath string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "usage: 5kmcli send [flags]") + return 1 + } + + client, err := buildClient(serverURL, keyPath) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + sub := args[0] + flags := args[1:] + + switch sub { + case "list": + return sendList(flags, client, stdout, stderr) + case "test": + return sendTest(flags, client, stdout, stderr) + default: + fmt.Fprintf(stderr, "unknown send subcommand: %s\n", sub) + return 1 + } +} + +func runKeys(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "usage: 5kmcli keys generate [--out-dir DIR]") + return 1 + } + if args[0] != "generate" { + fmt.Fprintf(stderr, "unknown keys subcommand: %s\n", args[0]) + return 1 + } + outDir := "." + for i := 1; i < len(args)-1; i++ { + if args[i] == "--out-dir" { + outDir = args[i+1] + } + } + pub, priv, err := GenerateKeyPair() + if err != nil { + fmt.Fprintf(stderr, "error generating key pair: %v\n", err) + return 1 + } + pubPath, privPath, err := WriteKeyPair(outDir, pub, priv) + if err != nil { + fmt.Fprintf(stderr, "error writing key pair: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "private key: %s\npublic key: %s\n", privPath, pubPath) + return 0 +} + +func listCreate(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + name := flagValue(args, "--name") + if name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli list create --name NAME") + return 1 + } + resp, err := client.CreateList(context.Background(), name) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, resp) + return 0 +} + +func listGet(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + id, ok := flagUint(args, "--id") + if !ok { + fmt.Fprintln(stderr, "usage: 5kmcli list get --id ID") + return 1 + } + resp, err := client.GetList(context.Background(), id) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, resp) + return 0 +} + +func listRename(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + id, ok := flagUint(args, "--id") + name := flagValue(args, "--name") + if !ok || name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli list rename --id ID --name NAME") + return 1 + } + resp, err := client.RenameList(context.Background(), id, name) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, resp) + return 0 +} + +func listDelete(args []string, client *api.PrivateClient, _, stderr io.Writer) int { + id, ok := flagUint(args, "--id") + if !ok { + fmt.Fprintln(stderr, "usage: 5kmcli list delete --id ID") + return 1 + } + if err := client.DeleteList(context.Background(), id); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 +} + +func listUsers(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + id, ok := flagUint(args, "--id") + if !ok { + fmt.Fprintln(stderr, "usage: 5kmcli list users --id ID") + return 1 + } + resp, err := client.GetUsers(context.Background(), id) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, resp) + return 0 +} + +func sendList(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + listName := flagValue(args, "--list") + rawPath := flagValue(args, "--raw-path") + if listName == "" || rawPath == "" { + fmt.Fprintln(stderr, "usage: 5kmcli send list --list NAME --raw-path PATH [--data KEY=VALUE ...]") + return 1 + } + raw, err := os.ReadFile(rawPath) + if err != nil { + fmt.Fprintf(stderr, "error reading raw file: %v\n", err) + return 1 + } + data := collectData(args) + if err := client.SendToList(context.Background(), listName, string(raw), data); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, "mail dispatched") + return 0 +} + +func sendTest(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + email := flagValue(args, "--email") + rawPath := flagValue(args, "--raw-path") + if email == "" || rawPath == "" { + fmt.Fprintln(stderr, "usage: 5kmcli send test --email EMAIL --raw-path PATH [--name NAME] [--data KEY=VALUE ...]") + return 1 + } + name := flagValue(args, "--name") + raw, err := os.ReadFile(rawPath) + if err != nil { + fmt.Fprintf(stderr, "error reading raw file: %v\n", err) + return 1 + } + data := collectData(args) + recipient := api.RecipientInput{Name: name, Email: email} + if err := client.SendTestMail(context.Background(), recipient, string(raw), data); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, "test mail sent") + return 0 +} + +func buildClient(serverURL, keyPath string) (*api.PrivateClient, error) { + var key ed25519.PrivateKey + if keyPath != "" { + k, err := ReadPrivateKey(keyPath) + if err != nil { + return nil, fmt.Errorf("loading private key: %w", err) + } + key = k + } + return api.NewPrivateClient(serverURL, key), nil +} + +func parseGlobalFlags(args []string, serverURL, keyPath *string) []string { + var remaining []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--server": + if i+1 < len(args) { + *serverURL = args[i+1] + i++ + } + case "--private-key-path": + if i+1 < len(args) { + *keyPath = args[i+1] + i++ + } + default: + remaining = append(remaining, args[i:]...) + return remaining + } + } + return remaining +} + +func flagValue(args []string, name string) string { + for i := 0; i < len(args)-1; i++ { + if args[i] == name { + return args[i+1] + } + } + return "" +} + +func flagUint(args []string, name string) (uint, bool) { + v := flagValue(args, name) + if v == "" { + return 0, false + } + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0, false + } + return uint(n), true +} + +func collectData(args []string) map[string]any { + data := make(map[string]any) + for i := 0; i < len(args)-1; i++ { + if args[i] == "--data" { + kv := args[i+1] + if idx := strings.IndexByte(kv, '='); idx > 0 { + data[kv[:idx]] = kv[idx+1:] + } + } + } + if len(data) == 0 { + return nil + } + return data +} + +func printJSON(w io.Writer, v any) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(v) +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..4f8d08f --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,447 @@ +package cli + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "log/slog" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/5000K/5000mails/api" + "github.com/5000K/5000mails/domain" +) + +// --- fakes (same shape as api tests) --- + +type fakeListManager struct { + lists map[uint]*domain.MailingList + nextID uint + users []*domain.User +} + +func newFakeListManager(lists ...*domain.MailingList) *fakeListManager { + m := &fakeListManager{lists: make(map[uint]*domain.MailingList), nextID: 1} + for _, l := range lists { + m.lists[l.ID] = l + if l.ID >= m.nextID { + m.nextID = l.ID + 1 + } + } + return m +} + +func (f *fakeListManager) Create(_ context.Context, name string) (*domain.MailingList, error) { + l := &domain.MailingList{ID: f.nextID, Name: name} + f.nextID++ + f.lists[l.ID] = l + return l, nil +} + +func (f *fakeListManager) Get(_ context.Context, id uint) (*domain.MailingList, error) { + l, ok := f.lists[id] + if !ok { + return nil, fmt.Errorf("list %d not found", id) + } + return l, nil +} + +func (f *fakeListManager) Rename(_ context.Context, id uint, name string) (*domain.MailingList, error) { + l, ok := f.lists[id] + if !ok { + return nil, fmt.Errorf("list %d not found", id) + } + l.Name = name + return l, nil +} + +func (f *fakeListManager) Delete(_ context.Context, id uint) error { + if _, ok := f.lists[id]; !ok { + return fmt.Errorf("list %d not found", id) + } + delete(f.lists, id) + return nil +} + +func (f *fakeListManager) CountUsers(_ context.Context, listID uint) (domain.UserCounts, error) { + var total, confirmed int + for _, u := range f.users { + if u.MailingListID == listID { + total++ + if u.IsConfirmed() { + confirmed++ + } + } + } + return domain.UserCounts{Total: total, Confirmed: confirmed}, nil +} + +func (f *fakeListManager) Users(_ context.Context, listID uint) ([]domain.User, error) { + var out []domain.User + for _, u := range f.users { + if u.MailingListID == listID { + out = append(out, *u) + } + } + return out, nil +} + +type fakeMailDispatcher struct { + lastListName string + lastRaw string + lastRecipient domain.User +} + +func (f *fakeMailDispatcher) SendToList(_ context.Context, listName, raw string, _ map[string]any) error { + f.lastListName = listName + f.lastRaw = raw + return nil +} + +func (f *fakeMailDispatcher) SendTestMail(_ context.Context, r domain.User, raw string, _ map[string]any) error { + f.lastRecipient = r + f.lastRaw = raw + return nil +} + +// --- helpers --- + +func startTestServer(t *testing.T, lm *fakeListManager, md *fakeMailDispatcher, pub ed25519.PublicKey) *httptest.Server { + t.Helper() + h := api.NewPrivateHandler(lm, md, pub, slog.Default()) + return httptest.NewServer(h.Routes()) +} + +func tmpRawFile(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "mail.md") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// --- key generation tests --- + +func TestGenerateKeyPair(t *testing.T) { + pub, priv, err := GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + if len(pub) != ed25519.PublicKeySize { + t.Errorf("unexpected public key size: %d", len(pub)) + } + if len(priv) != ed25519.PrivateKeySize { + t.Errorf("unexpected private key size: %d", len(priv)) + } +} + +func TestWriteAndReadKeyPair(t *testing.T) { + dir := t.TempDir() + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + + pubPath, privPath, err := WriteKeyPair(dir, pub, priv) + if err != nil { + t.Fatal(err) + } + + readPriv, err := ReadPrivateKey(privPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + if !readPriv.Equal(priv) { + t.Error("private key round-trip mismatch") + } + + readPub, err := ReadPublicKey(pubPath) + if err != nil { + t.Fatalf("read public key: %v", err) + } + if !readPub.Equal(pub) { + t.Error("public key round-trip mismatch") + } +} + +func TestKeysGenerateSubcommand(t *testing.T) { + dir := t.TempDir() + var stdout, stderr bytes.Buffer + + code := Run([]string{"keys", "generate", "--out-dir", dir}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit 0, got %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "private key:") { + t.Errorf("expected private key path in output, got: %s", stdout.String()) + } + + if _, err := os.Stat(filepath.Join(dir, privateKeyFile)); err != nil { + t.Errorf("private key file not created: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, publicKeyFile)); err != nil { + t.Errorf("public key file not created: %v", err) + } +} + +// --- cli dispatch tests --- + +func TestRunHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run([]string{"help"}, &stdout, &stderr) + if code != 0 { + t.Errorf("expected 0, got %d", code) + } + if !strings.Contains(stdout.String(), "Usage: 5kmcli") { + t.Error("expected usage in output") + } +} + +func TestRunNoArgs(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run(nil, &stdout, &stderr) + if code != 1 { + t.Errorf("expected 1, got %d", code) + } +} + +func TestRunUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run([]string{"bogus"}, &stdout, &stderr) + if code != 1 { + t.Errorf("expected 1, got %d", code) + } + if !strings.Contains(stderr.String(), "unknown command") { + t.Errorf("expected 'unknown command' in stderr, got: %s", stderr.String()) + } +} + +// --- list subcommand integration --- + +func TestListCreate(t *testing.T) { + m := newFakeListManager() + srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "create", "--name", "weekly"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + var resp api.ListResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if resp.Name != "weekly" { + t.Errorf("expected %q, got %q", "weekly", resp.Name) + } +} + +func TestListGet(t *testing.T) { + now := time.Now() + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "weekly"}) + m.users = []*domain.User{ + {ID: 1, MailingListID: 1, Email: "a@test.com", ConfirmedAt: &now}, + } + srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "get", "--id", "1"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + var resp api.ListDetailResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if resp.Subscribers.Total != 1 || resp.Subscribers.Confirmed != 1 { + t.Errorf("unexpected subscriber stats: %+v", resp.Subscribers) + } +} + +func TestListRename(t *testing.T) { + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "old"}) + srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "rename", "--id", "1", "--name", "new"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + var resp api.ListResponse + json.Unmarshal(stdout.Bytes(), &resp) + if resp.Name != "new" { + t.Errorf("expected %q, got %q", "new", resp.Name) + } +} + +func TestListDelete(t *testing.T) { + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "bye"}) + srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "delete", "--id", "1"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + if _, exists := m.lists[1]; exists { + t.Error("list should have been deleted") + } +} + +func TestListUsers(t *testing.T) { + now := time.Now() + m := newFakeListManager(&domain.MailingList{ID: 1, Name: "weekly"}) + m.users = []*domain.User{ + {ID: 1, MailingListID: 1, Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, + {ID: 2, MailingListID: 1, Name: "Bob", Email: "b@test.com"}, + } + srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "users", "--id", "1"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + var users []api.UserItem + json.Unmarshal(stdout.Bytes(), &users) + if len(users) != 2 { + t.Fatalf("expected 2 users, got %d", len(users)) + } +} + +// --- send subcommands --- + +func TestSendToList(t *testing.T) { + mail := &fakeMailDispatcher{} + srv := startTestServer(t, newFakeListManager(), mail, nil) + defer srv.Close() + + rawFile := tmpRawFile(t, "# Hello") + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "send", "list", "--list", "weekly", "--raw-path", rawFile, "--data", "foo=bar"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + if mail.lastListName != "weekly" { + t.Errorf("expected list %q, got %q", "weekly", mail.lastListName) + } + if mail.lastRaw != "# Hello" { + t.Errorf("expected raw %q, got %q", "# Hello", mail.lastRaw) + } + if !strings.Contains(stdout.String(), "mail dispatched") { + t.Errorf("expected 'mail dispatched' in output, got: %s", stdout.String()) + } +} + +func TestSendTestMail(t *testing.T) { + mail := &fakeMailDispatcher{} + srv := startTestServer(t, newFakeListManager(), mail, nil) + defer srv.Close() + + rawFile := tmpRawFile(t, "# Test") + var stdout, stderr bytes.Buffer + code := Run([]string{ + "--server", srv.URL, + "send", "test", + "--name", "Alice", + "--email", "alice@test.com", + "--raw-path", rawFile, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + if mail.lastRecipient.Email != "alice@test.com" { + t.Errorf("expected email %q, got %q", "alice@test.com", mail.lastRecipient.Email) + } + if mail.lastRecipient.Name != "Alice" { + t.Errorf("expected name %q, got %q", "Alice", mail.lastRecipient.Name) + } +} + +// --- authenticated CLI --- + +func TestAuthenticatedCLI(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + dir := t.TempDir() + _, privPath, _ := WriteKeyPair(dir, pub, priv) + + m := newFakeListManager() + srv := startTestServer(t, m, &fakeMailDispatcher{}, pub) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{ + "--server", srv.URL, + "--private-key-path", privPath, + "list", "create", "--name", "secure", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + var resp api.ListResponse + json.Unmarshal(stdout.Bytes(), &resp) + if resp.Name != "secure" { + t.Errorf("expected %q, got %q", "secure", resp.Name) + } + + t.Run("rejected without key", func(t *testing.T) { + var stdout2, stderr2 bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "create", "--name", "nope"}, &stdout2, &stderr2) + if code != 1 { + t.Errorf("expected 1, got %d", code) + } + }) +} + +// --- flag parser tests --- + +func TestParseGlobalFlags(t *testing.T) { + t.Run("extracts server and key path", func(t *testing.T) { + var server, key string + rest := parseGlobalFlags([]string{"--server", "http://x", "--private-key-path", "/k", "list", "create"}, &server, &key) + if server != "http://x" { + t.Errorf("server = %q", server) + } + if key != "/k" { + t.Errorf("key = %q", key) + } + if len(rest) != 2 || rest[0] != "list" { + t.Errorf("rest = %v", rest) + } + }) + + t.Run("returns all args when no global flags", func(t *testing.T) { + var s, k string + rest := parseGlobalFlags([]string{"list", "create"}, &s, &k) + if len(rest) != 2 { + t.Errorf("rest = %v", rest) + } + }) +} + +func TestCollectData(t *testing.T) { + args := []string{"--data", "a=1", "--data", "b=hello world", "--other", "x"} + data := collectData(args) + if data["a"] != "1" || data["b"] != "hello world" { + t.Errorf("unexpected data: %v", data) + } +} + +func TestFlagValue(t *testing.T) { + if v := flagValue([]string{"--name", "test", "--id", "5"}, "--name"); v != "test" { + t.Errorf("expected %q, got %q", "test", v) + } + if v := flagValue([]string{"--name"}, "--name"); v != "" { + t.Errorf("expected empty, got %q", v) + } +} diff --git a/cli/keys.go b/cli/keys.go new file mode 100644 index 0000000..4bf3493 --- /dev/null +++ b/cli/keys.go @@ -0,0 +1,79 @@ +package cli + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" +) + +const ( + privateKeyFile = "5kmcli.key" + publicKeyFile = "5kmcli.pub" +) + +func GenerateKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generating ed25519 key pair: %w", err) + } + return pub, priv, nil +} + +func WriteKeyPair(dir string, pub ed25519.PublicKey, priv ed25519.PrivateKey) (pubPath, privPath string, err error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", fmt.Errorf("creating output directory: %w", err) + } + + privPath = filepath.Join(dir, privateKeyFile) + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "ED25519 PRIVATE KEY", + Bytes: priv.Seed(), + }) + if err := os.WriteFile(privPath, privPEM, 0o600); err != nil { + return "", "", fmt.Errorf("writing private key: %w", err) + } + + pubPath = filepath.Join(dir, publicKeyFile) + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: "ED25519 PUBLIC KEY", + Bytes: pub, + }) + if err := os.WriteFile(pubPath, pubPEM, 0o644); err != nil { + return "", "", fmt.Errorf("writing public key: %w", err) + } + + return pubPath, privPath, nil +} + +func ReadPrivateKey(path string) (ed25519.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading private key file: %w", err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM block found in %s", path) + } + if block.Type != "ED25519 PRIVATE KEY" { + return nil, fmt.Errorf("unexpected PEM type %q, expected \"ED25519 PRIVATE KEY\"", block.Type) + } + return ed25519.NewKeyFromSeed(block.Bytes), nil +} + +func ReadPublicKey(path string) (ed25519.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading public key file: %w", err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM block found in %s", path) + } + if block.Type != "ED25519 PUBLIC KEY" { + return nil, fmt.Errorf("unexpected PEM type %q, expected \"ED25519 PUBLIC KEY\"", block.Type) + } + return ed25519.PublicKey(block.Bytes), nil +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..19dbfff --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/5000K/5000mails/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..2bd51b3 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,24 @@ +public-addr: ":8081" +private-addr: ":9000" +base-url: "https://yoursite.com" # used to build confirmation links in emails + +smtp: + host: "smtp.example.com" + port: 587 + username: "you@example.com" + password: "secret" + sender-email: "newsletter@example.com" + tls-policy: "TLSOpportunistic" # TLSMandatory | TLSOpportunistic | NoTLS + +db: + type: "sqlite" # sqlite or postgres + dsn: "5000mails.db" # file path for sqlite; connection string for postgres + +auth: + public-key-path: "" # path to Ed25519 public key; leave empty to disable auth for the management api + # generate a key pair with `5kmcli keys generate --out-dir ~/.config/5kmcli` + +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 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..1d39bc1 --- /dev/null +++ b/config/config.go @@ -0,0 +1,96 @@ +package config + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/ilyakaznacheev/cleanenv" +) + +type TLSPolicy string + +const ( + TLSMandatory TLSPolicy = "TLSMandatory" + TLSOpportunistic TLSPolicy = "TLSOpportunistic" + NoTLS TLSPolicy = "NoTLS" +) + +type SmtpConfig struct { + Host string `env:"SMTP_HOST" yaml:"host"` + Port int `env:"SMTP_PORT" env-default:"587" yaml:"port"` + Username string `env:"SMTP_USERNAME" yaml:"username"` + Password string `env:"SMTP_PASSWORD" yaml:"password"` + SenderEmail string `env:"SMTP_SENDER_EMAIL" yaml:"sender-email"` + TLSPolicy TLSPolicy `env:"SMTP_TLS_POLICY" env-default:"TLSOpportunistic" yaml:"tls-policy"` +} + +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"` + BaseURL string `env:"BASE_URL" env-default:"http://localhost:8080" yaml:"base-url"` + + Smtp SmtpConfig `yaml:"smtp"` + + DB struct { + Type string `env:"DB_TYPE" env-default:"sqlite" yaml:"type"` + DSN string `env:"DB_DSN" env-default:"5000mails.db" yaml:"dsn"` + } `yaml:"db"` + + Auth struct { + PublicKeyPath string `env:"AUTH_PUBLIC_KEY_PATH" yaml:"public-key-path"` + } `yaml:"auth"` + + Paths struct { + Config string `env:"CONFIG_PATH" env-default:"config.yml"` + Template string `env:"TEMPLATE_PATH" env-default:"./template.html" yaml:"template"` + Theme string `env:"THEME_PATH" env-default:"https://raw.githubusercontent.com/5000K/5000blogs/refs/heads/stable/template/theme.base.css" yaml:"theme"` + ConfirmMail string `env:"CONFIRM_MAIL_PATH" env-default:"./confirm.md" yaml:"confirm-mail"` + } `yaml:"paths"` +} + +// FetchResource reads a file from disk or downloads it over HTTP/HTTPS. +func FetchResource(urlOrPath string) ([]byte, error) { + if strings.HasPrefix(urlOrPath, "http://") || strings.HasPrefix(urlOrPath, "https://") { + resp, err := http.Get(urlOrPath) //nolint:noctx + if err != nil { + return nil, fmt.Errorf("fetch %q: %w", urlOrPath, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch %q: HTTP %d", urlOrPath, resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("fetch %q: read body: %w", urlOrPath, err) + } + return data, nil + } + data, err := os.ReadFile(urlOrPath) + if err != nil { + return nil, fmt.Errorf("read %q: %w", urlOrPath, err) + } + return data, nil +} + +func Get() (*Config, error) { + var cfg Config + + if err := cleanenv.ReadEnv(&cfg); err != nil { + return nil, err + } + + data, err := FetchResource(cfg.Paths.Config) + if err != nil { + return &cfg, nil + } + + if err := cleanenv.ParseYAML(bytes.NewReader(data), &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + return &cfg, nil +} diff --git a/db/connect.go b/db/connect.go new file mode 100644 index 0000000..1a88c81 --- /dev/null +++ b/db/connect.go @@ -0,0 +1,39 @@ +package db + +import ( + "errors" + "fmt" + "log/slog" + + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func Connect(dbType string, dsn string) (*gorm.DB, error) { + switch dbType { + case "mysql": + slog.Debug("connecting to mysql") + return gorm.Open(mysql.Open(dsn), &gorm.Config{}) + + case "postgres": + slog.Debug("connecting to postgres") + return gorm.Open(postgres.Open(dsn), &gorm.Config{}) + + case "sqlite": + slog.Debug("opening sqlite") + return gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + + default: + slog.Error("Unknown database type", "db-type", dbType) + return nil, errors.New("unknown database type: " + dbType) + } +} + +func AutoMigrate(database *gorm.DB) error { + if err := database.AutoMigrate(&MailingList{}, &User{}, &Confirmation{}); err != nil { + return fmt.Errorf("auto-migrating database: %w", err) + } + return nil +} diff --git a/db/model.go b/db/model.go new file mode 100644 index 0000000..549ee3e --- /dev/null +++ b/db/model.go @@ -0,0 +1,86 @@ +package db + +import ( + "time" + + "github.com/5000K/5000mails/domain" + "gorm.io/gorm" +) + +type MailingList struct { + gorm.Model + Name string `gorm:"not null;uniqueIndex"` + Users []User `gorm:"foreignKey:MailingListID"` +} + +type User struct { + gorm.Model + Name string `gorm:"not null"` + Email string `gorm:"not null;uniqueIndex:idx_user_email_list"` + ConfirmedAt *time.Time + MailingListID uint `gorm:"not null;uniqueIndex:idx_user_email_list"` + UnsubscribeToken string `gorm:"not null;uniqueIndex"` +} + +func ToGORMUser(u *domain.User) *User { + return &User{ + Name: u.Name, + Email: u.Email, + ConfirmedAt: u.ConfirmedAt, + MailingListID: u.MailingListID, + UnsubscribeToken: u.UnsubscribeToken, + } +} + +func ToDomainUser(u *User) *domain.User { + return &domain.User{ + ID: u.ID, + Name: u.Name, + Email: u.Email, + ConfirmedAt: u.ConfirmedAt, + MailingListID: u.MailingListID, + UnsubscribeToken: u.UnsubscribeToken, + } +} + +func ToDomainUsers(users []User) []domain.User { + result := make([]domain.User, len(users)) + for i, u := range users { + result[i] = *ToDomainUser(&u) + } + return result +} + +func ToGORMList(l *domain.MailingList) *MailingList { + return &MailingList{ + Name: l.Name, + } +} + +func ToDomainList(l *MailingList) *domain.MailingList { + return &domain.MailingList{ + ID: l.ID, + Name: l.Name, + } +} + +type Confirmation struct { + gorm.Model + UserID uint `gorm:"not null;index"` + Token string `gorm:"not null;uniqueIndex"` +} + +func ToDomainConfirmation(c *Confirmation) *domain.Confirmation { + return &domain.Confirmation{ + ID: c.ID, + UserID: c.UserID, + Token: c.Token, + } +} + +func ToGORMConfirmation(c *domain.Confirmation) *Confirmation { + return &Confirmation{ + UserID: c.UserID, + Token: c.Token, + } +} diff --git a/db/repository.go b/db/repository.go new file mode 100644 index 0000000..e479272 --- /dev/null +++ b/db/repository.go @@ -0,0 +1,19 @@ +package db + +import ( + "log/slog" + + "gorm.io/gorm" +) + +type MailingListRepository struct { + db *gorm.DB + logger *slog.Logger +} + +func NewMailingListRepository(db *gorm.DB, logger *slog.Logger) *MailingListRepository { + return &MailingListRepository{ + db: db, + logger: logger, + } +} diff --git a/db/repository.list.go b/db/repository.list.go new file mode 100644 index 0000000..0d916a6 --- /dev/null +++ b/db/repository.list.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + + "github.com/5000K/5000mails/domain" +) + +func (r *MailingListRepository) CreateList(ctx context.Context, name string) (*domain.MailingList, error) { + list := &MailingList{Name: name} + + result := r.db.WithContext(ctx).Create(list) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to create mailing list", + slog.String("name", name), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("create mailing list: %w", result.Error) + } + + r.logger.InfoContext(ctx, "created mailing list", + slog.String("name", name), + slog.Uint64("id", uint64(list.ID)), + ) + return ToDomainList(list), nil +} + +func (r *MailingListRepository) GetList(ctx context.Context, id uint) (*domain.MailingList, error) { + var list MailingList + + result := r.db.WithContext(ctx).First(&list, id) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get mailing list", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get mailing list: %w", result.Error) + } + + return ToDomainList(&list), nil +} + +func (r *MailingListRepository) GetListByName(ctx context.Context, name string) (*domain.MailingList, error) { + var list MailingList + + result := r.db.WithContext(ctx).Where("name = ?", name).First(&list) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get mailing list by name", + slog.String("name", name), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get mailing list by name: %w", result.Error) + } + + return ToDomainList(&list), nil +} + +func (r *MailingListRepository) UpdateList(ctx context.Context, id uint, name string) (*domain.MailingList, error) { + var list MailingList + result := r.db.WithContext(ctx).First(&list, id) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to find mailing list for update", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("update mailing list: %w", result.Error) + } + + list.Name = name + result = r.db.WithContext(ctx).Save(&list) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to update mailing list", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("update mailing list: %w", result.Error) + } + + r.logger.InfoContext(ctx, "updated mailing list", + slog.Uint64("id", uint64(id)), + slog.String("name", name), + ) + return ToDomainList(&list), nil +} + +func (r *MailingListRepository) DeleteList(ctx context.Context, id uint) error { + result := r.db.WithContext(ctx).Delete(&MailingList{}, id) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to delete mailing list", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return fmt.Errorf("delete mailing list: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("delete mailing list: list %d not found", id) + } + r.logger.InfoContext(ctx, "deleted mailing list", + slog.Uint64("id", uint64(id)), + ) + return nil +} diff --git a/db/repository.user.go b/db/repository.user.go new file mode 100644 index 0000000..ccda66c --- /dev/null +++ b/db/repository.user.go @@ -0,0 +1,192 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/5000K/5000mails/domain" +) + +func (r *MailingListRepository) AddUser(ctx context.Context, mailingListID uint, name, email, unsubscribeToken string) (*domain.User, error) { + user := &User{ + Name: name, + Email: email, + MailingListID: mailingListID, + UnsubscribeToken: unsubscribeToken, + } + + result := r.db.WithContext(ctx).Create(user) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to add user to mailing list", + slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("email", email), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("add user: %w", result.Error) + } + + r.logger.InfoContext(ctx, "added user to mailing list", + slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.Uint64("user_id", uint64(user.ID)), + slog.String("email", email), + ) + return ToDomainUser(user), nil +} + +func (r *MailingListRepository) ConfirmUser(ctx context.Context, userID uint) error { + now := time.Now() + + result := r.db.WithContext(ctx). + Model(&User{}). + Where("id = ? AND confirmed_at IS NULL", userID). + Update("confirmed_at", &now) + + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to confirm user", + slog.Uint64("user_id", uint64(userID)), + slog.Any("error", result.Error), + ) + return fmt.Errorf("confirm user: %w", result.Error) + } + + if result.RowsAffected == 0 { + r.logger.WarnContext(ctx, "confirm user had no effect: already confirmed or not found", + slog.Uint64("user_id", uint64(userID)), + ) + return fmt.Errorf("confirm user: user %d not found or already confirmed", userID) + } + + r.logger.InfoContext(ctx, "confirmed user subscription", + slog.Uint64("user_id", uint64(userID)), + slog.Time("confirmed_at", now), + ) + return nil +} + +func (r *MailingListRepository) GetUserByUnsubscribeToken(ctx context.Context, token string) (*domain.User, error) { + var user User + + result := r.db.WithContext(ctx).Where("unsubscribe_token = ?", token).First(&user) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get user by unsubscribe token", + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get user by unsubscribe token: %w", result.Error) + } + + return ToDomainUser(&user), nil +} + +func (r *MailingListRepository) GetConfirmedUsers(ctx context.Context, mailingListID uint) ([]domain.User, error) { + var users []User + + result := r.db.WithContext(ctx). + Where("mailing_list_id = ? AND confirmed_at IS NOT NULL", mailingListID). + Find(&users) + + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get confirmed users", + slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get confirmed users: %w", result.Error) + } + + r.logger.InfoContext(ctx, "fetched confirmed users", + slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.Int("count", len(users)), + ) + return ToDomainUsers(users), nil +} + +func (r *MailingListRepository) RemoveUser(ctx context.Context, userID uint) error { + result := r.db.WithContext(ctx).Delete(&User{}, userID) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to remove user", + slog.Uint64("user_id", uint64(userID)), + slog.Any("error", result.Error), + ) + return fmt.Errorf("remove user: %w", result.Error) + } + + if result.RowsAffected == 0 { + return fmt.Errorf("remove user: user %d not found", userID) + } + + r.logger.InfoContext(ctx, "removed user from mailing list", + slog.Uint64("user_id", uint64(userID)), + ) + return nil +} + +func (r *MailingListRepository) GetUsers(ctx context.Context, mailingListID uint) ([]domain.User, error) { + var users []User + + result := r.db.WithContext(ctx).Where("mailing_list_id = ?", mailingListID).Find(&users) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get users", + slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get users: %w", result.Error) + } + + r.logger.InfoContext(ctx, "fetched users", + slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.Int("count", len(users)), + ) + return ToDomainUsers(users), nil +} + +func (r *MailingListRepository) CreateConfirmation(ctx context.Context, userID uint, token string) (*domain.Confirmation, error) { + c := &Confirmation{UserID: userID, Token: token} + + result := r.db.WithContext(ctx).Create(c) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to create confirmation", + slog.Uint64("user_id", uint64(userID)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("create confirmation: %w", result.Error) + } + + r.logger.InfoContext(ctx, "created confirmation", + slog.Uint64("user_id", uint64(userID)), + slog.Uint64("confirmation_id", uint64(c.ID)), + ) + return ToDomainConfirmation(c), nil +} + +func (r *MailingListRepository) GetConfirmationByToken(ctx context.Context, token string) (*domain.Confirmation, error) { + var c Confirmation + + result := r.db.WithContext(ctx).Where("token = ?", token).First(&c) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get confirmation by token", + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get confirmation by token: %w", result.Error) + } + + return ToDomainConfirmation(&c), nil +} + +func (r *MailingListRepository) DeleteConfirmation(ctx context.Context, id uint) error { + result := r.db.WithContext(ctx).Delete(&Confirmation{}, id) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to delete confirmation", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return fmt.Errorf("delete confirmation: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("delete confirmation: confirmation %d not found", id) + } + r.logger.InfoContext(ctx, "deleted confirmation", + slog.Uint64("id", uint64(id)), + ) + return nil +} diff --git a/db/repository_test.go b/db/repository_test.go new file mode 100644 index 0000000..97e57fc --- /dev/null +++ b/db/repository_test.go @@ -0,0 +1,379 @@ +package db + +import ( + "context" + "log/slog" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func newTestRepo(t *testing.T) *MailingListRepository { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open in-memory db: %v", err) + } + if err := db.AutoMigrate(&MailingList{}, &User{}, &Confirmation{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return NewMailingListRepository(db, slog.Default()) +} + +// ---------- MailingList ---------- + +func TestCreateList(t *testing.T) { + repo := newTestRepo(t) + list, err := repo.CreateList(context.Background(), "weekly") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if list.ID == 0 { + t.Error("expected non-zero ID") + } + if list.Name != "weekly" { + t.Errorf("expected name %q, got %q", "weekly", list.Name) + } +} + +func TestCreateList_DuplicateNameErrors(t *testing.T) { + repo := newTestRepo(t) + if _, err := repo.CreateList(context.Background(), "weekly"); err != nil { + t.Fatalf("first create: %v", err) + } + _, err := repo.CreateList(context.Background(), "weekly") + if err == nil { + t.Fatal("expected error for duplicate name, got nil") + } +} + +func TestGetList(t *testing.T) { + repo := newTestRepo(t) + created, _ := repo.CreateList(context.Background(), "monthly") + + got, err := repo.GetList(context.Background(), created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ID != created.ID || got.Name != created.Name { + t.Errorf("got %+v, want %+v", got, created) + } +} + +func TestGetList_NotFound(t *testing.T) { + repo := newTestRepo(t) + _, err := repo.GetList(context.Background(), 9999) + if err == nil { + t.Fatal("expected error for unknown ID, got nil") + } +} + +func TestGetListByName(t *testing.T) { + repo := newTestRepo(t) + repo.CreateList(context.Background(), "daily") + + got, err := repo.GetListByName(context.Background(), "daily") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "daily" { + t.Errorf("expected name %q, got %q", "daily", got.Name) + } +} + +func TestGetListByName_NotFound(t *testing.T) { + repo := newTestRepo(t) + _, err := repo.GetListByName(context.Background(), "ghost") + if err == nil { + t.Fatal("expected error for unknown name, got nil") + } +} + +// ---------- User ---------- + +func seedList(t *testing.T, repo *MailingListRepository, name string) uint { + t.Helper() + list, err := repo.CreateList(context.Background(), name) + if err != nil { + t.Fatalf("seed list %q: %v", name, err) + } + return list.ID +} + +func TestAddUser(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + + user, err := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.ID == 0 { + t.Error("expected non-zero user ID") + } + if user.Email != "alice@example.com" { + t.Errorf("expected email %q, got %q", "alice@example.com", user.Email) + } + if user.ConfirmedAt != nil { + t.Error("new user should be unconfirmed") + } + if user.UnsubscribeToken != "tok-alice" { + t.Errorf("expected unsubscribe token %q, got %q", "tok-alice", user.UnsubscribeToken) + } +} + +func TestAddUser_DuplicateEmailErrors(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + + _, err := repo.AddUser(context.Background(), listID, "Alice2", "alice@example.com", "tok-alice-2") + if err == nil { + t.Fatal("expected error for duplicate email on same list, got nil") + } +} + +func TestConfirmUser(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + user, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + + if err := repo.ConfirmUser(context.Background(), user.ID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := repo.GetUserByUnsubscribeToken(context.Background(), "tok-alice") + if got.ConfirmedAt == nil { + t.Error("expected ConfirmedAt to be set after confirmation") + } +} + +func TestConfirmUser_AlreadyConfirmedErrors(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + user, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + repo.ConfirmUser(context.Background(), user.ID) + + err := repo.ConfirmUser(context.Background(), user.ID) + if err == nil { + t.Fatal("expected error when confirming already-confirmed user, got nil") + } +} + +func TestConfirmUser_NotFoundErrors(t *testing.T) { + repo := newTestRepo(t) + err := repo.ConfirmUser(context.Background(), 9999) + if err == nil { + t.Fatal("expected error for unknown user, got nil") + } +} + +func TestGetUserByUnsubscribeToken(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + repo.AddUser(context.Background(), listID, "Bob", "bob@example.com", "tok-bob") + + got, err := repo.GetUserByUnsubscribeToken(context.Background(), "tok-bob") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Email != "bob@example.com" { + t.Errorf("expected email %q, got %q", "bob@example.com", got.Email) + } + if got.UnsubscribeToken != "tok-bob" { + t.Errorf("expected token %q, got %q", "tok-bob", got.UnsubscribeToken) + } +} + +func TestGetUserByUnsubscribeToken_NotFound(t *testing.T) { + repo := newTestRepo(t) + _, err := repo.GetUserByUnsubscribeToken(context.Background(), "no-such-token") + if err == nil { + t.Fatal("expected error for unknown token, got nil") + } +} + +func TestGetConfirmedUsers(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + + confirmed, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + repo.AddUser(context.Background(), listID, "Bob", "bob@example.com", "tok-bob") + repo.ConfirmUser(context.Background(), confirmed.ID) + + users, err := repo.GetConfirmedUsers(context.Background(), listID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 confirmed user, got %d", len(users)) + } + if users[0].Email != "alice@example.com" { + t.Errorf("expected alice, got %q", users[0].Email) + } +} + +func TestGetConfirmedUsers_ExcludesOtherLists(t *testing.T) { + repo := newTestRepo(t) + listA := seedList(t, repo, "list-a") + listB := seedList(t, repo, "list-b") + + userA, _ := repo.AddUser(context.Background(), listA, "Alice", "alice@example.com", "tok-alice") + userB, _ := repo.AddUser(context.Background(), listB, "Bob", "bob@example.com", "tok-bob") + repo.ConfirmUser(context.Background(), userA.ID) + repo.ConfirmUser(context.Background(), userB.ID) + + users, err := repo.GetConfirmedUsers(context.Background(), listA) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 1 || users[0].Email != "alice@example.com" { + t.Errorf("expected only alice, got %+v", users) + } +} + +func TestRemoveUser(t *testing.T) { + repo := newTestRepo(t) + listID := seedList(t, repo, "weekly") + user, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + + if err := repo.RemoveUser(context.Background(), user.ID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err := repo.GetUserByUnsubscribeToken(context.Background(), "tok-alice") + if err == nil { + t.Fatal("expected error after removing user, got nil") + } +} + +func TestRemoveUser_NotFound(t *testing.T) { + repo := newTestRepo(t) + err := repo.RemoveUser(context.Background(), 9999) + if err == nil { + t.Fatal("expected error for unknown user, got nil") + } +} + +func TestUpdateList(t *testing.T) { + repo := newTestRepo(t) + list, _ := repo.CreateList(context.Background(), "original") + + updated, err := repo.UpdateList(context.Background(), list.ID, "renamed") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated.Name != "renamed" { + t.Errorf("expected %q, got %q", "renamed", updated.Name) + } +} + +func TestUpdateList_NotFound(t *testing.T) { + repo := newTestRepo(t) + _, err := repo.UpdateList(context.Background(), 9999, "nope") + if err == nil { + t.Fatal("expected error for unknown list, got nil") + } +} + +func TestDeleteList(t *testing.T) { + repo := newTestRepo(t) + list, _ := repo.CreateList(context.Background(), "doomed") + + if err := repo.DeleteList(context.Background(), list.ID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err := repo.GetList(context.Background(), list.ID) + if err == nil { + t.Fatal("expected error after deleting list, got nil") + } +} + +func TestDeleteList_NotFound(t *testing.T) { + repo := newTestRepo(t) + err := repo.DeleteList(context.Background(), 9999) + if err == nil { + t.Fatal("expected error for unknown list, got nil") + } +} + +func TestGetUsers(t *testing.T) { + repo := newTestRepo(t) + list, _ := repo.CreateList(context.Background(), "weekly") + repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + repo.AddUser(context.Background(), list.ID, "Bob", "b@test.com", "tok-b") + + users, err := repo.GetUsers(context.Background(), list.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 2 { + t.Errorf("expected 2 users, got %d", len(users)) + } +} + +func TestCreateConfirmation(t *testing.T) { + repo := newTestRepo(t) + list, _ := repo.CreateList(context.Background(), "weekly") + user, _ := repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + + conf, err := repo.CreateConfirmation(context.Background(), user.ID, "confirm-tok") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.UserID != user.ID || conf.Token != "confirm-tok" { + t.Errorf("unexpected confirmation: %+v", conf) + } +} + +func TestGetConfirmationByToken(t *testing.T) { + repo := newTestRepo(t) + list, _ := repo.CreateList(context.Background(), "weekly") + user, _ := repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + repo.CreateConfirmation(context.Background(), user.ID, "find-me") + + conf, err := repo.GetConfirmationByToken(context.Background(), "find-me") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.Token != "find-me" || conf.UserID != user.ID { + t.Errorf("unexpected confirmation: %+v", conf) + } +} + +func TestGetConfirmationByToken_NotFound(t *testing.T) { + repo := newTestRepo(t) + _, err := repo.GetConfirmationByToken(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error for unknown token, got nil") + } +} + +func TestDeleteConfirmation(t *testing.T) { + repo := newTestRepo(t) + list, _ := repo.CreateList(context.Background(), "weekly") + user, _ := repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + conf, _ := repo.CreateConfirmation(context.Background(), user.ID, "del-me") + + if err := repo.DeleteConfirmation(context.Background(), conf.ID); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err := repo.GetConfirmationByToken(context.Background(), "del-me") + if err == nil { + t.Fatal("expected error after deleting confirmation, got nil") + } +} + +func TestDeleteConfirmation_NotFound(t *testing.T) { + repo := newTestRepo(t) + err := repo.DeleteConfirmation(context.Background(), 9999) + if err == nil { + t.Fatal("expected error for unknown confirmation, got nil") + } +} diff --git a/domain/model.go b/domain/model.go new file mode 100644 index 0000000..8cfd5f2 --- /dev/null +++ b/domain/model.go @@ -0,0 +1,42 @@ +package domain + +import "time" + +// MailingList represents a named list that users can subscribe to. +type MailingList struct { + ID uint + Name string +} + +// User represents a subscriber on a mailing list. +type User struct { + ID uint + Name string + Email string + ConfirmedAt *time.Time + MailingListID uint + UnsubscribeToken string +} + +// IsConfirmed returns true if the user has completed double opt-in. +func (u *User) IsConfirmed() bool { + return u.ConfirmedAt != nil +} + +type MailMetadata struct { + Subject string + SenderName string +} + +// Confirmation holds a pending double opt-in token for a user. +type Confirmation struct { + ID uint + UserID uint + Token string +} + +// UserCounts holds subscriber totals for a mailing list. +type UserCounts struct { + Total int + Confirmed int +} diff --git a/domain/model_test.go b/domain/model_test.go new file mode 100644 index 0000000..434d654 --- /dev/null +++ b/domain/model_test.go @@ -0,0 +1,25 @@ +package domain_test + +import ( + "testing" + "time" + + "github.com/5000K/5000mails/domain" +) + +func TestUser_IsConfirmed(t *testing.T) { + t.Run("nil ConfirmedAt returns false", func(t *testing.T) { + u := domain.User{ConfirmedAt: nil} + if u.IsConfirmed() { + t.Error("expected IsConfirmed() = false for nil ConfirmedAt") + } + }) + + t.Run("non-nil ConfirmedAt returns true", func(t *testing.T) { + now := time.Now() + u := domain.User{ConfirmedAt: &now} + if !u.IsConfirmed() { + t.Error("expected IsConfirmed() = true for non-nil ConfirmedAt") + } + }) +} diff --git a/domain/ports.go b/domain/ports.go new file mode 100644 index 0000000..f578edc --- /dev/null +++ b/domain/ports.go @@ -0,0 +1,34 @@ +package domain + +import "context" + +type MailingListRepository interface { + CreateList(ctx context.Context, name string) (*MailingList, error) + GetList(ctx context.Context, id uint) (*MailingList, error) + GetListByName(ctx context.Context, name string) (*MailingList, error) + UpdateList(ctx context.Context, id uint, name string) (*MailingList, error) + DeleteList(ctx context.Context, id uint) error +} + +type UserRepository interface { + AddUser(ctx context.Context, mailingListID uint, name, email, unsubscribeToken string) (*User, error) + ConfirmUser(ctx context.Context, userID uint) error + GetUserByUnsubscribeToken(ctx context.Context, token string) (*User, error) + GetUsers(ctx context.Context, mailingListID uint) ([]User, error) + GetConfirmedUsers(ctx context.Context, mailingListID uint) ([]User, error) + RemoveUser(ctx context.Context, userID uint) error +} + +type ConfirmationRepository interface { + CreateConfirmation(ctx context.Context, userID uint, token string) (*Confirmation, error) + GetConfirmationByToken(ctx context.Context, token string) (*Confirmation, error) + DeleteConfirmation(ctx context.Context, id uint) error +} + +type Renderer interface { + Render(raw *string, data map[string]any) (metadata MailMetadata, body string, err error) +} + +type Sender interface { + SendMail(ctx context.Context, metadata MailMetadata, body string, recipient User) error +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4ad4d5a --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/5000K/5000mails + +go 1.26.0 + +require ( + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/wneessen/go-mail v0.7.2 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cb96047 --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= +github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..cecde84 --- /dev/null +++ b/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "crypto/ed25519" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/5000K/5000mails/api" + "github.com/5000K/5000mails/cli" + "github.com/5000K/5000mails/config" + "github.com/5000K/5000mails/db" + "github.com/5000K/5000mails/renderer" + "github.com/5000K/5000mails/service" + "github.com/5000K/5000mails/smtp" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + slog.SetDefault(logger) + + cfg, err := config.Get() + if err != nil { + logger.Error("loading config", slog.Any("error", err)) + os.Exit(1) + } + + database, err := db.Connect(cfg.DB.Type, cfg.DB.DSN) + if err != nil { + logger.Error("connecting to database", slog.Any("error", err)) + os.Exit(1) + } + if err := db.AutoMigrate(database); err != nil { + logger.Error("migrating database", slog.Any("error", err)) + os.Exit(1) + } + + repo := db.NewMailingListRepository(database, logger) + + sender, err := smtp.NewSender(cfg.Smtp, logger) + if err != nil { + logger.Error("creating smtp sender", slog.Any("error", err)) + os.Exit(1) + } + + tmplBytes, err := config.FetchResource(cfg.Paths.Template) + if err != nil { + logger.Error("loading template", slog.String("path", cfg.Paths.Template), slog.Any("error", err)) + os.Exit(1) + } + themeBytes, err := config.FetchResource(cfg.Paths.Theme) + if err != nil { + logger.Error("loading theme", slog.String("path", cfg.Paths.Theme), slog.Any("error", err)) + os.Exit(1) + } + rndr, err := renderer.NewGoldmarkRenderer(tmplBytes, themeBytes, logger) + if err != nil { + logger.Error("creating renderer", slog.Any("error", err)) + os.Exit(1) + } + + confirmRaw, err := config.FetchResource(cfg.Paths.ConfirmMail) + if err != nil { + logger.Error("loading confirm mail template", slog.String("path", cfg.Paths.ConfirmMail), slog.Any("error", err)) + os.Exit(1) + } + + subscriptionSvc := service.NewSubscriptionService(repo, repo, repo, rndr, sender, string(confirmRaw), cfg.BaseURL) + listSvc := service.NewListService(repo, repo) + mailSvc := service.NewMailService(repo, repo, rndr, sender) + + publicHandler := api.NewPublicHandler(subscriptionSvc, logger) + + var publicKey ed25519.PublicKey + if cfg.Auth.PublicKeyPath != "" { + publicKey, err = cli.ReadPublicKey(cfg.Auth.PublicKeyPath) + if err != nil { + logger.Error("loading auth public key", slog.String("path", cfg.Auth.PublicKeyPath), slog.Any("error", err)) + os.Exit(1) + } + logger.Info("private API authentication enabled") + } else { + logger.Warn("private API authentication disabled — no public key configured") + } + + privateHandler := api.NewPrivateHandler(listSvc, mailSvc, publicKey, logger) + + publicServer := &http.Server{Addr: cfg.PublicAddr, Handler: publicHandler.Routes()} + privateServer := &http.Server{Addr: cfg.PrivateAddr, Handler: privateHandler.Routes()} + + go func() { + logger.Info("public API listening", slog.String("addr", cfg.PublicAddr)) + if err := publicServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("public server error", slog.Any("error", err)) + } + }() + + go func() { + logger.Info("private API listening", slog.String("addr", cfg.PrivateAddr)) + if err := privateServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("private server error", slog.Any("error", err)) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + logger.Info("shutting down", slog.String("signal", sig.String())) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := publicServer.Shutdown(ctx); err != nil { + logger.Error("public server shutdown error", slog.Any("error", err)) + } + if err := privateServer.Shutdown(ctx); err != nil { + logger.Error("private server shutdown error", slog.Any("error", err)) + } + + logger.Info("servers stopped") +} diff --git a/renderer/goldmark.go b/renderer/goldmark.go new file mode 100644 index 0000000..1765dd7 --- /dev/null +++ b/renderer/goldmark.go @@ -0,0 +1,129 @@ +package renderer + +import ( + "bytes" + "fmt" + "log/slog" + "strings" + "text/template" + + "github.com/5000K/5000mails/domain" + "github.com/yuin/goldmark" + "gopkg.in/yaml.v3" +) + +// GoldmarkRenderer implements domain.Renderer using Go templates and Goldmark. +type GoldmarkRenderer struct { + tmpl *template.Template + theme string + logger *slog.Logger + md goldmark.Markdown +} + +// NewGoldmarkRenderer parses tmpl as a Go HTML template and returns a renderer. +func NewGoldmarkRenderer(tmpl, theme []byte, logger *slog.Logger) (*GoldmarkRenderer, error) { + t, err := template.New("layout").Parse(string(tmpl)) + if err != nil { + return nil, fmt.Errorf("parsing renderer layout template: %w", err) + } + return &GoldmarkRenderer{ + tmpl: t, + theme: string(theme), + logger: logger, + md: goldmark.New(), + }, nil +} + +// Render implements domain.Renderer. +// +// Pipeline: +// 1. Execute raw as a Go template with data. +// 2. Strip and parse the YAML frontmatter into MailMetadata. +// 3. Convert the remaining Markdown body to HTML via Goldmark. +// 4. Execute the layout template with data + "html" + "metadata" keys. +func (r *GoldmarkRenderer) Render(raw *string, data map[string]any) (domain.MailMetadata, string, error) { + templated, err := applyTemplate("content", *raw, data) + if err != nil { + return domain.MailMetadata{}, "", fmt.Errorf("templating markdown content: %w", err) + } + + metadata, markdownBody, err := parseFrontmatter(templated) + if err != nil { + return domain.MailMetadata{}, "", fmt.Errorf("parsing frontmatter: %w", err) + } + + var htmlBuf bytes.Buffer + if err := r.md.Convert([]byte(markdownBody), &htmlBuf); err != nil { + return domain.MailMetadata{}, "", fmt.Errorf("converting markdown to html: %w", err) + } + + layoutData := mergeData(data, map[string]any{ + "html": htmlBuf.String(), + "metadata": metadata, + "theme": r.theme, + }) + + var finalBuf bytes.Buffer + if err := r.tmpl.Execute(&finalBuf, layoutData); err != nil { + return domain.MailMetadata{}, "", fmt.Errorf("executing layout template: %w", err) + } + + r.logger.Debug("rendered mail", slog.String("subject", metadata.Subject)) + return metadata, finalBuf.String(), nil +} + +func applyTemplate(name, text string, data map[string]any) (string, error) { + t, err := template.New(name).Parse(text) + if err != nil { + return "", fmt.Errorf("parsing template %q: %w", name, err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("executing template %q: %w", name, err) + } + return buf.String(), nil +} + +type frontmatterFields struct { + Subject string `yaml:"subject"` + Sender string `yaml:"sender"` +} + +func parseFrontmatter(s string) (domain.MailMetadata, string, error) { + const marker = "---" + if !strings.HasPrefix(s, marker) { + return domain.MailMetadata{}, s, nil + } + + after := strings.TrimPrefix(s, marker) + after = strings.TrimPrefix(after, "\r\n") + after = strings.TrimPrefix(after, "\n") + + end := strings.Index(after, "\n---") + if end == -1 { + return domain.MailMetadata{}, "", fmt.Errorf("frontmatter opening marker has no closing marker") + } + + yamlSrc := after[:end] + body := after[end+4:] // skip \n--- + body = strings.TrimPrefix(body, "\r\n") + body = strings.TrimPrefix(body, "\n") + + var fm frontmatterFields + if err := yaml.Unmarshal([]byte(yamlSrc), &fm); err != nil { + return domain.MailMetadata{}, "", fmt.Errorf("parsing frontmatter yaml: %w", err) + } + + return domain.MailMetadata{Subject: fm.Subject, SenderName: fm.Sender}, body, nil +} + +func mergeData(base, extra map[string]any) map[string]any { + merged := make(map[string]any, len(base)+len(extra)) + for k, v := range base { + merged[k] = v + } + for k, v := range extra { + merged[k] = v + } + return merged +} diff --git a/renderer/goldmark_test.go b/renderer/goldmark_test.go new file mode 100644 index 0000000..6539da8 --- /dev/null +++ b/renderer/goldmark_test.go @@ -0,0 +1,177 @@ +package renderer + +import ( + "log/slog" + "strings" + "testing" + + "github.com/5000K/5000mails/domain" +) + +// layout that exposes both the html body and both metadata fields +const testLayout = `Subject:{{.metadata.Subject}} Sender:{{.metadata.SenderName}} +{{.html}}` + +func newRenderer(t *testing.T) *GoldmarkRenderer { + t.Helper() + r, err := NewGoldmarkRenderer([]byte(testLayout), nil, slog.Default()) + if err != nil { + t.Fatalf("NewGoldmarkRenderer: %v", err) + } + return r +} + +// ---------- parseFrontmatter unit tests ---------- + +func TestParseFrontmatter_ValidBlock(t *testing.T) { + input := "---\nsubject: \"Hello\"\nsender: \"Bot\"\n---\n# Body" + meta, body, err := parseFrontmatter(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Subject != "Hello" { + t.Errorf("expected Subject %q, got %q", "Hello", meta.Subject) + } + if meta.SenderName != "Bot" { + t.Errorf("expected SenderName %q, got %q", "Bot", meta.SenderName) + } + if !strings.HasPrefix(body, "# Body") { + t.Errorf("unexpected body: %q", body) + } +} + +func TestParseFrontmatter_NoFrontmatter(t *testing.T) { + input := "# Just markdown" + meta, body, err := parseFrontmatter(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta != (domain.MailMetadata{}) { + t.Errorf("expected empty metadata, got %+v", meta) + } + if body != input { + t.Errorf("expected body to equal input, got %q", body) + } +} + +func TestParseFrontmatter_UnclosedMarkerErrors(t *testing.T) { + input := "---\nsubject: oops\n" + _, _, err := parseFrontmatter(input) + if err == nil { + t.Fatal("expected error for unclosed frontmatter, got nil") + } +} + +func TestParseFrontmatter_InvalidYAMLErrors(t *testing.T) { + input := "---\n: bad: yaml: [\n---\n# body" + _, _, err := parseFrontmatter(input) + if err == nil { + t.Fatal("expected error for invalid YAML, got nil") + } +} + +// ---------- GoldmarkRenderer.Render ---------- + +func TestRender_MetadataExtracted(t *testing.T) { + r := newRenderer(t) + raw := "---\nsubject: \"Newsletter\"\nsender: \"Alice\"\n---\nHello." + meta, _, err := r.Render(&raw, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta.Subject != "Newsletter" { + t.Errorf("expected Subject %q, got %q", "Newsletter", meta.Subject) + } + if meta.SenderName != "Alice" { + t.Errorf("expected SenderName %q, got %q", "Alice", meta.SenderName) + } +} + +func TestRender_MarkdownConvertedToHTML(t *testing.T) { + r := newRenderer(t) + raw := "---\nsubject: \"S\"\nsender: \"B\"\n---\n**bold**" + _, body, err := r.Render(&raw, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(body, "bold") { + t.Errorf("expected bold in body, got:\n%s", body) + } +} + +func TestRender_ContentTemplatingApplied(t *testing.T) { + r := newRenderer(t) + raw := "---\nsubject: \"S\"\nsender: \"B\"\n---\nHello, {{.name}}!" + data := map[string]any{"name": "World"} + _, body, err := r.Render(&raw, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(body, "Hello, World!") { + t.Errorf("expected templated name in output, got:\n%s", body) + } +} + +func TestRender_LayoutTemplateReceivesHTMLAndMetadata(t *testing.T) { + r := newRenderer(t) + raw := "---\nsubject: \"Weekly\"\nsender: \"Bot\"\n---\n# Hi" + meta, body, err := r.Render(&raw, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(body, "Subject:"+meta.Subject) { + t.Errorf("expected Subject in body, got:\n%s", body) + } + if !strings.Contains(body, "Sender:"+meta.SenderName) { + t.Errorf("expected SenderName in body, got:\n%s", body) + } +} + +func TestRender_InvalidContentTemplateErrors(t *testing.T) { + r := newRenderer(t) + raw := "---\nsubject: S\nsender: B\n---\n{{.unclosed" + _, _, err := r.Render(&raw, nil) + if err == nil { + t.Fatal("expected error for invalid content template, got nil") + } +} + +func TestRender_InvalidLayoutTemplateErrors(t *testing.T) { + _, err := NewGoldmarkRenderer([]byte("{{.unclosed"), nil, slog.Default()) + if err == nil { + t.Fatal("expected error for invalid layout template, got nil") + } +} + +func TestRender_ThemeInjectedIntoLayout(t *testing.T) { + layout := `{{.html}}` + r, err := NewGoldmarkRenderer([]byte(layout), []byte("body{color:red}"), slog.Default()) + if err != nil { + t.Fatalf("NewGoldmarkRenderer: %v", err) + } + raw := "---\nsubject: S\nsender: B\n---\nhi" + _, body, err := r.Render(&raw, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(body, "") { + t.Errorf("expected theme in layout output, got:\n%s", body) + } +} + +func TestRender_ExtraDataPassedToLayout(t *testing.T) { + layout := `{{.customKey}}: {{.html}}` + r, err := NewGoldmarkRenderer([]byte(layout), nil, slog.Default()) + if err != nil { + t.Fatalf("NewGoldmarkRenderer: %v", err) + } + raw := "---\nsubject: S\nsender: B\n---\nhi" + data := map[string]any{"customKey": "injected"} + _, body, err := r.Render(&raw, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasPrefix(body, "injected:") { + t.Errorf("expected customKey in layout output, got:\n%s", body) + } +} diff --git a/service/fakes_test.go b/service/fakes_test.go new file mode 100644 index 0000000..de6f945 --- /dev/null +++ b/service/fakes_test.go @@ -0,0 +1,271 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/5000K/5000mails/domain" +) + +// fakeListRepo is an in-memory MailingListRepository. +type fakeListRepo struct { + lists map[uint]*domain.MailingList + nextID uint + + createErr error + getErr error + getByNameErr error + updateErr error + deleteErr error +} + +func newFakeListRepo(seed ...*domain.MailingList) *fakeListRepo { + r := &fakeListRepo{lists: make(map[uint]*domain.MailingList), nextID: 1} + for _, l := range seed { + r.lists[l.ID] = l + if l.ID >= r.nextID { + r.nextID = l.ID + 1 + } + } + return r +} + +func (r *fakeListRepo) CreateList(_ context.Context, name string) (*domain.MailingList, error) { + if r.createErr != nil { + return nil, r.createErr + } + l := &domain.MailingList{ID: r.nextID, Name: name} + r.nextID++ + r.lists[l.ID] = l + return l, nil +} + +func (r *fakeListRepo) GetList(_ context.Context, id uint) (*domain.MailingList, error) { + if r.getErr != nil { + return nil, r.getErr + } + l, ok := r.lists[id] + if !ok { + return nil, fmt.Errorf("list %d not found", id) + } + return l, nil +} + +func (r *fakeListRepo) GetListByName(_ context.Context, name string) (*domain.MailingList, error) { + if r.getByNameErr != nil { + return nil, r.getByNameErr + } + for _, l := range r.lists { + if l.Name == name { + return l, nil + } + } + return nil, fmt.Errorf("list %q not found", name) +} + +func (r *fakeListRepo) UpdateList(_ context.Context, id uint, name string) (*domain.MailingList, error) { + if r.updateErr != nil { + return nil, r.updateErr + } + l, ok := r.lists[id] + if !ok { + return nil, fmt.Errorf("list %d not found", id) + } + l.Name = name + return l, nil +} + +func (r *fakeListRepo) DeleteList(_ context.Context, id uint) error { + if r.deleteErr != nil { + return r.deleteErr + } + if _, ok := r.lists[id]; !ok { + return fmt.Errorf("list %d not found", id) + } + delete(r.lists, id) + return nil +} + +// fakeUserRepo is an in-memory UserRepository. +type fakeUserRepo struct { + users map[uint]*domain.User + nextID uint + + addErr error + confirmErr error + getByUnsubscribeTokenErr error + getUsersErr error + getConfirmedErr error + removeErr error +} + +func newFakeUserRepo(seed ...*domain.User) *fakeUserRepo { + r := &fakeUserRepo{users: make(map[uint]*domain.User), nextID: 1} + for _, u := range seed { + r.users[u.ID] = u + if u.ID >= r.nextID { + r.nextID = u.ID + 1 + } + } + return r +} + +func (r *fakeUserRepo) AddUser(_ context.Context, mailingListID uint, name, email, unsubscribeToken string) (*domain.User, error) { + if r.addErr != nil { + return nil, r.addErr + } + u := &domain.User{ID: r.nextID, Name: name, Email: email, MailingListID: mailingListID, UnsubscribeToken: unsubscribeToken} + r.nextID++ + r.users[u.ID] = u + return u, nil +} + +func (r *fakeUserRepo) ConfirmUser(_ context.Context, userID uint) error { + if r.confirmErr != nil { + return r.confirmErr + } + u, ok := r.users[userID] + if !ok { + return fmt.Errorf("user %d not found", userID) + } + now := time.Now() + u.ConfirmedAt = &now + return nil +} + +func (r *fakeUserRepo) GetUserByUnsubscribeToken(_ context.Context, token string) (*domain.User, error) { + if r.getByUnsubscribeTokenErr != nil { + return nil, r.getByUnsubscribeTokenErr + } + for _, u := range r.users { + if u.UnsubscribeToken == token { + return u, nil + } + } + return nil, fmt.Errorf("user with unsubscribe token %q not found", token) +} + +func (r *fakeUserRepo) GetUsers(_ context.Context, mailingListID uint) ([]domain.User, error) { + if r.getUsersErr != nil { + return nil, r.getUsersErr + } + var out []domain.User + for _, u := range r.users { + if u.MailingListID == mailingListID { + out = append(out, *u) + } + } + return out, nil +} + +func (r *fakeUserRepo) GetConfirmedUsers(_ context.Context, mailingListID uint) ([]domain.User, error) { + if r.getConfirmedErr != nil { + return nil, r.getConfirmedErr + } + var out []domain.User + for _, u := range r.users { + if u.MailingListID == mailingListID && u.ConfirmedAt != nil { + out = append(out, *u) + } + } + return out, nil +} + +func (r *fakeUserRepo) RemoveUser(_ context.Context, userID uint) error { + if r.removeErr != nil { + return r.removeErr + } + if _, ok := r.users[userID]; !ok { + return fmt.Errorf("user %d not found", userID) + } + delete(r.users, userID) + return nil +} + +// fakeConfirmationRepo is an in-memory ConfirmationRepository. +type fakeConfirmationRepo struct { + confirmations map[uint]*domain.Confirmation + nextID uint + + createErr error + getErr error + deleteErr error +} + +func newFakeConfirmationRepo(seed ...*domain.Confirmation) *fakeConfirmationRepo { + r := &fakeConfirmationRepo{confirmations: make(map[uint]*domain.Confirmation), nextID: 1} + for _, c := range seed { + r.confirmations[c.ID] = c + if c.ID >= r.nextID { + r.nextID = c.ID + 1 + } + } + return r +} + +func (r *fakeConfirmationRepo) CreateConfirmation(_ context.Context, userID uint, token string) (*domain.Confirmation, error) { + if r.createErr != nil { + return nil, r.createErr + } + c := &domain.Confirmation{ID: r.nextID, UserID: userID, Token: token} + r.nextID++ + r.confirmations[c.ID] = c + return c, nil +} + +func (r *fakeConfirmationRepo) GetConfirmationByToken(_ context.Context, token string) (*domain.Confirmation, error) { + if r.getErr != nil { + return nil, r.getErr + } + for _, c := range r.confirmations { + if c.Token == token { + return c, nil + } + } + return nil, fmt.Errorf("confirmation token not found") +} + +func (r *fakeConfirmationRepo) DeleteConfirmation(_ context.Context, id uint) error { + if r.deleteErr != nil { + return r.deleteErr + } + if _, ok := r.confirmations[id]; !ok { + return fmt.Errorf("confirmation %d not found", id) + } + delete(r.confirmations, id) + return nil +} + +// fakeSender records SendMail calls. +type fakeSender struct { + calls []sendCall + err error +} + +type sendCall struct { + metadata domain.MailMetadata + body string + recipient domain.User +} + +func (s *fakeSender) SendMail(_ context.Context, metadata domain.MailMetadata, body string, recipient domain.User) error { + if s.err != nil { + return s.err + } + s.calls = append(s.calls, sendCall{metadata: metadata, body: body, recipient: recipient}) + return nil +} + +// fakeRenderer returns configurable metadata / body. +type fakeRenderer struct { + metadata domain.MailMetadata + body string + err error + lastData map[string]any +} + +func (r *fakeRenderer) Render(_ *string, data map[string]any) (domain.MailMetadata, string, error) { + r.lastData = data + return r.metadata, r.body, r.err +} diff --git a/service/list.go b/service/list.go new file mode 100644 index 0000000..a3f53a7 --- /dev/null +++ b/service/list.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "fmt" + + "github.com/5000K/5000mails/domain" +) + +// ListService manages mailing list CRUD and user counts. +type ListService struct { + lists domain.MailingListRepository + users domain.UserRepository +} + +// NewListService creates a new ListService. +func NewListService(lists domain.MailingListRepository, users domain.UserRepository) *ListService { + return &ListService{lists: lists, users: users} +} + +// Create creates a new mailing list with the given name. +func (s *ListService) Create(ctx context.Context, name string) (*domain.MailingList, error) { + list, err := s.lists.CreateList(ctx, name) + if err != nil { + return nil, fmt.Errorf("creating list %q: %w", name, err) + } + return list, nil +} + +// Get returns a mailing list by its ID. +func (s *ListService) Get(ctx context.Context, id uint) (*domain.MailingList, error) { + list, err := s.lists.GetList(ctx, id) + if err != nil { + return nil, fmt.Errorf("getting list %d: %w", id, err) + } + return list, nil +} + +// GetByName returns a mailing list by its name. +func (s *ListService) GetByName(ctx context.Context, name string) (*domain.MailingList, error) { + list, err := s.lists.GetListByName(ctx, name) + if err != nil { + return nil, fmt.Errorf("getting list %q: %w", name, err) + } + return list, nil +} + +// Rename renames a mailing list. +func (s *ListService) Rename(ctx context.Context, id uint, newName string) (*domain.MailingList, error) { + list, err := s.lists.UpdateList(ctx, id, newName) + if err != nil { + return nil, fmt.Errorf("renaming list %d: %w", id, err) + } + return list, nil +} + +// Delete deletes a mailing list by its ID. +func (s *ListService) Delete(ctx context.Context, id uint) error { + if err := s.lists.DeleteList(ctx, id); err != nil { + return fmt.Errorf("deleting list %d: %w", id, err) + } + return nil +} + +// CountUsers returns the total and confirmed subscriber counts for a mailing list. +func (s *ListService) CountUsers(ctx context.Context, listID uint) (domain.UserCounts, error) { + all, err := s.users.GetUsers(ctx, listID) + if err != nil { + return domain.UserCounts{}, fmt.Errorf("getting users for list %d: %w", listID, err) + } + + confirmed, err := s.users.GetConfirmedUsers(ctx, listID) + if err != nil { + return domain.UserCounts{}, fmt.Errorf("getting confirmed users for list %d: %w", listID, err) + } + + return domain.UserCounts{ + Total: len(all), + Confirmed: len(confirmed), + }, nil +} + +// Users returns all subscribers for a mailing list, confirmed or not. +func (s *ListService) Users(ctx context.Context, listID uint) ([]domain.User, error) { + users, err := s.users.GetUsers(ctx, listID) + if err != nil { + return nil, fmt.Errorf("getting users for list %d: %w", listID, err) + } + return users, nil +} diff --git a/service/list_test.go b/service/list_test.go new file mode 100644 index 0000000..24ff895 --- /dev/null +++ b/service/list_test.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/5000K/5000mails/domain" +) + +func TestListService_Create(t *testing.T) { + t.Run("returns new list on success", func(t *testing.T) { + svc := NewListService(newFakeListRepo(), newFakeUserRepo()) + list, err := svc.Create(context.Background(), "newsletter") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if list.Name != "newsletter" { + t.Errorf("expected name %q, got %q", "newsletter", list.Name) + } + }) + + t.Run("wraps repo error", func(t *testing.T) { + repo := newFakeListRepo() + repo.createErr = errors.New("db failure") + svc := NewListService(repo, newFakeUserRepo()) + _, err := svc.Create(context.Background(), "newsletter") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, repo.createErr) { + t.Errorf("expected error to wrap repo error, got: %v", err) + } + }) +} + +func TestListService_Get(t *testing.T) { + list := &domain.MailingList{ID: 1, Name: "weekly"} + + t.Run("returns list by ID", func(t *testing.T) { + svc := NewListService(newFakeListRepo(list), newFakeUserRepo()) + got, err := svc.Get(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ID != list.ID || got.Name != list.Name { + t.Errorf("got %+v, want %+v", got, list) + } + }) + + t.Run("wraps repo error", func(t *testing.T) { + repo := newFakeListRepo() + repo.getErr = errors.New("not found") + svc := NewListService(repo, newFakeUserRepo()) + _, err := svc.Get(context.Background(), 99) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, repo.getErr) { + t.Errorf("expected wrapped repo error, got: %v", err) + } + }) +} + +func TestListService_GetByName(t *testing.T) { + list := &domain.MailingList{ID: 2, Name: "monthly"} + + t.Run("returns list by name", func(t *testing.T) { + svc := NewListService(newFakeListRepo(list), newFakeUserRepo()) + got, err := svc.GetByName(context.Background(), "monthly") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "monthly" { + t.Errorf("expected name %q, got %q", "monthly", got.Name) + } + }) + + t.Run("wraps repo error for unknown name", func(t *testing.T) { + repo := newFakeListRepo() + repo.getByNameErr = errors.New("not found") + svc := NewListService(repo, newFakeUserRepo()) + _, err := svc.GetByName(context.Background(), "ghost") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, repo.getByNameErr) { + t.Errorf("expected wrapped repo error, got: %v", err) + } + }) +} + +func TestListService_Rename(t *testing.T) { + list := &domain.MailingList{ID: 3, Name: "old-name"} + + t.Run("updates list name", func(t *testing.T) { + svc := NewListService(newFakeListRepo(list), newFakeUserRepo()) + got, err := svc.Rename(context.Background(), 3, "new-name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Name != "new-name" { + t.Errorf("expected %q, got %q", "new-name", got.Name) + } + }) + + t.Run("wraps repo error", func(t *testing.T) { + repo := newFakeListRepo() + repo.updateErr = errors.New("update failed") + svc := NewListService(repo, newFakeUserRepo()) + _, err := svc.Rename(context.Background(), 3, "new-name") + if !errors.Is(err, repo.updateErr) { + t.Errorf("expected wrapped repo error, got: %v", err) + } + }) +} + +func TestListService_Delete(t *testing.T) { + list := &domain.MailingList{ID: 4, Name: "doomed"} + + t.Run("deletes list", func(t *testing.T) { + repo := newFakeListRepo(list) + svc := NewListService(repo, newFakeUserRepo()) + if err := svc.Delete(context.Background(), 4); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, exists := repo.lists[4]; exists { + t.Error("expected list to be deleted") + } + }) + + t.Run("wraps repo error", func(t *testing.T) { + repo := newFakeListRepo() + repo.deleteErr = errors.New("delete failed") + svc := NewListService(repo, newFakeUserRepo()) + err := svc.Delete(context.Background(), 4) + if !errors.Is(err, repo.deleteErr) { + t.Errorf("expected wrapped repo error, got: %v", err) + } + }) +} + +func TestListService_CountUsers(t *testing.T) { + now := time.Now() + users := []*domain.User{ + {ID: 1, MailingListID: 10, Email: "a@example.com", ConfirmedAt: &now}, + {ID: 2, MailingListID: 10, Email: "b@example.com", ConfirmedAt: nil}, + {ID: 3, MailingListID: 10, Email: "c@example.com", ConfirmedAt: &now}, + } + + t.Run("counts total and confirmed users", func(t *testing.T) { + svc := NewListService(newFakeListRepo(), newFakeUserRepo(users...)) + counts, err := svc.CountUsers(context.Background(), 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if counts.Total != 3 { + t.Errorf("expected Total=3, got %d", counts.Total) + } + if counts.Confirmed != 2 { + t.Errorf("expected Confirmed=2, got %d", counts.Confirmed) + } + }) + + t.Run("wraps GetUsers error", func(t *testing.T) { + repo := newFakeUserRepo() + repo.getUsersErr = errors.New("db down") + svc := NewListService(newFakeListRepo(), repo) + _, err := svc.CountUsers(context.Background(), 10) + if !errors.Is(err, repo.getUsersErr) { + t.Errorf("expected wrapped error, got: %v", err) + } + }) + + t.Run("wraps GetConfirmedUsers error", func(t *testing.T) { + repo := newFakeUserRepo(users...) + repo.getConfirmedErr = errors.New("confirmed query failed") + svc := NewListService(newFakeListRepo(), repo) + _, err := svc.CountUsers(context.Background(), 10) + if !errors.Is(err, repo.getConfirmedErr) { + t.Errorf("expected wrapped error, got: %v", err) + } + }) +} diff --git a/service/mail.go b/service/mail.go new file mode 100644 index 0000000..dee6df7 --- /dev/null +++ b/service/mail.go @@ -0,0 +1,88 @@ +package service + +import ( + "context" + "fmt" + + "github.com/5000K/5000mails/domain" +) + +// MailService renders markdown content and dispatches it to mailing list +// recipients or arbitrary test addresses. +type MailService struct { + lists domain.MailingListRepository + users domain.UserRepository + renderer domain.Renderer + sender domain.Sender +} + +// NewMailService creates a new MailService. +func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, renderer domain.Renderer, sender domain.Sender) *MailService { + return &MailService{ + lists: lists, + users: users, + renderer: renderer, + sender: sender, + } +} + +// SendToList renders raw and sends the resulting mail to every confirmed +// subscriber of the mailing list identified by listName. +// data is passed through to the renderer as template variables. +func (s *MailService) SendToList(ctx context.Context, listName string, raw string, data map[string]any) error { + list, err := s.lists.GetListByName(ctx, listName) + if err != nil { + return fmt.Errorf("looking up list %q: %w", listName, err) + } + + recipients, err := s.users.GetConfirmedUsers(ctx, list.ID) + if err != nil { + return fmt.Errorf("getting confirmed users for list %q: %w", listName, err) + } + + if len(recipients) == 0 { + return nil + } + + for _, recipient := range recipients { + recipientData := make(map[string]any, len(data)+1) + for k, v := range data { + recipientData[k] = v + } + recipientData["Recipient"] = recipient + + metadata, body, err := s.renderer.Render(&raw, recipientData) + if err != nil { + return fmt.Errorf("rendering mail for %q: %w", recipient.Email, err) + } + + if err := s.sender.SendMail(ctx, metadata, body, recipient); err != nil { + return fmt.Errorf("sending mail to %q: %w", recipient.Email, err) + } + } + + return nil +} + +// SendTestMail renders raw and sends the resulting mail to the given user. +// The user is passed in directly and is not looked up from the database, +// making this suitable for previewing a newsletter before a real dispatch. +// data is passed through to the renderer as template variables. +func (s *MailService) SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error { + recipientData := make(map[string]any, len(data)+1) + for k, v := range data { + recipientData[k] = v + } + recipientData["Recipient"] = recipient + + metadata, body, err := s.renderer.Render(&raw, recipientData) + if err != nil { + return fmt.Errorf("rendering test mail for %q: %w", recipient.Email, err) + } + + if err := s.sender.SendMail(ctx, metadata, body, recipient); err != nil { + return fmt.Errorf("sending test mail to %q: %w", recipient.Email, err) + } + + return nil +} diff --git a/service/mail_test.go b/service/mail_test.go new file mode 100644 index 0000000..d2e92d2 --- /dev/null +++ b/service/mail_test.go @@ -0,0 +1,193 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/5000K/5000mails/domain" +) + +func confirmedUser(id uint, listID uint, email string) *domain.User { + now := time.Now() + return &domain.User{ID: id, MailingListID: listID, Email: email, Name: "Test", ConfirmedAt: &now} +} + +func TestMailService_SendToList(t *testing.T) { + metadata := domain.MailMetadata{Subject: "Hello", SenderName: "Bot"} + list := &domain.MailingList{ID: 5, Name: "weekly"} + + t.Run("skips send when no confirmed recipients", func(t *testing.T) { + sender := &fakeSender{} + svc := NewMailService( + newFakeListRepo(list), + newFakeUserRepo(), + &fakeRenderer{metadata: metadata, body: "body"}, + sender, + ) + if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sender.calls) != 0 { + t.Errorf("expected no send calls, got %d", len(sender.calls)) + } + }) + + t.Run("renders and sends to confirmed recipients", func(t *testing.T) { + users := newFakeUserRepo( + confirmedUser(1, 5, "alice@example.com"), + confirmedUser(2, 5, "bob@example.com"), + ) + sender := &fakeSender{} + svc := NewMailService(newFakeListRepo(list), users, &fakeRenderer{metadata: metadata, body: "rendered"}, sender) + + if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sender.calls) != 2 { + t.Fatalf("expected 2 send calls, got %d", len(sender.calls)) + } + emails := map[string]bool{} + for _, call := range sender.calls { + emails[call.recipient.Email] = true + if call.body != "rendered" { + t.Errorf("expected body %q, got %q", "rendered", call.body) + } + if call.metadata != metadata { + t.Errorf("expected metadata %+v, got %+v", metadata, call.metadata) + } + } + if !emails["alice@example.com"] || !emails["bob@example.com"] { + t.Errorf("expected both recipients to receive mail, got: %v", emails) + } + }) + + t.Run("injects Recipient into render data per recipient", func(t *testing.T) { + user := confirmedUser(1, 5, "alice@example.com") + renderer := &fakeRenderer{metadata: metadata, body: "body"} + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), renderer, &fakeSender{}) + + if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, ok := renderer.lastData["Recipient"] + if !ok { + t.Fatal("expected Recipient key in render data") + } + if u, ok := got.(domain.User); !ok || u.Email != user.Email { + t.Errorf("unexpected Recipient in render data: %+v", got) + } + }) + + t.Run("wraps GetListByName error", func(t *testing.T) { + listRepo := newFakeListRepo() + listRepo.getByNameErr = errors.New("list missing") + svc := NewMailService(listRepo, newFakeUserRepo(), &fakeRenderer{}, &fakeSender{}) + err := svc.SendToList(context.Background(), "ghost", "raw", nil) + if !errors.Is(err, listRepo.getByNameErr) { + t.Errorf("expected wrapped error, got: %v", err) + } + }) + + t.Run("wraps GetConfirmedUsers error", func(t *testing.T) { + userRepo := newFakeUserRepo() + userRepo.getConfirmedErr = errors.New("db down") + svc := NewMailService(newFakeListRepo(list), userRepo, &fakeRenderer{}, &fakeSender{}) + err := svc.SendToList(context.Background(), "weekly", "raw", nil) + if !errors.Is(err, userRepo.getConfirmedErr) { + t.Errorf("expected wrapped error, got: %v", err) + } + }) + + t.Run("wraps renderer error", func(t *testing.T) { + renderErr := errors.New("template broken") + svc := NewMailService( + newFakeListRepo(list), + newFakeUserRepo(confirmedUser(1, 5, "a@example.com")), + &fakeRenderer{err: renderErr}, + &fakeSender{}, + ) + err := svc.SendToList(context.Background(), "weekly", "raw", nil) + if !errors.Is(err, renderErr) { + t.Errorf("expected wrapped render error, got: %v", err) + } + }) + + t.Run("wraps sender error", func(t *testing.T) { + sendErr := errors.New("smtp refused") + svc := NewMailService( + newFakeListRepo(list), + newFakeUserRepo(confirmedUser(1, 5, "a@example.com")), + &fakeRenderer{metadata: metadata, body: "body"}, + &fakeSender{err: sendErr}, + ) + err := svc.SendToList(context.Background(), "weekly", "raw", nil) + if !errors.Is(err, sendErr) { + t.Errorf("expected wrapped send error, got: %v", err) + } + }) +} + +func TestMailService_SendTestMail(t *testing.T) { + metadata := domain.MailMetadata{Subject: "Test", SenderName: "Bot"} + recipient := domain.User{ID: 1, Email: "dev@example.com", Name: "Dev"} + + t.Run("renders and sends to given recipient", func(t *testing.T) { + sender := &fakeSender{} + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), &fakeRenderer{metadata: metadata, body: "preview"}, sender) + + if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sender.calls) != 1 { + t.Fatalf("expected 1 send call, got %d", len(sender.calls)) + } + call := sender.calls[0] + if call.recipient.Email != recipient.Email { + t.Errorf("unexpected recipient: %+v", call.recipient) + } + if call.body != "preview" { + t.Errorf("expected body %q, got %q", "preview", call.body) + } + }) + + t.Run("injects Recipient into render data", func(t *testing.T) { + renderer := &fakeRenderer{metadata: metadata, body: "body"} + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), renderer, &fakeSender{}) + + if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, ok := renderer.lastData["Recipient"] + if !ok { + t.Fatal("expected Recipient key in render data") + } + if u, ok := got.(domain.User); !ok || u.Email != recipient.Email { + t.Errorf("unexpected Recipient in render data: %+v", got) + } + }) + + t.Run("wraps renderer error", func(t *testing.T) { + renderErr := errors.New("bad template") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}) + err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil) + if !errors.Is(err, renderErr) { + t.Errorf("expected wrapped render error, got: %v", err) + } + }) + + t.Run("wraps sender error", func(t *testing.T) { + sendErr := errors.New("smtp gone") + svc := NewMailService( + newFakeListRepo(), + newFakeUserRepo(), + &fakeRenderer{metadata: metadata, body: "body"}, + &fakeSender{err: sendErr}, + ) + err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil) + if !errors.Is(err, sendErr) { + t.Errorf("expected wrapped send error, got: %v", err) + } + }) +} diff --git a/service/subscription.go b/service/subscription.go new file mode 100644 index 0000000..5b80512 --- /dev/null +++ b/service/subscription.go @@ -0,0 +1,127 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + + "github.com/5000K/5000mails/domain" +) + +// SubscriptionService manages user subscriptions to mailing lists. +type SubscriptionService struct { + lists domain.MailingListRepository + users domain.UserRepository + confirmations domain.ConfirmationRepository + renderer domain.Renderer + sender domain.Sender + confirmMail string // raw markdown template for the confirmation mail + baseURL string +} + +// NewSubscriptionService creates a new SubscriptionService. +func NewSubscriptionService( + lists domain.MailingListRepository, + users domain.UserRepository, + confirmations domain.ConfirmationRepository, + renderer domain.Renderer, + sender domain.Sender, + confirmMail string, + baseURL string, +) *SubscriptionService { + return &SubscriptionService{ + lists: lists, + users: users, + confirmations: confirmations, + renderer: renderer, + sender: sender, + confirmMail: confirmMail, + baseURL: baseURL, + } +} + +// Subscribe adds a user to the mailing list with the given name and sends a +// confirmation mail to the user's address. +// Returns an error if the mailing list does not exist. +func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, email string) (*domain.User, error) { + list, err := s.lists.GetListByName(ctx, listName) + if err != nil { + return nil, fmt.Errorf("mailing list %q not found: %w", listName, err) + } + + unsubToken, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generating unsubscribe token: %w", err) + } + + user, err := s.users.AddUser(ctx, list.ID, userName, email, unsubToken) + if err != nil { + return nil, fmt.Errorf("adding user to list %q: %w", listName, err) + } + + token, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generating confirmation token: %w", err) + } + + if _, err := s.confirmations.CreateConfirmation(ctx, user.ID, token); err != nil { + return nil, fmt.Errorf("creating confirmation for user %d: %w", user.ID, err) + } + + metadata, body, err := s.renderer.Render(&s.confirmMail, map[string]any{ + "token": token, + "confirmURL": s.baseURL + "/confirm/" + token, + "Recipient": *user, + }) + if err != nil { + return nil, fmt.Errorf("rendering confirmation mail: %w", err) + } + + if err := s.sender.SendMail(ctx, metadata, body, *user); err != nil { + return nil, fmt.Errorf("sending confirmation mail to %q: %w", email, err) + } + + return user, nil +} + +// Confirm completes the double opt-in for the confirmation identified by token. +func (s *SubscriptionService) Confirm(ctx context.Context, token string) error { + confirmation, err := s.confirmations.GetConfirmationByToken(ctx, token) + if err != nil { + return fmt.Errorf("looking up confirmation token: %w", err) + } + + if err := s.users.ConfirmUser(ctx, confirmation.UserID); err != nil { + return fmt.Errorf("confirming user %d: %w", confirmation.UserID, err) + } + + if err := s.confirmations.DeleteConfirmation(ctx, confirmation.ID); err != nil { + return fmt.Errorf("deleting used confirmation %d: %w", confirmation.ID, err) + } + + return nil +} + +// Unsubscribe removes a user identified by their unsubscribe token. +func (s *SubscriptionService) Unsubscribe(ctx context.Context, unsubscribeToken string) error { + user, err := s.users.GetUserByUnsubscribeToken(ctx, unsubscribeToken) + if err != nil { + return fmt.Errorf("user with unsubscribe token not found: %w", err) + } + + if err := s.users.RemoveUser(ctx, user.ID); err != nil { + return fmt.Errorf("removing user %d: %w", user.ID, err) + } + + return nil +} + +// generateToken returns a cryptographically random 32-byte hex token. +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/service/subscription_test.go b/service/subscription_test.go new file mode 100644 index 0000000..fc75fa0 --- /dev/null +++ b/service/subscription_test.go @@ -0,0 +1,219 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/5000K/5000mails/domain" +) + +func newSubscriptionSvc( + lists *fakeListRepo, + users *fakeUserRepo, + confs *fakeConfirmationRepo, + renderer *fakeRenderer, + sender *fakeSender, +) *SubscriptionService { + return NewSubscriptionService(lists, users, confs, renderer, sender, "# Confirm your subscription\nToken: {{.token}}", "https://example.com") +} + +func TestSubscriptionService_Subscribe(t *testing.T) { + metadata := domain.MailMetadata{Subject: "Confirm", SenderName: "Bot"} + list := &domain.MailingList{ID: 1, Name: "weekly"} + + t.Run("adds user, creates confirmation, sends mail", func(t *testing.T) { + users := newFakeUserRepo() + confs := newFakeConfirmationRepo() + sender := &fakeSender{} + svc := newSubscriptionSvc(newFakeListRepo(list), users, confs, &fakeRenderer{metadata: metadata, body: "click here"}, sender) + + user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.Email != "alice@example.com" { + t.Errorf("unexpected user email: %s", user.Email) + } + if len(confs.confirmations) != 1 { + t.Errorf("expected 1 confirmation, got %d", len(confs.confirmations)) + } + if len(sender.calls) != 1 { + t.Fatalf("expected 1 send call, got %d", len(sender.calls)) + } + if sender.calls[0].recipient.Email != "alice@example.com" { + t.Errorf("unexpected recipient: %+v", sender.calls[0].recipient) + } + }) + + t.Run("passes Recipient in render data", func(t *testing.T) { + renderer := &fakeRenderer{metadata: metadata, body: "click here"} + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), renderer, &fakeSender{}) + + user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, ok := renderer.lastData["Recipient"] + if !ok { + t.Fatal("expected Recipient key in render data") + } + if u, ok := got.(domain.User); !ok || u.Email != user.Email { + t.Errorf("unexpected Recipient in render data: %+v", got) + } + }) + + t.Run("injects confirmURL into render data", func(t *testing.T) { + renderer := &fakeRenderer{metadata: metadata, body: "click here"} + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), renderer, &fakeSender{}) + + if _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + token, _ := renderer.lastData["token"].(string) + wantURL := "https://example.com/confirm/" + token + if got, _ := renderer.lastData["confirmURL"].(string); got != wantURL { + t.Errorf("confirmURL = %q, want %q", got, wantURL) + } + }) + + t.Run("returns error when GetListByName fails", func(t *testing.T) { + repo := newFakeListRepo() + repo.getByNameErr = errors.New("list missing") + svc := newSubscriptionSvc(repo, newFakeUserRepo(), newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "ghost", "Bob", "bob@example.com") + if !errors.Is(err, repo.getByNameErr) { + t.Errorf("expected wrapped list error, got: %v", err) + } + }) + + t.Run("returns error when AddUser fails", func(t *testing.T) { + users := newFakeUserRepo() + users.addErr = errors.New("duplicate email") + svc := newSubscriptionSvc(newFakeListRepo(list), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + if !errors.Is(err, users.addErr) { + t.Errorf("expected wrapped add error, got: %v", err) + } + }) + + t.Run("returns error when CreateConfirmation fails", func(t *testing.T) { + confs := newFakeConfirmationRepo() + confs.createErr = errors.New("db full") + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), confs, &fakeRenderer{}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + if !errors.Is(err, confs.createErr) { + t.Errorf("expected wrapped confirmation error, got: %v", err) + } + }) + + t.Run("returns error when Render fails", func(t *testing.T) { + renderErr := errors.New("bad template") + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + if !errors.Is(err, renderErr) { + t.Errorf("expected wrapped render error, got: %v", err) + } + }) + + t.Run("returns error when SendMail fails", func(t *testing.T) { + sendErr := errors.New("smtp gone") + metadata := domain.MailMetadata{Subject: "Confirm"} + svc := newSubscriptionSvc( + newFakeListRepo(list), + newFakeUserRepo(), + newFakeConfirmationRepo(), + &fakeRenderer{metadata: metadata, body: "ok"}, + &fakeSender{err: sendErr}, + ) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + if !errors.Is(err, sendErr) { + t.Errorf("expected wrapped send error, got: %v", err) + } + }) +} + +func TestSubscriptionService_Confirm(t *testing.T) { + t.Run("confirms user and deletes confirmation", func(t *testing.T) { + users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListID: 1}) + confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "abc123"}) + svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) + + if err := svc.Confirm(context.Background(), "abc123"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !users.users[1].IsConfirmed() { + t.Error("expected user to be confirmed") + } + if len(confs.confirmations) != 0 { + t.Errorf("expected confirmation to be deleted, got %d remaining", len(confs.confirmations)) + } + }) + + t.Run("returns error for unknown token", func(t *testing.T) { + confs := newFakeConfirmationRepo() + confs.getErr = errors.New("token not found") + svc := newSubscriptionSvc(newFakeListRepo(), newFakeUserRepo(), confs, &fakeRenderer{}, &fakeSender{}) + err := svc.Confirm(context.Background(), "bad-token") + if !errors.Is(err, confs.getErr) { + t.Errorf("expected wrapped token error, got: %v", err) + } + }) + + t.Run("returns error when ConfirmUser fails", func(t *testing.T) { + users := newFakeUserRepo() + users.confirmErr = errors.New("confirm failed") + confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 99, Token: "tok"}) + svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) + err := svc.Confirm(context.Background(), "tok") + if !errors.Is(err, users.confirmErr) { + t.Errorf("expected wrapped confirm error, got: %v", err) + } + }) + + t.Run("returns error when DeleteConfirmation fails", func(t *testing.T) { + users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListID: 1}) + confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "tok"}) + confs.deleteErr = errors.New("delete failed") + svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) + err := svc.Confirm(context.Background(), "tok") + if !errors.Is(err, confs.deleteErr) { + t.Errorf("expected wrapped delete error, got: %v", err) + } + }) +} + +func TestSubscriptionService_Unsubscribe(t *testing.T) { + u := &domain.User{ID: 1, Email: "alice@example.com", MailingListID: 1, UnsubscribeToken: "tok-alice"} + + t.Run("removes user by unsubscribe token", func(t *testing.T) { + users := newFakeUserRepo(u) + svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + if err := svc.Unsubscribe(context.Background(), "tok-alice"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, exists := users.users[1]; exists { + t.Error("expected user to be removed") + } + }) + + t.Run("returns error when token not found", func(t *testing.T) { + users := newFakeUserRepo() + users.getByUnsubscribeTokenErr = errors.New("not found") + svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + err := svc.Unsubscribe(context.Background(), "bad-token") + if !errors.Is(err, users.getByUnsubscribeTokenErr) { + t.Errorf("expected wrapped error, got: %v", err) + } + }) + + t.Run("returns error when RemoveUser fails", func(t *testing.T) { + users := newFakeUserRepo(u) + users.removeErr = errors.New("delete failed") + svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + err := svc.Unsubscribe(context.Background(), "tok-alice") + if !errors.Is(err, users.removeErr) { + t.Errorf("expected wrapped error, got: %v", err) + } + }) +} diff --git a/smtp/sender.go b/smtp/sender.go new file mode 100644 index 0000000..baeb7d5 --- /dev/null +++ b/smtp/sender.go @@ -0,0 +1,80 @@ +package smtp + +import ( + "context" + "fmt" + "log/slog" + + gomail "github.com/wneessen/go-mail" + + "github.com/5000K/5000mails/config" + "github.com/5000K/5000mails/domain" +) + +type Sender struct { + client *gomail.Client + senderEmail string + logger *slog.Logger +} + +func tlsPolicy(p config.TLSPolicy) gomail.TLSPolicy { + switch p { + case config.TLSMandatory: + return gomail.TLSMandatory + case config.NoTLS: + return gomail.NoTLS + default: + return gomail.TLSOpportunistic + } +} + +func NewSender(cfg config.SmtpConfig, logger *slog.Logger) (*Sender, error) { + client, err := gomail.NewClient( + cfg.Host, + gomail.WithPort(cfg.Port), + gomail.WithSMTPAuth(gomail.SMTPAuthPlain), + gomail.WithUsername(cfg.Username), + gomail.WithPassword(cfg.Password), + gomail.WithTLSPolicy(tlsPolicy(cfg.TLSPolicy)), + ) + if err != nil { + return nil, fmt.Errorf("creating smtp client: %w", err) + } + + return &Sender{ + client: client, + senderEmail: cfg.SenderEmail, + logger: logger, + }, nil +} + +func (s *Sender) SendMail(ctx context.Context, metadata domain.MailMetadata, body string, recipient domain.User) error { + msg := gomail.NewMsg() + + if err := msg.FromFormat(metadata.SenderName, s.senderEmail); err != nil { + return fmt.Errorf("setting from address: %w", err) + } + + if err := msg.AddToFormat(recipient.Name, recipient.Email); err != nil { + return fmt.Errorf("setting to address for %q: %w", recipient.Email, err) + } + + msg.Subject(metadata.Subject) + msg.SetBodyString(gomail.TypeTextHTML, body) + + if err := s.client.DialAndSendWithContext(ctx, msg); err != nil { + s.logger.ErrorContext(ctx, "failed to send mail", + slog.String("recipient", recipient.Email), + slog.String("subject", metadata.Subject), + slog.Any("error", err), + ) + return fmt.Errorf("sending mail to %q: %w", recipient.Email, err) + } + + s.logger.InfoContext(ctx, "mail sent", + slog.String("recipient", recipient.Email), + slog.String("subject", metadata.Subject), + ) + + return nil +} diff --git a/static/confirm.md b/static/confirm.md new file mode 100644 index 0000000..b20beff --- /dev/null +++ b/static/confirm.md @@ -0,0 +1,15 @@ +--- +subject: "Please confirm your subscription" +sender: "Your Newsletter Name" +--- +Hi {{.Recipient.Name}}, + +Thanks for signing up! Click the button below to confirm your email address and activate your subscription. + +[Confirm my subscription]({{.confirmURL}}) + +If you did not sign up, you can safely ignore this email — nothing will change. + +--- + +*This link is personal and expires once used. Do not share it.* diff --git a/static/template.html b/static/template.html new file mode 100644 index 0000000..46d1436 --- /dev/null +++ b/static/template.html @@ -0,0 +1,132 @@ + + + + + + {{.metadata.Subject}} + + + +
+ {{.html}} +
+ + diff --git a/static/theme.example.css b/static/theme.example.css new file mode 100644 index 0000000..74757a6 --- /dev/null +++ b/static/theme.example.css @@ -0,0 +1,64 @@ +/* ── Non-colour tokens: same for both modes ── */ +:root { + /* Layout */ + --content-width: 48rem; + --spacing-page-h: 1rem; + --spacing-page-h-wide: 1.5rem; + --spacing-page-v: 2.5rem; + --gap-base: 0.5rem; + + /* Shape */ + --radius-sm: 3px; + --radius-md: 4px; + --radius-lg: 6px; + + /* Typography */ + --font-body: system-ui, -apple-system, sans-serif; + --font-heading: system-ui, -apple-system, sans-serif; + --font-mono: 'SFMono-Regular', Menlo, Consolas, monospace; + --font-size-base: 1rem; +} + +/* ── Light mode (default) ── */ +:root { + --color-bg: #f8f8f8; + --color-surface: #eeeeee; + --color-border: #cccccc; + --color-text: #2a2a2a; + --color-text-heading: #111111; + --color-text-muted: #666666; + --color-text-dim: #aaaaaa; + --color-header-bg: #1a1a1a; + --color-header-border: #2a2a2a; + --color-header-brand: #f0f0f0; + --color-header-nav: #888888; + --color-accent: #2a6bca; + --color-accent-hover: #1a5ab8; + --color-tag-bg: #ddeeff; + --color-tag-text: #1a4f8f; + --color-code-bg: #efefef; + --color-code-text: #2a2a2a; +} + +/* ── Dark mode ── */ +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #111111; + --color-surface: #1a1a1a; + --color-border: #2a2a2a; + --color-text: #d8d8d8; + --color-text-heading: #f0f0f0; + --color-text-muted: #888888; + --color-text-dim: #555555; + --color-header-bg: #111111; + --color-header-border: #1e1e1e; + --color-header-brand: #f0f0f0; + --color-header-nav: #555555; + --color-accent: #7eb8f7; + --color-accent-hover: #a8d0ff; + --color-tag-bg: #1a1a2e; + --color-tag-text: #7eb8f7; + --color-code-bg: #1e1e2e; + --color-code-text: #c8d3f5; + } +} \ No newline at end of file diff --git a/static/theme.md b/static/theme.md new file mode 100644 index 0000000..9045348 --- /dev/null +++ b/static/theme.md @@ -0,0 +1,50 @@ +## Theme variable reference + +### Colors + +| Variable | Purpose | +|---|---| +| `--color-bg` | Main page/body background | +| `--color-surface` | Slightly elevated surface - card backgrounds, banded sections, table header cells | +| `--color-border` | Dividers, rule lines, table and code block outlines | +| `--color-text` | Default body and prose text | +| `--color-text-heading` | Headings and high-emphasis text | +| `--color-text-muted` | Secondary labels - dates, author lines, descriptions | +| `--color-text-dim` | Tertiary / very subtle text - page counters, section separators, tag row labels | +| `--color-header-bg` | Site header bar background | +| `--color-header-border` | Header bottom border | +| `--color-header-brand` | Site name / brand text in the header | +| `--color-header-nav` | Navigation link text in the header | +| `--color-accent` | Primary interactive color - links, blockquote accents, focus rings | +| `--color-accent-hover` | Hover / active state of accent elements | +| `--color-tag-bg` | Tag badge background | +| `--color-tag-text` | Tag badge text | +| `--color-code-bg` | Inline code and code block background | +| `--color-code-text` | Code text | + +### Layout + +| Variable | Purpose | +|---|---| +| `--content-width` | Max width of the main content column (e.g. `52rem`) | +| `--spacing-page-h` | Horizontal page padding at narrow viewports | +| `--spacing-page-h-wide` | Horizontal page padding at wider viewports | +| `--spacing-page-v` | Vertical padding at the top and bottom of the content area | +| `--gap-base` | Minimum meaningful gap between elements. Used directly or as a multiplier (e.g. `calc(var(--gap-base) * 2)`) for spacing between nav items, form controls, list rows, and similar compound layouts | + +### Shape + +| Variable | Purpose | +|---|---| +| `--radius-sm` | Corner radius for small elements - inline code, tag badges, inputs | +| `--radius-md` | Corner radius for mid-size elements - buttons, search box, post cards | +| `--radius-lg` | Corner radius for large elements - code blocks, image frames | + +### Typography + +| Variable | Purpose | +|---|---| +| `--font-body` | Font stack for body text and UI elements | +| `--font-heading` | Font stack for headings (`h1`-`h4`) | +| `--font-mono` | Font stack for code and pre blocks | +| `--font-size-base` | Root font size (cascades via `rem`). Default `1rem` / `16px` | \ No newline at end of file