Running it
One process, one file, and DNS that can prove the mail is yours.
There is no external database and no vendor SDK — the transport is plain nodemailer, so any SMTP provider works. The work of deploying this is almost entirely in the DNS and in the decision about who the requester is.
Install
npm ci --omit=dev
node src/token.js create "console" --scopes read,write,send
NODE_ENV=production node src/server.js
Put it behind TLS. Back up the SQLite file — it is the only record that your requests exist, and reconstructing filing dates after the fact is not possible.
Mint the token before you expose the port
If HOST is anything other than loopback and no active token exists, the process refuses to start and tells you the command to run. That is the intended order of operations, not an obstacle to work around.
DNS
Sending needs an authenticated domain. Receiving needs MX records pointed at an inbound parser. For foia.yourdomain.org:
| Record | Purpose |
|---|---|
TXT SPF | v=spf1 include:<your-provider> -all |
TXT DKIM | Provided by your ESP. Use a 2048-bit key. |
TXT DMARC | Start at p=none; rua=…, tighten to p=quarantine once the reports are clean |
MX | Point at your inbound parse host so req-*@ replies arrive |
All three of SPF, DKIM and DMARC matter here for one reason: government mail filters are strict, and a request that lands in a spam folder is a request that was never made. There is no delivery receipt in FOIA practice and no way to tell the difference afterwards between an agency that ignored you and an agency that never saw it.
Environment
SMTP_URL=smtps://apikey:SG.xxxx@smtp.sendgrid.net:465
MAIL_DOMAIN=foia.yourdomain.org
INBOUND_SECRET=<random string; sent as the x-inbound-secret header>
DB_PATH=/var/lib/foia/foia.db
HOST=127.0.0.1 # default; anything wider needs a token minted first
PORT=3000
SEND_RATE_PER_HOUR=30 # per token, rolling hour
CORS_ORIGIN=* # set to the console's origin once it has one
Point your provider's inbound parse webhook at POST /api/inbound and set the shared secret header x-inbound-secret. Any SMTP provider works — the transport is plain nodemailer, not a vendor SDK, so the URL is the only thing that changes.
Before you send anything
DRY_RUN=1 renders exactly what would go out, and sends nothing.
Use it the first time. Use it every time you change letter text. The whole message is returned in the response, so what you are reading is the message and not a preview of it.
The dry run
With DRY_RUN=1 the mailer streams to stdout instead of sending. The send endpoint still does everything else it would do — creates the audit row, marks the request filed, starts the deadline clock — and returns the rendered message in a preview field. The audit action is recorded as sent-dry-run, so a dry run can never be mistaken for a send afterwards.
Warm the domain
A brand-new domain that suddenly sends twenty messages to .gov addresses looks like spam to every filter in the path. Send a handful the first week. Check the DMARC reports before scaling up. The default send rate limit of thirty per hour per token is a leaked-credential guard, not a warm-up plan — the warm-up is on you.
Decide who the requester is before you deploy
This is a legal design decision, not a technical one, and it is easy to get wrong.
FOIA fee category depends on who is asking and why. Educational, non-commercial scientific, and news-media requesters are not charged review fees — and review is the dominant cost on contract and law-enforcement records. If the server sent mail as itself, the operator would be the requester, the actual user's fee category would not apply, and the records would belong to the operator.
The mail is built to prevent that: the envelope sender is a per-request address on your domain, Reply-To is the user's own address, and the letter body carries the user's name and address. If you operate this for other people, say so plainly in your terms — you are a transmission service, the user is the requester, and the user owns the records.
The console asks for the same things, and for the same reason
Server URL, token, your name, your email. The name and the email go into the request itself, because you are the requester and the server is not. The server URL and the token are kept in that browser's localStorage, the token is shown masked, and Forget token clears it.
With no server and no token the console is still a text generator: it drafts a filing-ready request and points you at the right portal, entirely offline. That is its original purpose and most of its value. The server features are additive, and they degrade honestly when the server is absent, unreachable, or refusing the token.
This site
The documentation you are reading is a static build: no framework, no runtime, no third-party request of any kind. There are no fonts loaded from a CDN, no analytics, and nothing that phones anywhere — including the narrated explainer on every page, which is an ordinary MP4 served from this origin.
Every generated page is served under a strict Content-Security-Policy with connect-src 'none', and the build fails rather than emitting a page that carries an inline style, because an inline style is exactly the thing that passes a local check and then gets stripped in production.
The console is carried here in full
It is a single self-contained file, served from this site so the tool is reachable without installing anything. It talks to whatever server you point it at, and to nothing else.
How this works
0:53 · narrated · captioned · served from this site, and nothing is sent anywhere when you press play. Prefer to read it? The transcript is below.
Transcript
| At | Section | What it says |
|---|---|---|
| 0:02 | What it needs | One Node process and one SQLite file. Back that file up. It is the only record that your requests exist, and filing dates cannot be reconstructed. |
| 0:13 | The DNS | Sending needs SPF, DKIM and DMARC on a domain you control. Government filters are strict, and a request in a spam folder is a request never made. |
| 0:23 | Order of operations | Mint a token before you expose the port. If the host is not loopback and no token exists, the process refuses to start and prints the command. |
| 0:32 | The dry run | Set the dry-run flag and it renders exactly what would go out, and sends nothing. Use it the first time, and every time you change letter text. |
| 0:41 | Who is asking | Decide who the requester is before deploying. If the server sent as itself, the operator would be the requester and would own the records. |