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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ AWS_SECRET_ACCESS_KEY=local
SNS_TOPIC_ARN=arn:aws:sns:eu-central-1:000000000000:utskick-events
# {"<form>":"<key of 32+ characters>"} to try a server form locally (docs/signup.md)
FORM_KEYS={}
# Bearer token for POST /subscribe/status. Distinct from every form key. Fake local value only.
STATUS_KEY=local-status-key-not-for-production-use
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ You keep the list, the templates and the numbers. Amazon SES only delivers the m
- **Numbers that matter:** open, click, click-to-open, unsubscribe, bounce and complaint rates per send; sending health over 30 days against the limits Amazon SES and Gmail hold senders to; and each list's growth by month.
- **Bounces and spam complaints** arrive from SES through SNS; each message's signature is checked, and the address is suppressed.
- **Imports** a Mailchimp audience export: subscribers with their opt-in time, unsubscribed and cleaned addresses as suppressions.
- **Sign-ups** from your own sites: a browser form, a server route with a key, or a Cloudflare Worker over a service binding ([docs/signup.md](docs/signup.md)).
- **Sign-ups** from your own sites: a browser form, a server route with a key, or a Cloudflare Worker over a service binding. A server can ask whether the signed-in person is on a list, with a separate secret ([docs/signup.md](docs/signup.md)).
- **A dashboard** for the team with sending health, lists and their growth, sends, rates, clicks per link and the audit log, and no personal data ([docs/dashboard.md](docs/dashboard.md)).
- **An MCP server** for everyone at the company, through their own assistant (Claude, ChatGPT, Grok, Cursor, Codex …), with OAuth 2.1. An assistant reads the numbers, writes drafts, sends tests to the person using it, and makes lists for one mailing, such as an event's guests. It can only *ask* to send to a list: an admin gets the request by mail and approves each send ([docs/mcp.md](docs/mcp.md)).
- **A web archive** page per issue, without names or tracking.
Expand All @@ -23,7 +23,7 @@ You keep the list, the templates and the numbers. Amazon SES only delivers the m
issue HTML ──► CLI ──/admin──► Worker ──SendEmail──► Amazon SES ──► inbox
▲ │ │
readers: /u /c /o /a ─────────┤ ├── D1 (lists, sends, │
sites: /subscribe, RPC ─────┤ │ events, OAuth) └─ SNS ──► /hooks/ses
sites: /subscribe, /subscribe/status, RPC ─────┤ │ events, OAuth) └─ SNS ──► /hooks/ses
assistants: /mcp + OAuth ─────┤ └── R2 (images)
admins: /dashboard, /approve ┘ cron: sends what a person approved
```
Expand Down Expand Up @@ -81,13 +81,13 @@ More: [docs/signup.md](docs/signup.md) (forms), [docs/dashboard.md](docs/dashboa

## Security model

- **Admin API.** `/admin` takes a bearer token, compared in constant time; changing calls are written to the audit log. The token, the AWS key, the SNS topic ARN and the form keys are Worker secrets, never files in the repository. Consider putting `/admin` behind a Cloudflare Access service token as well.
- **Admin API.** `/admin` takes a bearer token, compared in constant time; changing calls are written to the audit log. The token, the AWS key, the SNS topic ARN, the form keys and `STATUS_KEY` are Worker secrets, never files in the repository. Consider putting `/admin` behind a Cloudflare Access service token as well.
- **AWS key.** An IAM user allowed `ses:SendEmail` on one identity and one configuration set, and nothing else.
- **SNS webhook.** Nothing in a message is used before its signature checks out: SignatureVersion 1 or 2, the signing certificate fetched only from `sns.<the topic's region>.amazonaws.com` with the `SimpleNotificationService-…pem` path, and only the configured topic accepted. That includes a subscription confirmation's `SubscribeURL`.
- **Reader tokens.** Each delivery has its own 128-bit random token, used in its unsubscribe, click and open URLs. A token identifies one mail, not a person, and the API never returns one. Like any newsletter footer link, a forwarded mail lets its new reader unsubscribe the original one.
- **Unsubscribe.** A GET only shows a button, because mail scanners open links. A POST, the one RFC 8058 clients send, unsubscribes. In a test mail the POST is logged and takes nobody off a list.
- **Bounces.** A hard bounce or a spam complaint suppresses the address on every list. A full mailbox does so when a second mail bounces for it within 30 days; other soft bounces (a busy server, one slow to trust a new sender) do not. Only the mail's own recipient is suppressed, never other addresses a bounce report names.
- **Sign-ups.** Browser forms are allowed by exact origin (CORS), server routes by a per-form key, sites on Cloudflare over a service binding. Each list's consent id must match the config, a honeypot field catches form bots, and requests are rate-limited per client (per /64 for IPv6). The answer is the same for a new, known or suppressed address. Without double opt-in, a sign-up never lifts a suppression and never changes an existing contact. A site over a service binding has the same rate limit.
- **Sign-ups.** Browser forms are allowed by exact origin (CORS), server routes by a per-form key, sites on Cloudflare over a service binding. Each list's consent id must match the config, a honeypot field catches form bots, and requests are rate-limited per client (per /64 for IPv6). The answer is the same for a new, known or suppressed address. Without double opt-in, a sign-up never lifts a suppression and never changes an existing contact. A site over a service binding has the same rate limit. Whether a signed-in person is on a list is `POST /subscribe/status`, with its own secret (`STATUS_KEY`); a form key is refused, the call takes one address, and the answer is only `{ subscribed: true }` or `{ subscribed: false }`.
- **Assistants (MCP).** OAuth 2.1 with PKCE S256 only, tokens bound to the `/mcp` resource, rotating refresh tokens, revocation per client and per person, and least-privilege scopes (`stats.read`, `subscribers.read`, `drafts.write`, `test.send`, `lists.write`). Sending to a list is never a scope: an admin approves each send on a page that shows the list, the subject, the count, the text part, every link and anything hidden; the approval works once, within 24 hours by default. With `approval.notifyFrom` set, the admins get the approval link by mail and the assistant never holds it. An assistant can add addresses to a list it made, and no tool returns one. Every tool call is audited. Tokens, codes and secrets are stored only as hashes.
- **Members and admins.** Sign-in for the dashboard, the consent page and approvals goes through Cloudflare Access; Utskick checks the Access JWT itself. `members` (addresses, or `@domain` for a whole company) may connect an assistant and read the dashboard; only `admins` approve sends. Every call an assistant makes checks that its person is still a member. The development stand-in works only when both the configured and the requested host are local.
- **Pages.** Every reader and admin page is server-rendered without scripts. The dashboard and approval pages carry a strict Content-Security-Policy and cannot be framed; the web archive is served in a CSP sandbox.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ We answer within five working days, tell you what we will do, and credit you in

