# Self-hosting

The SendScore app, [sendscore/sendscore-app](https://github.com/sendscore/sendscore-app), is open source under AGPL-3.0. It is one Go binary that receives mail over SMTP, runs the [engine](https://github.com/sendscore/sendscore) on it, and serves the reports, the test address pages, [the API](https://sendscore.ai/docs/api/), and [the MCP connector](https://sendscore.ai/docs/mcp/) at `/api/mcp`. To point the local `sendscore mcp` server at your app, set `SENDSCORE_URL` to its address and `SENDSCORE_TOKEN` to an API token from its Account page.

## What it needs

- **Docker**, or **Go 1.26** or later to build it yourself.
- **Port 25, inbound.** Newsletter platforms deliver to port 25 and nowhere else. Many cloud and home connections block it, so check before you start.
- **A domain whose MX record points at the server.** Test addresses are `t-<token>@` that domain.
- **SQLite**, which is built in. The database is one file.

The first two are the hard part. If you can't receive mail on port 25 at a domain of your own, [the CLI](https://sendscore.ai/docs/cli/) still runs every check on a saved `.eml` file, with no server. There is no hosted version, but if you would use one, [say so on this issue](https://github.com/sendscore/sendscore/issues/1).

## Run it with Docker

The app's repository has a `Dockerfile` and a `compose.yaml`.

```bash
git clone https://github.com/sendscore/sendscore-app.git
cd sendscore-app
docker build -t sendscore-app .
```

Put the configuration below in a `.env` file, then start it.

```bash
docker run -d --name sendscore --env-file .env --stop-timeout 90 \
  -p 25:2525 -p 127.0.0.1:3014:3014 \
  -v sendscore-data:/data \
  -v /srv/sendscore/certs:/certs:ro \
  sendscore-app
```

Or run `docker compose up -d --build`, which does the same from `compose.yaml`. Uncomment the certificate mount there first, since it ships commented out and the app exits if the files it is told to load are missing.

- The image is small and runs as a user with no privileges (uid 65532), with no shell inside.
- Port 25 on the host goes to 2525 in the container, since a user with no privileges cannot listen on 25.
- The database is `/data/sendscore.db` on the `sendscore-data` volume. Copying the file of a running database can give a torn copy, so back up with `docker compose exec app sendscore-app backup /data/backup.db`.
- `GET /healthz` answers 200 when the database, the heartbeat, and the SMTP listener are working, and 503 otherwise. The image's `HEALTHCHECK` uses it.
- Publish the web port on 127.0.0.1 only, and put a web server with TLS in front of it.
- Mount the directory that holds the mail domain's certificate, not the files, so a renewed certificate is picked up. Set `SENDSCORE_TLS_CERT` and `SENDSCORE_TLS_KEY` to the files inside it, such as `/certs/fullchain.pem` and `/certs/privkey.pem`. The key must be readable by uid 65532, so have your certificate tool copy both files there after each renewal.
- Stop it with a timeout of 90 seconds, as above, because open sessions get up to 70 seconds to finish and background work up to 15 more.

## Build and run without Docker

```bash
git clone https://github.com/sendscore/sendscore-app.git
cd sendscore-app
go build -o sendscore-app .
```

To update, run `git pull`, then build again.

Then set the configuration below and run the binary. With nothing set, it serves the web app on `127.0.0.1:3014` and receives mail on `127.0.0.1:2525`, for addresses at `@localhost`, which is enough to try it on your own machine.

## Configuration

Set these in the environment, or in a `.env.local` or `.env` file in the directory the app starts in.

| Variable | Default | What it sets |
| --- | --- | --- |
| `SENDSCORE_ADDR` | `127.0.0.1:3014` | Where the web app and API listen |
| `SENDSCORE_SMTP_ADDR` | `127.0.0.1:2525` | Where the SMTP receiver listens. Port 25 must reach it. |
| `SENDSCORE_MAIL_DOMAIN` | `localhost` | The domain in test addresses, the one your MX record points here |
| `SENDSCORE_BASE_URL` | `http://` and the web address | The public URL, used in `wait_url`, `page_url`, `report_url`, sign-in links, and the OAuth metadata |
| `SENDSCORE_DB` | `sendscore.db` | The SQLite database file |
| `SENDSCORE_TLS_CERT` | none | A certificate file for the mail domain, for STARTTLS |
| `SENDSCORE_TLS_KEY` | none | The certificate's private key file |
| `SENDSCORE_CHECK_LINKS` | on | `0` turns off fetching links and images, and `content.links` and `content.images` are then skipped |
| `SENDSCORE_SPAMHAUS_DQS_KEY` | none | A Spamhaus DQS key, to look link domains up on Spamhaus DBL |
| `SENDSCORE_SURBL_ZONE` | none | A SURBL data feed zone, for subscribers |
| `SENDSCORE_PUBLIC_BLOCKLISTS` | off | `1` uses the free public Spamhaus and SURBL zones, for non-commercial use only |
| `SENDSCORE_SECURE_COOKIES` | `true` when `SENDSCORE_BASE_URL` is https, otherwise `false` | When true, cookies are marked Secure, responses carry HSTS, and the app refuses to start without `SENDSCORE_BASE_URL`, `SENDSCORE_MAIL_DOMAIN`, and the SES keys. |
| `SES_ACCESS_KEY_ID` | none | An Amazon SES access key, for emailing sign-in links |
| `SES_SECRET_ACCESS_KEY` | none | The SES key's secret |
| `SES_REGION` | `us-east-1` | The SES region |
| `SENDSCORE_MAIL_FROM` | none, required with the SES keys | The sender of sign-in emails, an address your SES account may send from |
| `SENDSCORE_DAILY_TESTS` | `500` | Test addresses a signed-in account may make per UTC day |
| `SENDSCORE_ANON_DAILY_TESTS` | `5`, or `0` on a public instance with closed sign-ups | Test addresses and uploads per UTC day for each IP that is not signed in |
| `SENDSCORE_ANON_DAILY_TOTAL` | `100` | Test addresses and uploads per UTC day for all anonymous visitors together |
| `SENDSCORE_ANON_DAILY_MESSAGES` | `200` | Messages per UTC day received by all anonymous addresses together |
| `SENDSCORE_DAILY_MESSAGES` | `1000` | Messages an account's test addresses may receive together per UTC day. Past it, the receiver answers `452`. |
| `SENDSCORE_MESSAGES_PER_ADDRESS` | `20` | Messages one test address accepts in its life. Past it, the receiver answers `550`. |
| `SENDSCORE_SMTP_SESSIONS` | `200` | SMTP connections open at once. Past it, the receiver answers `421`. A connection has 2 minutes to name a real address. |
| `SENDSCORE_SMTP_SESSIONS_PER_IP` | `5` | SMTP connections open at once from one sending IP or IPv6 /64, and four times that from one IPv4 /24 or IPv6 /48. Past it, the receiver answers `421`. |
| `SENDSCORE_SMTP_BUFFER_MB` | `256` | Megabytes of memory for messages being read and checked, by SMTP and uploads together. A message counts for ten times its size, about what it costs at its peak. Past it, SMTP answers `451` and an upload gets `503`. It must be at least `250`, or the app won't start. |
| `SENDSCORE_SMTP_ANALYZING` | `8` | Messages checked at once. More wait up to 30 seconds, then get a `451`. |
| `SENDSCORE_DAILY_MB` | `1024` | Megabytes one account may store per UTC day, across test addresses, monitoring addresses, and uploads, and the same for anonymous senders together |
| `SENDSCORE_ACCOUNT_STORAGE_MB` | `2048` | Megabytes of originals one account may keep at once, and the same for anonymous senders together. Past it, that sender's mail gets a `452` and its uploads a `429`, and everyone else is unaffected. |
| `SENDSCORE_MAX_STORAGE_MB` | `10240` | Database size at which new mail gets a `452` and uploads a `503`. It counts the database alone, not the WAL, backups, or temporary files, so set it well below your free disk space. |
| `SENDSCORE_SIGNIN_EMAILS_PER_HOUR` | `200` | Sign-in emails per hour for the whole service, on top of 5 an hour per network and per address |
| `SENDSCORE_OAUTH_REGISTRATIONS_PER_HOUR` | `600` | MCP connector registrations per hour for the whole service, on top of 20 an hour per network |
| `SENDSCORE_SIGNUPS` | `closed` | Who may make an account. See [Accounts and sign-in](#accounts-and-sign-in). |
| `SENDSCORE_ALLOWED_EMAILS` | none | Addresses that may sign up while sign-ups are closed, as a comma-separated list. Add your own before you first sign in. |
| `SENDSCORE_PUBLICATIONS` | `5` | Newsletters an account may monitor |
| `SENDSCORE_HISTORY_DAYS` | `365` | Days a monitored newsletter's issues are kept |
| `SENDSCORE_RAW_DAYS` | `30` | Days the original of a message is kept. Its report stays until the message expires. |
| `SENDSCORE_PUBLICATION_DAILY_MESSAGES` | `50` | Messages one monitoring address accepts per UTC day. Past it, the receiver answers `452`. |
| `SENDSCORE_ALERTS_PER_DAY` | `10` | Alerts one newsletter sends per UTC day |
| `SENDSCORE_ALERTS_PER_ACCOUNT` | `30` | Alerts one account sends per UTC day, however many newsletters it makes and deletes |
| `SENDSCORE_ALERTS_PER_RECIPIENT` | `20` | Alerts one address receives per UTC day, from all accounts |
| `SENDSCORE_ALERTS_PER_HOUR` | `200` | Alerts per hour for the whole service |
| `SENDSCORE_ALERT_FROM` | none, required with the SES keys | The sender of alert emails and alert confirmations, an address your SES account may send from |
| `SENDSCORE_PRIVACY_URL` | none | Your privacy policy, linked from the app's footer and sign-in page |
| `SENDSCORE_TERMS_URL` | none | Your terms, linked the same way. The sign-in page mentions both only when both are set. |
| `SENDSCORE_TRUST_PROXY` | off | `true` behind a reverse proxy, so limits count the client IP from `X-Forwarded-For`, read only on connections from `SENDSCORE_TRUSTED_PROXIES` |
| `SENDSCORE_TRUSTED_PROXIES` | loopback and the private networks | Addresses or CIDR ranges the proxy connects from, comma separated |

Set `SENDSCORE_TLS_CERT` and `SENDSCORE_TLS_KEY` to a certificate and key for the mail domain, for example from Let's Encrypt. Without them STARTTLS is off, and every message fails `auth.tls`. The app checks the files once a minute and loads them again when they change, so a renewed certificate needs no restart.

One SMTP connection delivers at most 10 messages. The next `MAIL` gets a `451`, so the sender reconnects for the rest.

Link checking fetches every link and image in each message the app receives or is given as an upload. It never fetches unsubscribe, confirm, verify, or preference links, and it refuses private, loopback, and other non-public addresses, so a message can't make the server reach your own network.

## Accounts and sign-in

An account can be deleted from its Account page, which deletes its reports, messages, newsletters, tokens, and connected apps. Accounts sign in with an emailed link, with no passwords. A link works once and expires after 15 minutes. With the SES keys set, the app emails it from `SENDSCORE_MAIL_FROM`, and it exits at startup unless `SENDSCORE_MAIL_FROM` and `SENDSCORE_ALERT_FROM` are both set. Without them, it writes the link to its log instead, which is fine on your own machine and nowhere else. In production, give an https `SENDSCORE_BASE_URL`, which turns on `SENDSCORE_SECURE_COOKIES`, so the app will not start until the SES keys and `SENDSCORE_MAIL_DOMAIN` are set, and sign-in links never come from the request's Host header or land in the log.

Signed-in users create API tokens on the Account page, and every `/api/v1/` route requires one. The MCP connector at `/api/mcp` takes an API token or signs a client in with OAuth, as in [MCP setup](https://sendscore.ai/docs/mcp/). Each account sees only its own test addresses and reports.

Sign-ups are closed by default through `SENDSCORE_SIGNUPS`, so a stranger who finds the server can't make an account. Put your own address in `SENDSCORE_ALLOWED_EMAILS` before you first sign in. Anyone else sees the same page and gets no email, so the server can't be made to send mail to strangers. The account limits are too generous for strangers, so think before you open sign-ups on a public server. On a public instance with closed sign-ups, anonymous test addresses are off.

Set `SENDSCORE_TRUST_PROXY=true` only when the app sits behind a reverse proxy that sets `X-Forwarded-For` itself. The app reads that header only on connections from `SENDSCORE_TRUSTED_PROXIES`, which defaults to loopback and the private networks. The [app's README](https://github.com/sendscore/sendscore-app) has the proxy settings.

## Monitoring

[Monitoring](https://sendscore.ai/docs/monitoring/) is on for every account, up to `SENDSCORE_PUBLICATIONS` newsletters each. Alerts are sent through SES with the same keys as sign-in emails, and without them each alert is written to the log.

The heartbeat runs inside the app, once a minute, so a missed issue is noticed only while the app is running. After a restart it catches up on deadlines from the last day.

## Behind a web server

A typical setup keeps both listeners on localhost, puts a web server with TLS in front of `SENDSCORE_ADDR`, and forwards port 25 to `SENDSCORE_SMTP_ADDR`.

On shutdown, open waits answer `not_received` at once, uploads and SMTP sessions get up to 70 seconds to finish, and background work gets up to 15 more. Give the process manager a stop timeout of 90 seconds, which is systemd's default.
