FOIA ConsoleBorn Between 2 Generals Open the console →

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:

RecordPurpose
TXT SPFv=spf1 include:<your-provider> -all
TXT DKIMProvided by your ESP. Use a 2048-bit key.
TXT DMARCStart at p=none; rua=…, tighten to p=quarantine once the reports are clean
MXPoint 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
AtSectionWhat it says
0:02What it needsOne 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:13The DNSSending 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:23Order of operationsMint 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:32The dry runSet 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:41Who is askingDecide who the requester is before deploying. If the server sent as itself, the operator would be the requester and would own the records.