## In scope

The Worker (`src/`), the CLI (`cli/`), the database schema (`migrations/`) and the documented setup (`docs/setup.md`). Examples are: sending to someone who unsubscribed or never signed up, reading or changing subscribers without the admin token or an OAuth grant, sending to a list without a human approval, forging SES/SNS events, reading personal data from public pages, and anything that runs code in a reader's or an admin's browser.
The Worker (`src/`), the CLI (`cli/`), the database schema (`migrations/`) and the documented setup (`docs/setup.md`). Examples are: sending to someone who unsubscribed or never signed up, reading or changing subscribers without the admin token or an OAuth grant, learning whether an address is on a list without `STATUS_KEY` (including with a form key), sending to a list without a human approval, forging SES/SNS events, reading personal data from public pages, and anything that runs code in a reader's or an admin's browser.

The security model is described in [README.md](README.md#security-model), [docs/mcp.md](docs/mcp.md), [docs/dashboard.md](docs/dashboard.md) and [docs/signup.md](docs/signup.md).

Expand Down
4 changes: 4 additions & 0 deletions cloudflare.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export default defineConfig(async ({ mode }) => {
// POST /subscribe: 10 sign-ups a minute per form and client address (src/signup.ts).
// The namespace is any number no other rate limiter in the account uses.
SIGNUP_LIMITER: bindings.rateLimit({ namespace: deploy.signupLimiterNamespace, simple: { limit: 10, period: 60 } }),
// POST /subscribe/status: 120 lookups a minute per calling server (src/status.ts).
STATUS_LIMITER: bindings.rateLimit({ namespace: deploy.statusLimiterNamespace, simple: { limit: 120, period: 60 } }),
PUBLIC_URL: bindings.text(dev ? "http://localhost:5173" : deploy.publicUrl),
AWS_REGION: bindings.text(deploy.awsRegion),
// In development, every sign-in at /oauth/authorize, /approve/ and /dashboard is the first admin,
Expand All @@ -43,6 +45,8 @@ export default defineConfig(async ({ mode }) => {
SNS_TOPIC_ARN: bindings.secret(),
// JSON {"<form>": "<key>"} for the forms in the config that need a key; {} when none do (docs/signup.md).
FORM_KEYS: bindings.secret(),
// Bearer token for POST /subscribe/status. Distinct from every form key (docs/signup.md).
STATUS_KEY: bindings.secret(),
},
},
};
Expand Down
8 changes: 7 additions & 1 deletion config/example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"id": "00000000-0000-0000-0000-000000000000"
},
"r2": "utskick-images",
"signupLimiterNamespace": "1001"
"signupLimiterNamespace": "1001",
"statusLimiterNamespace": "1002"
},
"organization": "Example Co",
"homepage": "https://example.com",
Expand Down Expand Up @@ -70,5 +71,10 @@
],
"key": true
}
},
"status": {
"lists": [
"newsletter"
]
}
}
8 changes: 7 additions & 1 deletion config/sourceful.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"id": "e43b0294-fce6-4010-bb4c-08a2fd99a96c"
},
"r2": "utskick-images",
"signupLimiterNamespace": "7001"
"signupLimiterNamespace": "7001",
"statusLimiterNamespace": "7002"
},
"organization": "Sourceful Energy",
"homepage": "https://sourceful.energy",
Expand Down Expand Up @@ -86,5 +87,10 @@
],
"key": true
}
},
"status": {
"lists": [
"app-users"
]
}
}
7 changes: 4 additions & 3 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,21 @@ cf d1 create --name utskick-db --jurisdiction eu
cf r2 buckets create --name utskick-images --cf-r2-jurisdiction eu
```

Copy `config/example.json` to `config/<you>.json` and fill in its `deploy` section: `publicUrl` (`https://$DOMAIN`; the deploy attaches that hostname to the Worker), `awsRegion`, `sesConfigurationSet`, the D1 name and id, the R2 bucket, and `signupLimiterNamespace` (any number no other rate limiter in the account uses). Fill in the rest too: organization, lists (with a `consent` id for lists that take form sign-ups), `forms`, `members` (who may use the dashboard and connect an assistant: addresses, or `"@example.com"` for everyone at a domain), `admins` (members who may also approve a send to a list) and `testAllowlist` (addresses besides the signed-in person's own that an assistant's test send may reach). Then:
Copy `config/example.json` to `config/<you>.json` and fill in its `deploy` section: `publicUrl` (`https://$DOMAIN`; the deploy attaches that hostname to the Worker), `awsRegion`, `sesConfigurationSet`, the D1 name and id, the R2 bucket, `signupLimiterNamespace` and `statusLimiterNamespace` (any numbers no other rate limiter in the account uses). Fill in the rest too: organization, lists (with a `consent` id for lists that take form sign-ups), `forms`, `status.lists` (lists `POST /subscribe/status` may query; omit the block to leave that call off), `members` (who may use the dashboard and connect an assistant: addresses, or `"@example.com"` for everyone at a domain), `admins` (members who may also approve a send to a list) and `testAllowlist` (addresses besides the signed-in person's own that an assistant's test send may reach). Then:

