Metadata-Version: 2.4
Name: mailtorpedo
Version: 2.0.0
Summary: Self-hosted email platform: campaigns for humans, a REST API for developers.
Project-URL: Homepage, https://github.com/pptx704/torpedo
Project-URL: Issues, https://github.com/pptx704/torpedo/issues
Author-email: "Rafeed M. Bhuiyan" <rafeedm.bhuiyan@gmail.com>
License: MIT License
        
        Copyright (c) 2026 Omukk Limited
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: bulk-email,campaign,email,gmail,smtp,transactional
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: aiosmtplib>=3.0
Requires-Dist: aiosqlite>=0.20
Requires-Dist: alembic>=1.13
Requires-Dist: asyncpg>=0.29
Requires-Dist: bcrypt>=4.1
Requires-Dist: charset-normalizer>=3.3
Requires-Dist: cryptography>=42.0
Requires-Dist: email-validator>=2.1
Requires-Dist: fastapi[standard]>=0.115
Requires-Dist: httpx>=0.27
Requires-Dist: openpyxl>=3.1
Requires-Dist: pydantic-settings>=2.2
Requires-Dist: pyjwt>=2.8
Requires-Dist: python-dotenv>=1.0
Requires-Dist: sqlalchemy>=2.0.30
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# Torpedo

Self-hosted email marketing tool. Campaigns for people who don't write code, a REST API for people who do.

Mail goes out through **your** SMTP server or **your** Gmail account. Torpedo does the orchestration — lists, templates, pacing, retries, suppression — and never touches your recipients' data or holds it on someone else's infrastructure.

```bash
git clone https://github.com/pptx704/torpedo
cd torpedo
docker compose up
```

Open <http://localhost:8000>. One container, no database service, no broker.

---

## Contents

