OAuth is the wrong shape for a contact form

The job is small. Someone fills in a form, and one email goes from our server to our own inbox. This repository has built that three times:

  • 2017: Amazon SES, through Python's own smtplib. A username, a password, and about twenty lines of code.
  • 2024: the Gmail API, through Google's client libraries and OAuth.
  • 2026: Resend, through one HTTP POST from Tornado's own HTTP client.

The first and last have the same shape: one credential and one protocol call. This note is about the middle one, what it cost, and what turned up when we went back to look at it.

What the Gmail version needed

requirements.txt grew by three lines in June 2024. Those three lines install twenty-three packages.

Resolved from the package metadata in this machine's own virtualenv, which still has every one of them installed two months after they left requirements.txt, because pip never uninstalls a line you delete. Each package is drawn once, under the first thing that pulled it in; hover a row to see everything that requires it. The Resend version adds nothing to this list.

Then there is everything around the packages. A Google Cloud project. An OAuth consent screen. A client ID and a client secret. A refresh token, generated once by a person clicking through a browser login, then pasted into the server's environment (support for that arrived in October 2025; before it, the code read a token.json off disk). If the consent screen is left in Testing status, as a personal project's usually is, Google expires that refresh token after seven days.

And there are the scopes the code asked for:

SCOPES = ["https://www.googleapis.com/auth/gmail.readonly",
          "https://www.googleapis.com/auth/gmail.compose",
          "https://www.googleapis.com/auth/gmail.send"]

Sending needs only the last one. The first grants read access to every message in the mailbox. A contact form, a public endpoint that anyone on the internet can POST to, was holding a credential that could read the owner's whole inbox.

The fallback path is stranger. With no token in the environment, the handler ran InstalledAppFlow.from_client_secrets_file(...) and then flow.run_local_server(port=0). That is Google's flow for desktop apps: it opens a browser on the local machine and waits for a person to log in. On a server, inside a request handler, there is no browser and no person.

None of this is a bug in Google's libraries. They do what OAuth is for: letting one party act on another party's account, with that party's consent, revocably. A contact form has no other party. The person who owns the inbox wrote the server. There is nobody to ask for consent, so all that is left of OAuth is its cost.

The form never reached it

Writing this note meant checking what the Gmail code actually did. The answer, on master, is nothing at all.

On 2021-02-17 a commit titled zombie commit, turn on xsrf_cookies set xsrf_cookies=True. With that setting on, Tornado rejects any POST that does not carry an _xsrf token, and it does so before the handler runs. The form template never included {% module xsrf_form_html() %}, so the form had no token to send. From that commit on, every browser submission got 403: Forbidden.

The Gmail commit was written in June 2024 on a checkout whose newest commit was from August 2017, from before XSRF was turned on. On that commit the form works. Eighty-nine seconds later, the merge that brought it up to date with master turned XSRF back on, and the Gmail code joined the SES code behind the 403.

Each state was checked by running that commit's own server and templates under Tornado's test client, submitting every field of the form the way a browser would, with the mail function stubbed out. Two working windows are too short to draw: 1 hour 53 minutes on the svelte branch's first afternoon, and the 89 seconds the Gmail commit lasted before its merge.

The checks behind the chart, run on current Tornado (6.5.5) with the mail function replaced by a counter:

commit branch date XSRF on token in form browser submit mail code ran
909dba9 master 2017-08-30 no no 200 yes
21232e1 master 2021-02-17 yes no 403 no
757fae4 svelte 2023-01-02 14:39 yes no 403 no
29af1cb svelte 2023-01-02 14:56 yes yes 200 yes
20879ed svelte 2023-01-02 16:49 yes no form no route no
253b580 (side) 2024-06-08 14:47 no no 200 yes
2477f30 master 2024-06-08 14:49 yes no 403 no
be4ab86 master 2025-10-23 yes no 403 no

(The commits from 2021 and 2023 only import on modern Tornado with a shim for the removed @tornado.web.asynchronous decorator, which another handler used and which has nothing to do with the XSRF check.)

So for 1,806 days, from February 2021 to the Svelte merge, master had a contact form that no browser could use. The Gmail integration, with its twenty-three packages and its inbox-wide scope, spent its whole life behind that 403. The environment-variable support added in October 2025 was careful work on code that could not be reached.

Someone did fix it once. On the svelte branch's first afternoon, a commit called more generic landing page copy courtesy of chatgpt added the missing token to the template at 14:56, and the form worked. At 16:49 the branch switched to serving the Svelte app, which had no route for the form, and a week later it deleted the mail code. The token never made it back to master.

Git cannot say which commit the server was running in those years. It can say that between February 2021 and July 2026, no commit on either line of history could deliver a browser's message, apart from two: one for an hour and fifty-three minutes, and one for eighty-nine seconds.

The failure was loud for exactly one person, the visitor, who got a bare 403 page and had no way to report it. It was silent for the person who could have fixed it, because a message that never arrives looks the same as nobody writing. It belongs with the five failures that were all repaired on one day in July 2026: each of them was loud somewhere nobody was looking.

The Resend version

The current form is handlers/contact.py, 170 lines with its guards, and the part that sends is one request:

request = HTTPRequest(
    RESEND_ENDPOINT,
    method='POST',
    headers={
        'Authorization': 'Bearer %s' % self.api_key,
        'Content-Type': 'application/json',
    },
    body=json.dumps(payload),
    request_timeout=15,
)
await AsyncHTTPClient().fetch(request)

No SDK, and no new package: AsyncHTTPClient is part of Tornado, which the site already runs on. The key is created in Resend's dashboard and can be restricted to sending, and to one domain, which is the least privilege the Gmail scopes never had. The visitor's address goes in reply_to, so answering them is just hitting Reply. The form never needs to read mail to make that work.

XSRF is on, and this time it is wired through. SpaStaticFileHandler touches self.xsrf_token when it serves the app shell, which sets the _xsrf cookie. Contact.svelte reads the cookie and sends it back in an X-XSRFToken header. Two tests hold that in place: test_valid_submission_sends gets the cookie the way a page load would and expects a send, and test_xsrf_required leaves it off and expects a 403. The old form had the setting and no test, which is how it spent nearly five years rejecting everyone.

Guards that say what they can't do

The guards are ordinary: required fields, length caps (a 10,000-character message, a 320-character address), a hidden website field as a honeypot, and a limit of five sends per hour per IP. The honeypot answers 200, so a bot gets no signal to adapt to.

The part worth copying is the limiter's docstring:

class RateLimiter:
    """
    In-memory and per-process, so it resets on restart and doesn't survive
    multiple workers.  That is fine for the volume this form will see; it
    exists to blunt a script, not to be an authority.
    """

That is a limitation written down at the point where someone would otherwise discover it. It names the condition that breaks it (more than one worker) and the job it is actually doing (slowing a script, not enforcing a quota). The same caveat is in this repo's agent instructions, so anyone who scales the site out reads it before they find out the hard way. Every guard has a limit, and the useful ones say so.

When OAuth is the right shape

When the account belongs to somebody else. If an app reads a customer's calendar, or sends mail as the customer, from the customer's own address, then delegation is the whole point, and the consent screen is part of the product.

When the account is yours and so is the server, use the thing built for servers: a transactional email API with a key, or SMTP with a password, which is what the 2017 version did in twenty lines. The test is whether there is anyone to delegate to. If the answer is you, OAuth adds a browser login, a token lifecycle, and a library stack to a job that needs one secret and one request.