```bash
cf d1 migrations apply <d1-id>
openssl rand -hex 32 # the admin token: into the secrets file, and into .env as MAILER_ADMIN_TOKEN
openssl rand -hex 32 # STATUS_KEY: into the secrets file and Bitwarden; never a form key (docs/signup.md)
```

Secrets file (`.secrets.json`, git-ignored, deleted after use):

```json
{ "ADMIN_TOKEN": "…", "AWS_ACCESS_KEY_ID": "…", "AWS_SECRET_ACCESS_KEY": "…", "SNS_TOPIC_ARN": "arn:aws:sns:<region>:<account>:utskick-events", "FORM_KEYS": "{}" }
{ "ADMIN_TOKEN": "…", "AWS_ACCESS_KEY_ID": "…", "AWS_SECRET_ACCESS_KEY": "…", "SNS_TOPIC_ARN": "arn:aws:sns:<region>:<account>:utskick-events", "FORM_KEYS": "{}", "STATUS_KEY": "…" }
```

`FORM_KEYS` holds the keys of website forms that post from a server; `{}` until you have one ([signup.md](signup.md)).
`FORM_KEYS` holds the keys of website forms that post from a server; `{}` until you have one ([signup.md](signup.md)). `STATUS_KEY` is a separate secret for `POST /subscribe/status` (whether the signed-in person is on a list). Generate it with `openssl rand -hex 32` and store it in Bitwarden as `STATUS_KEY`; it must not be a form key. Without `status.lists` in the config the call stays off.

```bash
UTSKICK_CONFIG=config/<you>.json cf deploy --secrets-file .secrets.json && rm .secrets.json
Expand Down
Loading
Loading