- [What you get](#what-you-get)
- [Install](#install)
- [Tutorial](#tutorial)
  - [1. Create your account](#1-create-your-account)
  - [2. Connect a sending account](#2-connect-a-sending-account)
  - [3. Import a contact list](#3-import-a-contact-list)
  - [4. Write a template](#4-write-a-template)
  - [5. Send a campaign](#5-send-a-campaign)
  - [6. Watch it run](#6-watch-it-run)
  - [7. Manage the suppression list](#7-manage-the-suppression-list)
- [The template language](#the-template-language)
- [Sending rate and daily quotas](#sending-rate-and-daily-quotas)
- [What "sent" actually means](#what-sent-actually-means)
- [The API](#the-api)
- [The command line](#the-command-line)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [Using Torpedo as a Python library](#using-torpedo-as-a-python-library)
- [Development](#development)
- [License](#license)

---

## What you get

| | |
|---|---|
| **Campaigns** | Import a list, write a message, send to everyone. Pause, resume, retry, cancel. Several campaigns can run at once. |
| **Personalisation** | `{{ first_name }}` in the body *and* the subject line, with fallbacks for blank cells. |
| **Two bodies, one email** | Write in plain text, and add an HTML version when you want one. Write only HTML and the plain-text part is generated for you. |
| **Imports** | CSV, TSV, JSON, Excel, Google Sheets — upload a file, paste rows, or link a sheet. |
| **Your own sending** | Any SMTP server, or Gmail via App Password or OAuth. |
| **Rate control** | One message every 600 ms by default, adjustable, plus daily quota tracking per account. |
| **Suppression** | Addresses that must never be mailed again, enforced everywhere. |
| **Transactional API** | `POST /v1/emails` with API keys, idempotency, scheduling and webhooks. |
| **Resumability** | Kill the process mid-campaign and it picks up where it stopped. Messages left ambiguous by the crash are held for you to decide, not silently resent. |

Deliberately **not** included: open tracking and click tracking. Torpedo embeds no pixels and rewrites no links, so there is no engagement dashboard. If you need one, this is the wrong tool.

---

## Install

### Docker (recommended)

```bash
git clone https://github.com/pptx704/torpedo
cd torpedo
docker compose up
```

There is no published image, so the first `up` builds one — a few minutes, once. After that it starts in seconds. Open <http://localhost:8000>.

One container. SQLite lives in a volume; secrets are generated on first start.

Prefer PostgreSQL?

```bash
docker compose -f docker-compose.yml -f docker-compose.postgres.yml \
  --profile postgres up
```

Nothing in the app changes — only `DATABASE_URL` does.

To configure either, copy `env.example` to `.env` before starting; compose hands the file to the container. Set `PORT` there to serve on something other than 8000.

### From source

Needs Python 3.12+, [uv](https://docs.astral.sh/uv/), and [bun](https://bun.sh/).

```bash
uv sync                          # install Python dependencies
uv run alembic upgrade head      # create the database
cd web && bun install && bun run build && cd ..
uv run torpedo serve             # http://127.0.0.1:8000
```

For frontend work, run the halves separately — bun's dev server proxies the API to uvicorn:

```bash
uv run torpedo serve --reload    # terminal one
cd web && bun run dev            # terminal two → http://localhost:5173
```

---

## Tutorial

The rest of this section walks through one complete campaign, from an empty install to a delivered message.

### 1. Create your account

The first visit shows a setup page. Pick a username, an email and a password of at least 10 characters.

Setup closes the moment it succeeds — nobody who finds the URL later can claim the install. If you need a second administrator, create them from the command line.

Sessions are an httpOnly cookie, so nothing on the page can read your token. Behind HTTPS, set `SESSION_COOKIE_SECURE=true` so it never crosses plain HTTP.

### 2. Connect a sending account

**Accounts → Add account.** An account is one sending identity: where mail goes out from, and what pace it goes at.

#### Your own SMTP server

| Field | Example |
|---|---|
| Host | `smtp.example.com` |
| Port | `587` for STARTTLS, `465` for SSL/TLS |
| Encryption | STARTTLS (usual), SSL/TLS, or none |
| Username / password | Whatever your provider issued |
| From address | The address recipients will see |

The password is encrypted at rest and never returned by any API — not masked, not partial. The account list shows only whether credentials are configured.

#### Gmail, the easy way

Gmail still accepts plain SMTP if the account uses an **App Password**:

1. Turn on 2-Step Verification on the Google account.
2. Visit <https://myaccount.google.com/apppasswords> and create one.
3. Add an SMTP account in Torpedo: host `smtp.gmail.com`, port `587`, STARTTLS, username your full Gmail address, password the App Password.

No Google Cloud project, no OAuth client, no verification. Workspace administrators can disable App Passwords, in which case use OAuth below.

#### Gmail, via OAuth

Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` from your own Google Cloud OAuth client, restart, then **Accounts → Connect a Google account**.

> **Read this before choosing OAuth.** `gmail.send` is a Google *restricted* scope. Until Google verifies your OAuth client, it is limited to 100 test users and refresh tokens that expire after **seven days** — meaning you reconnect the account weekly. That is Google's review process, not a Torpedo limitation. The App Password route above has none of these constraints.

The same connection also unlocks importing from private Google Sheets.

#### Test it

Every account has a **Test** button. Leave the address blank to check the credentials only, or enter one to send a real test message. Torpedo shows you exactly what the server replied:

```
Test message accepted for you@example.com.
Server said: 2.0.0 Ok: queued as 4bYc3K1z
```

Credentials that authenticate are not the same as credentials that can *send* — a test message is the only way to find out that your relay refuses this sender before a campaign discovers it 400 times.

### 3. Import a contact list

**Lists → Import a list.** Three ways in, all ending in the same preview.

**Upload a file** — CSV, TSV, JSON or Excel. The format is detected from the filename; the encoding and delimiter are detected from the contents. For Excel, pick a sheet and a header row.

**Paste rows** — copy straight out of a spreadsheet (that's TSV), or paste CSV or JSON. Useful for the fifteen people you were sent in an email.

**Google Sheet** — paste the URL. A sheet shared as *anyone with the link can view* needs no Google connection at all; private sheets use the connected account.

Then you get a **preview**: the real columns, the first rows, and any warnings.

```
3 rows · 3 columns · utf-8 · delimiter ","

name    email               plan
Ada     ada@example.com     pro
Grace   grace@example.com   free
Alan    alan@example.com
```

Nothing is saved until you confirm. That's the point — a wrongly sniffed delimiter gets caught by a person looking at a table, rather than discovered after forty thousand rows land in the database.

Torpedo guesses which column holds the addresses, by name first and by content second, so a column called `contact` full of email addresses is still found. Override it if the guess is wrong.

Two options before committing:

- **Keep only the first of each address** — silently drops duplicates.
- **Skip rows with an unusable address** — drops malformed ones instead of refusing the whole import.

After import you get a count of what happened: `Imported 2 contacts (1 invalid, 1 duplicate)`.

### 4. Write a template — optional

Templates are for emails you will send more than once. If this is a one-off, skip to the next step and write it straight into the campaign.

**Templates → New template.**

Pick the list your merge tags should come from — the editor then offers exactly the columns that exist, so you can't invent one by accident.

**Subject.** Merge tags work here too. Click a chip to insert one at the cursor:

```
Hello {{ name }}, your {{ plan ?? "trial" }} plan
```

**Body.** An email has two bodies, and the editor treats them that way.

It opens on **plain text** — a real textarea, with your merge tags picked out as you type. Click a chip to insert one at the cursor. For a lot of mail that is the whole job, and a text-only email is a perfectly good email.

Want formatting? **Add an HTML version.** That gives you a second body with the rich-text editor on it: bold, italic, headings, lists, quotes, links, images, dividers, and **HTML** to edit the markup directly. Whatever you already wrote in plain text is carried over as a starting point. Both bodies are sent, and the recipient's client picks — so if the HTML doesn't render, the plain text does.

You never have to write the same email twice. Leave the plain-text body empty and one is **generated from your HTML** at send time, so the message is never HTML-only. That matters: clients that refuse HTML still get something readable, and single-part HTML mail is penalised by spam filters. The preview shows you exactly what that generated version says.

**Attachments.** Upload files; they're stored once and shared across every template that references them. Anything over ~20 MB is rejected by most providers, so Torpedo warns you.

**Preview.** Under the editor, because you write first and check afterwards. It opens full size and renders against a real row, with `‹ 1 of 84 ›` to step through the list and a **HTML / Plain text** switch to see either part. You see the exact subject and body that recipient gets — including the blank cell that would have embarrassed you:

```
Row 1:  Hello Ada, your pro plan
Row 3:  Hello Alan, your trial plan     ← plan was empty, fallback used
```

If the email references a column the list doesn't have, the preview says so by name, on the row where it is empty.

### 5. Send a campaign

**Campaigns → New campaign.** Pick the list — that is the only thing needed to start, and it creates the draft. Then write the email right there: same editor, same two bodies, same merge tags, same preview.

Nothing else is required up front:

- **No template.** Write it in the campaign. When you press send, Torpedo offers once to keep it as a template for next time; decline and it never asks again.
- **No sending account.** Press send and you are asked to pick one, or to add an SMTP sender without leaving the page.
- **Change your mind.** Picking a template from **Start from** loads it over what you have written (it asks first). Editing afterwards makes the copy yours again.

**Drafts save themselves** as you type, so closing the tab loses nothing. A campaign can sit as a draft indefinitely; it holds no messages until it starts.

**Duplicate** any campaign — draft, running or finished — to start a new one from it. The copy takes the email that campaign actually sent, not whatever the template says today.

**Delete** removes a draft, from the campaigns list or from the composer. Only drafts: a campaign that has started is the only record of what it sent, so those are cancelled rather than deleted.

The pre-flight panel updates as you go:

```
2

recipients, of 3 rows in the list

• 1 on the suppression list
• 12/500 already sent on this account today
```

Pre-flight checks, before a single message goes out:

- Merge tags no column in the list can fill
- Syntax errors in the subject or body
- Malformed addresses
- Duplicate addresses
- Addresses on the suppression list
- Whether the account's remaining daily quota covers the recipients
- Attachment size against provider limits

Anything under **Still to do** blocks the send. Warnings don't — a quota shortfall means the run pauses when it runs out and continues after the quota resets, which is usually what you want.

Settings worth knowing:

| Setting | Default | What it does |
|---|---|---|
| **Interval** | 600 ms | Gap between messages. The UI translates it: *100 per minute*. Can't go below the account's floor. |
| **Send at** | now | Schedule for later. Cancel any time before it fires. |
| **Dry run** | off | Renders and records everything, sends nothing. A full rehearsal. |
| **If the process dies mid-send** | Hold | See [below](#6-watch-it-run). |
| **Skip suppressed / duplicates** | on | Turning these off is a deliberate choice. |

Then **Start sending** — or **Save as draft** to start it later.

> **Try a dry run first.** It exercises the whole pipeline: renders every message, catches a template that fails on row 8,300, and delivers nothing.

### 6. Watch it run

The campaign page streams live. Counters, a progress bar, and a per-recipient table showing what the server said about each one:

| Recipient | Status | Server response | Queue ID |
|---|---|---|---|
| ada@example.com | sent | 2.0.0 Ok: queued as 4bYc3K1z | `4bYc3K1z` |
| grace@example.com | sent | 2.0.0 Ok: queued as 4bYc3K2a | `4bYc3K2a` |
| bad@example.com | failed | 550 5.1.1 User unknown | — |

That queue ID is the one your own mail server logged. It's how you trace a specific message in your own server's logs — Torpedo can't see any further than that hop.

**Controls.** Pause stops new sends immediately; resume picks up where it stopped. Cancel drops everything not yet sent. **Retry failed** re-queues failed messages, and — once you decide to — any held as `unknown` too.

A rejected recipient does not stop the campaign. One bad address means one failed message, not an aborted run.

**If the process dies mid-campaign** — a crash, a reboot, `docker compose down` at the wrong moment — Torpedo sorts recipients into three groups on restart:

- **Not yet attempted** → simply sent. No decision needed.
- **Already finished** → left alone.
- **In flight when it died** → genuinely ambiguous. The message was handed to the server, but the result was never recorded. It may have gone out. It may not have.

That third group is what the **"If the process dies mid-send"** setting governs:

- **Hold** (default) — parked as `unknown` and surfaced for you to decide. Nobody receives a duplicate unless a human chooses that.
- **Send them again** — re-queued. At-least-once delivery, duplicates possible.

The campaign page shows held messages plainly, and **Retry failed** covers them too, once you decide to.

### 7. Manage the suppression list

**Suppression.** Addresses here are never sent to, by any campaign or API call.

Add one at a time, or paste a whole block. Unsubscribes land here automatically.

This is the single most valuable safety mechanism in a bulk sender. Continuing to mail addresses that reject you is what gets an account throttled — the suppression list is what stops that happening by accident.

To send to a suppressed address from the API, you must pass `ignore_suppression: true`. There is no way to do it silently.

---

## The template language

Small, and deliberately so. A template can read the row it's given and nothing else — it can't loop, branch, or call anything, so a template is never a way to run code.

| Syntax | Does |
|---|---|
| `{{ first_name }}` | Insert a column's value |
| `{{ first_name ?? "there" }}` | Fall back when the cell is missing **or blank** |
| `{{ name \| upper }}` | Apply a filter |
| `{{ name \| trim \| upper }}` | Chain filters, left to right |
| `{{ signature \| raw }}` | Insert HTML without escaping it |
| `{{ "First Name" }}` | Quote a column name containing spaces |

Filters: `raw`, `upper`, `lower`, `title`, `trim`.

**Escaping.** HTML bodies escape values by default, so a customer named `<script>` is text and not a script. Text bodies don't escape. `| raw` is the only way out and has to be typed on purpose.

**Blank vs missing.** A column that exists but is empty renders as nothing — an empty cell is data, not a mistake. A column that doesn't exist at all is caught by pre-flight, before the run starts.

Errors report the line and column, so the editor can point at the problem.

---

## Sending rate and daily quotas

Two separate limits, and people usually worry about the wrong one.

**The interval** is the gap between consecutive messages — 600 ms by default, which works out to 100 a minute, or 6,000 an hour. Set it per campaign; it can never go below the account's floor.

**The daily quota** is the ceiling your provider imposes. For Gmail:

| Account type | Messages per day |
|---|---|
| Consumer (`@gmail.com`) | 500 |
| Google Workspace | 2,000 |

At 600 ms, a consumer account exhausts its entire day in **about five minutes**. So the quota is what actually binds, not the interval.

Torpedo counts sends per account per UTC day and stops before you hit the ceiling. Messages stay queued and resume when the quota resets — they aren't failed and they aren't lost. Remaining quota is shown on the dashboard and in pre-flight.

**One account is one pipe.** Two campaigns aimed at the same account share its pacing and its quota. Running them together doesn't double your throughput, and it doesn't double your risk either — which is the point.

---

## What "sent" actually means

This deserves its own section, because it's the one place Torpedo differs from what you may expect from a hosted service.

```
Torpedo ──► your submission server ──► recipient's mail exchanger ──► inbox
           (smtp.gmail.com / your MTA)
        └── the only hop Torpedo sees ──┘
```

Services like Resend and SendGrid report `delivered` because they **are** the sending infrastructure — they talk to the recipient's mail exchanger directly and read its final answer. Torpedo hands your message to your own server and stops there. It's one hop removed.

So:

> **`sent` means the submission server accepted responsibility for the message. It does not mean the message arrived.**

What Torpedo does keep, for every message: the full response line, the response code, and the queue ID parsed out of it. `250 2.0.0 Ok: queued as 4bYc3K1z` is recorded whole, not reduced to a tick.

Rejections **at submission time** are caught properly, and there are more of them than people expect:

| Response | Handling |
|---|---|
| `550 5.1.1 User unknown` | Permanently failed. Fires immediately whenever your server verifies recipients — routine for same-domain sends |
| `550 5.7.1 Relay denied` | Permanently failed — this sender isn't authorised |
| `552` message too large | Permanently failed |
| `421` / `450` rate limited | Retried with exponential backoff |

There is no `delivered`, `bounced`, `opened` or `clicked` — in the UI, in the API, or in webhooks. None of them are observable from where Torpedo stands, and inventing them would be worse than admitting it.

Real bounces arrive as messages in whichever mailbox your `Return-Path` points at. Torpedo doesn't read that mailbox yet; automatic bounce processing is a planned addition, and the event names leave room for it.

---

## The API

Everything the web app does is available over HTTP. Full reference at `/docs` (OpenAPI at `/openapi.json`), guide in [`docs/api.md`](docs/api.md).

Create a key under **Developers**. It's shown once — only its SHA-256 is stored, so a lost key gets replaced, not recovered.

```bash
curl https://mail.example.com/v1/emails \
  -H "Authorization: Bearer tor_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{
        "to": ["someone@example.com"],
        "subject": "Welcome, {{ first_name }}",
        "html": "<p>Hi {{ first_name ?? \"there\" }}, thanks for signing up.</p>",
        "variables": {"first_name": "Sam"}
      }'
```

```json
{ "id": "0f8b…", "status": "queued", "to": "someone@example.com" }
```

The same template language works here, so transactional mail personalises the same way campaigns do.

Highlights:

- **Keys carry a permission** — `full_access` for the integration that manages resources, `sending_access` for the key that only sends. An API key can never create another API key.
- **`Idempotency-Key`** — a replay returns the original response and sends nothing further. The same key with a *different* body is a `409`, not a second send.
- **`scheduled_at`** — send later; `DELETE` cancels while it's still pending.
- **Webhooks** — HMAC-signed, retried with backoff, every attempt logged and visible in the UI.
- **Cursor pagination**, **rate-limit headers**, and a stable error envelope.

Two surfaces exist: `/v1` is a contract and safe to build against; `/api` is what the web app uses and changes with the UI. Use `/v1`.

---

## The command line

```bash
uv run torpedo --help
```

| Command | Does |
|---|---|
| `torpedo serve` | Run the web app and API |
| `torpedo migrate` | Apply database migrations |
| `torpedo create-admin` | Create the first administrator without a browser |
| `torpedo import <file> --name "Customers"` | Import a list from a file |
| `torpedo send --to a@b.com --subject Hi --text "..."` | Send one message immediately |
| `torpedo version` | Print the version |

`torpedo send` bypasses the queue and prints the server's raw response, which makes it a good way to check a relay from a shell.

---

## Configuration

Everything is optional. With no configuration at all, Torpedo runs on SQLite and generates its own secrets. Copy [`env.example`](env.example) to `.env` for the annotated list; the ones that matter:

| Variable | Default | Notes |
|---|---|---|
| `DATABASE_URL` | SQLite in `./data` | Or `postgresql+asyncpg://…`. Set by compose under Docker |
| `DATA_DIR` | `./data` | Database file, attachments, generated keys. Set by compose under Docker |
| `TORPEDO_SECRET_KEY` | generated | Encrypts stored credentials. **Back this up** |
| `JWT_SECRET` | generated | Signs sessions |
| `SESSION_COOKIE_SECURE` | `false` | Set `true` behind HTTPS |
| `PUBLIC_BASE_URL` | — | Enables one-click unsubscribe (see below) |
| `GOOGLE_CLIENT_ID` / `_SECRET` | — | Only for Gmail OAuth and private Sheets |
| `DEFAULT_INTERVAL_MS` | `600` | Default campaign pacing |
| `PORT` | `8000` | Docker only — the host side of the port mapping |

`.env` works the same either way: from source the app reads it, and under Docker compose passes it to the container. The two rows marked above are the exception — compose points them at the container's own volume, so a host path in `.env` cannot take effect and is ignored.

**About `PUBLIC_BASE_URL`.** Setting it adds an RFC 8058 one-click unsubscribe link, which Gmail and Yahoo require from bulk senders. It is *not* tracking: it records that someone unsubscribed and nothing else — no opens, no clicks, no timestamps of reading. Without it, Torpedo uses the `mailto:` unsubscribe form, which needs no reachable server.

**Backups.** Everything lives in `DATA_DIR`: the SQLite database, `blobs/` for attachments, and `secrets.json` if you didn't supply your own keys. Losing `secrets.json` means re-entering every stored credential. On PostgreSQL, back up the database separately — `blobs/` and `secrets.json` still matter.

---

## Troubleshooting

**"That sheet is not shared publicly."** The Google Sheet import in *link* mode needs sharing set to *anyone with the link can view*. Or connect a Google account and switch to private mode.

**Google keeps asking me to reconnect.** Unverified OAuth clients get refresh tokens that expire after seven days. Either submit your OAuth client for Google verification, or use an App Password over SMTP instead — no expiry.

**"The template does not compile."** A `{{` without a matching `}}`, an unknown filter, or `??` without a quoted fallback. The message gives a line and column.

**"The template uses `{{ company }}`, which this list has no column for."** Either add the column to the list or give the tag a fallback: `{{ company ?? "your team" }}`.

**Everything says `550 5.7.1 Relay denied`.** Your SMTP server won't send as that From address. Check the account's *From address* matches what the server permits.

**The campaign says "N messages with unknown delivery".** The process died mid-send. Torpedo can't tell whether those went out. Retry them if a duplicate is acceptable; leave them if it isn't.

**A run is stuck at "queued".** Usually the daily quota. Check the account on the dashboard — if today's count is at the cap, it resumes after midnight UTC.

**"Stored credentials could not be decrypted."** `TORPEDO_SECRET_KEY` changed since they were saved. Restore the old key, or re-enter the credentials.

---

## Using Torpedo as a Python library

The engine is importable on its own — no server, no database:

```bash
pip install mailtorpedo
```

```python
import asyncio

from torpedo.core import OutboundMessage, SmtpConfig, SmtpTransport, Template
from torpedo.core.sources import SourceFormat, parse_bytes

# Parse a contact file
table = parse_bytes(open("contacts.csv", "rb").read(), SourceFormat.CSV).table

# Compile a template once, render per row
subject = Template.compile('Hi {{ first_name ?? "there" }}')
print(subject.variables)  # frozenset({'first_name'})
print(subject.missing_variables(table.columns))  # anything the file can't fill


async def main() -> None:
    transport = SmtpTransport(SmtpConfig(host="smtp.example.com", port=587))
    try:
        for row in table.rows:
            result = await transport.send(
                OutboundMessage(
                    to=(row["email"],),
                    subject=subject.render(row),
                    text=f"Hello {row.get('first_name', '')}",
                    from_email="you@example.com",
                )
            )
            print(result.code, result.response, result.queue_id)
    finally:
        await transport.close()


asyncio.run(main())
```

`torpedo.core` has no dependency on the database, FastAPI or the settings object, so it works fine inside a script or another application.

---

## Development

`uv sync` installs the dev group along with everything else, so the checks below need no separate setup.

```bash
uv run pre-commit install        # once per clone — wires up the git hook
```

That hook runs `ruff check --fix` and `ruff format` over the Python files you staged. Both rewrite rather than only complain: if either changes something the commit stops, and you `git add` the result and commit again.

```bash
uv run pre-commit run --all-files   # the same checks over the whole tree
git commit --no-verify              # skip them, when you mean to
```

The hooks are `repo: local` and go through `uv run`, so they use the ruff pinned in `uv.lock` — the same one CI runs. A mirrored upstream hook would carry its own copy, and the version skew is what makes a clean commit fail the pipeline.

The hook is not the whole suite. Before opening a pull request:

```bash
uv run ruff check .              # lint
uv run ruff format --check .     # formatting
uv run mypy                      # strict, over src/, tests/ and main.py
uv run pytest -q                 # the suite
cd web && bun run check          # svelte-check — must be 0 errors, 0 warnings
```

Two things CI cannot do for you:

- **CI only runs SQLite.** If you touched models, migrations or raw SQL, run the suite against PostgreSQL too — SQLite-only syntax passes CI happily. The `db` service in `docker-compose.postgres.yml` publishes no port, on purpose: it is reachable from the app container and nothing else. For the test leg, start a throwaway one instead:

  ```bash
  docker run --rm -p 5432:5432 \
    -e POSTGRES_USER=torpedo -e POSTGRES_PASSWORD=torpedo -e POSTGRES_DB=torpedo \
    postgres:18-alpine

  DATABASE_URL=postgresql+asyncpg://torpedo:torpedo@localhost:5432/torpedo \
    uv run pytest -q
  ```

`--rm` and no volume are the point — the suite creates its own tables, and the container takes the database down with it. - **CI never builds the image.** The runners are ARM, so an image built there would be the wrong architecture for most people self-hosting. A change to the `Dockerfile` is only proved by `docker build .` on your own machine.

New migrations should round-trip on both backends before they are merged:

```bash
uv run alembic upgrade head
uv run alembic downgrade base
uv run alembic upgrade head
```

And remember that `uv run torpedo serve` serves `web/build`, not `web/src` — a frontend change is invisible until `bun run build`. Only the Vite dev server on :5173 picks it up directly.

[`CLAUDE.md`](CLAUDE.md) documents the architecture and the invariants, [`DESIGN.md`](DESIGN.md) the visual system.

---

## License

MIT. See [LICENSE](LICENSE).
