A double opt-in newsletter sign-up on Amazon SES, without a newsletter tool

One of my sites has a blog that people find through search, read once and leave. I wanted a way to tell the interested ones when something new is up. The usual answer is a newsletter tool. The usual newsletter tool costs a monthly fee from the first subscriber, loads a script on my pages and keeps the list on its own servers.
The sign-up side of a newsletter is small enough to own. It went live in one afternoon, on mail infrastructure I was already paying cents for. This post covers that half: collecting addresses properly. Sending issues is a separate job.
The four pieces
A table. One row per address: the email, a status that is either pending or confirmed, a random token, the time of sign-up and the time of confirmation. The email column is unique. Nothing else is needed at this stage.
A sign-up route. It takes the address from the form, checks that it looks like one, creates the row as pending with a fresh token and sends one mail containing a confirmation link. If the address is already on the list, the answer should still be "check your inbox". A form that says "you are already subscribed" tells anyone who types an address whether that person is on your list.
A confirm route. The link in the mail leads here with the token. The route looks up the row, sets it to confirmed, stores the time and shows a short thank-you page. An unknown token gets a friendly page that says the link is no longer valid, and nothing else.
The mail. Plain, short, one link. No logo, no tracking pixel, no second call to action. It has a single job, and every extra element makes it look more like the kind of mail filters throw away.
That is double opt-in: the address is only on the list after its owner clicked. In Germany it is the standard you are expected to meet, and everywhere else it protects you from typos, from people signing up their colleagues and from bots filling the form with other people's addresses. A list of confirmed addresses is smaller and worth more.
The SES side
Amazon SES sends the confirmation mail. Four settings decide whether it arrives.
Production access. A new SES account can only mail addresses you verified yourself. I have written about that trap separately. Check it before anything else, in the region you send from.
A verified domain with DKIM. Verify the whole domain, not a single address, and publish the three DKIM records SES gives you. Mail from a domain without them lands in spam often enough to make the whole exercise pointless.
A custom MAIL FROM domain. By default the technical sender of SES mail is an Amazon address. Setting a subdomain of your own, such as bounce.example.com, with the MX and SPF records SES asks for, makes the visible sender and the technical sender belong to the same domain. Mail providers check that alignment.
A sender that can do one thing. The site uses an access key that belongs to a user who is allowed to send mail and nothing else.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}]
}
If that key ever leaks from an environment file, the worst case is mail sent in my name until I notice. It cannot read anything or change anything, and it cannot start servers.
I also put the sends into their own configuration set, so bounces and complaints for the newsletter can be told apart from the site's other mail later.
The sentence I took out
The first version of the form said how often the newsletter would come. It sounded good and specific. It was also a promise about a rhythm I had never kept, because the newsletter did not exist yet.
A promised frequency turns into a debt the moment the first person confirms. Miss it and you have broken the first thing you told them. So the form now says that a mail comes only when there is something new, the confirmation mail says the same, and no number appears anywhere. If a rhythm establishes itself, it can be written down then.
It is a small thing. It is also the kind of sentence that gets written in thirty seconds and stays on a site for five years.
The placeholder that broke the first deployment
The first deployment failed in the least interesting way possible. The sign-up route answered with an error, and the log showed the database refusing the request with a 401.
The route reads the database key from an environment variable. The variable was set. I had copied its value from the password manager entry for that database. That entry had been created long before, in a hurry, and the field for the key contained the word TODO.
The deployment had, faithfully, been configured with the secret TODO.
There is nothing clever to say about the fix: the real key went into the entry and into the deployment. The lesson is about the test. A sign-up is not tested by looking at the message on the page. It is tested by the full round trip: submit an address, find the mail in the inbox, click the link, and see the row in the table say confirmed. Only that proves the chain, and it is what caught the placeholder within minutes. I do exactly that after every deployment that touches the route, with an address of my own, and delete the test row and the test mail afterwards.
What is deliberately not there yet
There is no issue sender. When I build it, it has three rules before it sends a single mail: it mails only rows with status confirmed, every mail carries an unsubscribe link built from the row's token, and the same link sits in a List-Unsubscribe header so the mail program can show its own unsubscribe button. Bounces and complaints will come back through SES notifications and take the address off the list without my help.
There is also no pop-up. The sign-up box sits in the middle and at the end of each article, and a slim bar appears once a reader is past the halfway mark. Closing the bar hides it for thirty days. Someone who has read half an article has shown more interest than someone who just arrived, and asking them is fair.
Is it worth it
For a list that may reach a few hundred readers, yes. The cost is a table, two routes and cents per thousand mails. The addresses are mine, nothing third-party loads on the page, and I know exactly what happens to an address between the form and the row.
For a list in the tens of thousands with segments, automations and a team that wants a visual editor, buy the tool. That is what it is for. Most blogs are nowhere near that, and they pay the monthly fee anyway.
