Recently Written · git

recentlywritten

my writings (done recently)

git clone https://github.com/equwal/recentlywritten

Log | Files | Refs


commit b58a2fe46e6a33043e3779c0e57f14d874edc9b5
Spenser Truex <truex@equwal.com>
2026-09-22 23:28:15 -0700

Add accounts: email on new posts and a private feed

Add accounts/, a small Go service for the paths under /account/. The
web server continues to serve the static site.

- Sign-in with an email link. The link works one time and expires in
  15 minutes. A GET request shows a button, so mail scanners cannot
  use the link.
- Settings: email on or off, and the tags to receive.
- A private RSS feed for each user, for feed reader apps on Android
  and iOS. The user can replace the URL. The old URL then stops working.
- One email for each check that finds new posts. A row in deliveries
  is written before the send, so no user gets a post two times.
- Unsubscribe with a link in each email and with RFC 8058 one-click.
- Account deletion.

build.sh now writes postdata/index.tsv and postdata/<slug>.html for
the service, and the nav links to account/.

Secrets come only from environment variables. See
accounts/.env.example.

 .gitignore                          |   6 +
 accounts/.env.example               |  27 +++
 accounts/deploy/nginx-location.conf |   9 +
 accounts/deploy/rw-accounts.service |  24 +++
 accounts/feed.go                    |  94 +++++++++++
 accounts/feed_test.go               | 162 ++++++++++++++++++
 accounts/go.mod                     |  20 +++
 accounts/go.sum                     |  52 ++++++
 accounts/handlers.go                | 324 ++++++++++++++++++++++++++++++++++++
 accounts/handlers_test.go           | 245 +++++++++++++++++++++++++++
 accounts/helpers_test.go            | 116 +++++++++++++
 accounts/mail.go                    | 100 +++++++++++
 accounts/mail_test.go               |  98 +++++++++++
 accounts/main.go                    | 132 +++++++++++++++
 accounts/notify.go                  | 165 ++++++++++++++++++
 accounts/notify_test.go             | 225 +++++++++++++++++++++++++
 accounts/posts.go                   | 104 ++++++++++++
 accounts/store.go                   | 239 ++++++++++++++++++++++++++
 accounts/store_test.go              | 110 ++++++++++++
 accounts/templates.html             | 110 ++++++++++++
 build.sh                            |  14 ++
 index.html                          |   1 +
 22 files changed, 2377 insertions(+)
diff --git a/.gitignore b/.gitignore
index e84915c..53991d8 100755
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,9 @@
 # the working tree, so they have to be kept on disk (and backed up) by hand.
 /static/book/
 /site/static/book/
+
+# Build output and local settings of the accounts service.
+/accounts/accounts
+/accounts/accounts.exe
+/accounts/.env
+/accounts/*.db*
diff --git a/accounts/.env.example b/accounts/.env.example
new file mode 100644
index 0000000..2e0e973
--- /dev/null
+++ b/accounts/.env.example
@@ -0,0 +1,27 @@
+# Settings for the accounts service. Copy this file to
+# /etc/rw-accounts.env on the server, fill in the values, and make it
+# readable only by root (chmod 600). Do not commit the filled-in copy.
+
+# The public URL of the site, with no final slash.
+RW_BASE_URL=https://recentlywritten.com
+# The web root that build.sh deploys. The service reads postdata/ in it.
+RW_SITE_DIR=/var/www/recentlywritten
+# The SQLite database file. The directory must be writable by the service.
+RW_DB_PATH=/var/lib/rw-accounts/rw.db
+# The address and port that the service listens on. Optional.
+RW_LISTEN=127.0.0.1:8081
+# The time between two checks for new posts. Optional. Default: 15m.
+RW_NOTIFY_INTERVAL=15m
+
+# The sender address. Its domain needs SPF and DKIM records for the
+# SMTP provider.
+RW_MAIL_FROM=Recently Written <posts@recentlywritten.com>
+# Any SMTP server with STARTTLS: a local MTA, or the SMTP endpoint of a
+# provider such as Resend (smtp.resend.com), Postmark
+# (smtp.postmarkapp.com), or Amazon SES.
+SMTP_HOST=
+# Optional. Default: 587.
+SMTP_PORT=587
+# Leave empty for a server that needs no sign-in (for example a local MTA).
+SMTP_USERNAME=
+SMTP_PASSWORD=
diff --git a/accounts/deploy/nginx-location.conf b/accounts/deploy/nginx-location.conf
new file mode 100644
index 0000000..f8b6b1d
--- /dev/null
+++ b/accounts/deploy/nginx-location.conf
@@ -0,0 +1,9 @@
+# Example for nginx. Add this block to the server block of recentlywritten.com. nginx
+# continues to serve the static site. It sends only /account/ to the
+# accounts service.
+location /account/ {
+    proxy_pass http://127.0.0.1:8081;
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto $scheme;
+}
diff --git a/accounts/deploy/rw-accounts.service b/accounts/deploy/rw-accounts.service
new file mode 100644
index 0000000..516049e
--- /dev/null
+++ b/accounts/deploy/rw-accounts.service
@@ -0,0 +1,24 @@
+# systemd unit for the accounts service.
+# Install:
+#   GOOS=linux GOARCH=amd64 go build -o accounts .   (in accounts/)
+#   copy the binary to /usr/local/bin/rw-accounts
+#   copy this file to /etc/systemd/system/rw-accounts.service
+#   systemctl daemon-reload && systemctl enable --now rw-accounts
+[Unit]
+Description=Recently Written accounts, email and private feeds
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+EnvironmentFile=/etc/rw-accounts.env
+ExecStart=/usr/local/bin/rw-accounts
+DynamicUser=yes
+StateDirectory=rw-accounts
+ReadOnlyPaths=/var/www/recentlywritten
+ProtectSystem=strict
+ProtectHome=yes
+NoNewPrivileges=yes
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/accounts/feed.go b/accounts/feed.go
new file mode 100644
index 0000000..e3a4a45
--- /dev/null
+++ b/accounts/feed.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+	"encoding/xml"
+	"io"
+	"time"
+)
+
+// The rss types give the RSS 2.0 shape. encoding/xml does the escaping.
+type rss struct {
+	XMLName xml.Name `xml:"rss"`
+	Version string   `xml:"version,attr"`
+	Channel channel  `xml:"channel"`
+}
+
+type channel struct {
+	Title       string `xml:"title"`
+	Link        string `xml:"link"`
+	Description string `xml:"description"`
+	Language    string `xml:"language"`
+	Items       []item `xml:"item"`
+}
+
+type item struct {
+	Title       string   `xml:"title"`
+	Link        string   `xml:"link"`
+	GUID        guid     `xml:"guid"`
+	PubDate     string   `xml:"pubDate,omitempty"`
+	Categories  []string `xml:"category"`
+	Description string   `xml:"description"`
+}
+
+type guid struct {
+	IsPermaLink string `xml:"isPermaLink,attr"`
+	Value       string `xml:",chardata"`
+}
+
+// feedSize is the number of items in a private feed.
+const feedSize = 20
+
+// buildFeed makes the private feed for a user who muted the tags in muted.
+// body returns the HTML body of a post.
+func buildFeed(baseURL string, posts []Post, muted map[string]bool, body func(slug string) string) channel {
+	ch := channel{
+		Title:       "Recently Written",
+		Link:        baseURL + "/",
+		Description: "A site full of things which I have recently written.",
+		Language:    "en-us",
+	}
+	for _, p := range posts {
+		if len(ch.Items) == feedSize {
+			break
+		}
+		if !wanted(p, muted) {
+			continue
+		}
+		url := baseURL + "/" + p.Slug + ".html"
+		ch.Items = append(ch.Items, item{
+			Title:       p.Title,
+			Link:        url,
+			GUID:        guid{IsPermaLink: "true", Value: url},
+			PubDate:     feedDate(p.Date),
+			Categories:  p.Tags,
+			Description: body(p.Slug),
+		})
+	}
+	return ch
+}
+
+// feedDate gives the RFC 822 date that RSS requires, in the format that
+// build.sh uses. A date that does not parse gives an empty string, and the
+// item then has no pubDate.
+func feedDate(d string) string {
+	t, err := time.Parse("2006-01-02", d)
+	if err != nil {
+		return ""
+	}
+	return t.Format(time.RFC1123Z)
+}
+
+// writeFeed writes ch as an RSS 2.0 document. encoding/xml escapes the
+// text and replaces each character that XML 1.0 does not permit.
+func writeFeed(w io.Writer, ch channel) error {
+	if _, err := io.WriteString(w, xml.Header); err != nil {
+		return err
+	}
+	enc := xml.NewEncoder(w)
+	enc.Indent("", "  ")
+	if err := enc.Encode(rss{Version: "2.0", Channel: ch}); err != nil {
+		return err
+	}
+	_, err := io.WriteString(w, "\n")
+	return err
+}
diff --git a/accounts/feed_test.go b/accounts/feed_test.go
new file mode 100644
index 0000000..9098372
--- /dev/null
+++ b/accounts/feed_test.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+	"bytes"
+	"encoding/xml"
+	"errors"
+	"io"
+	"slices"
+	"strings"
+	"testing"
+
+	"pgregory.net/rapid"
+)
+
+// wellFormed reads each token of doc with a strict XML parser.
+func wellFormed(doc []byte) error {
+	d := xml.NewDecoder(bytes.NewReader(doc))
+	for {
+		_, err := d.Token()
+		if errors.Is(err, io.EOF) {
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+	}
+}
+
+// anyText draws any string, also strings that are not valid UTF-8 and
+// strings with characters that XML 1.0 does not permit.
+func anyText() *rapid.Generator[string] {
+	return rapid.OneOf(
+		rapid.String(),
+		rapid.Map(rapid.SliceOf(rapid.Byte()), func(b []byte) string { return string(b) }),
+		rapid.SampledFrom([]string{"]]>", "<![CDATA[", "&amp;", "</item>", "\x00", "\r\n", "\uFFFE"}),
+	)
+}
+
+// xmlText draws strings of characters that XML 1.0 permits.
+func xmlText() *rapid.Generator[string] {
+	return rapid.StringOf(rapid.Rune().Filter(func(r rune) bool {
+		return r == '\t' || r == '\n' || r == '\r' ||
+			r >= 0x20 && r <= 0xD7FF || r >= 0xE000 && r <= 0xFFFD || r >= 0x10000 && r <= 0x10FFFF
+	}))
+}
+
+func drawChannel(t *rapid.T, text *rapid.Generator[string]) channel {
+	ch := channel{Title: text.Draw(t, "title"), Link: text.Draw(t, "link"),
+		Description: text.Draw(t, "desc"), Language: "en-us"}
+	for range rapid.IntRange(0, 3).Draw(t, "items") {
+		ch.Items = append(ch.Items, item{
+			Title:       text.Draw(t, "item title"),
+			Link:        text.Draw(t, "item link"),
+			GUID:        guid{IsPermaLink: "true", Value: text.Draw(t, "guid")},
+			PubDate:     text.Draw(t, "date"),
+			Categories:  rapid.SliceOfN(text, 0, 2).Draw(t, "tags"),
+			Description: text.Draw(t, "body"),
+		})
+	}
+	return ch
+}
+
+func TestFeedIsWellFormedProperty(t *testing.T) {
+	rapid.Check(t, func(t *rapid.T) {
+		var b bytes.Buffer
+		if err := writeFeed(&b, drawChannel(t, anyText())); err != nil {
+			t.Fatal(err)
+		}
+		if err := wellFormed(b.Bytes()); err != nil {
+			t.Fatalf("feed is not well-formed: %v\n%s", err, b.String())
+		}
+	})
+}
+
+// TestFeedRoundTripProperty checks the escaping: an XML parser gives back
+// each text exactly.
+func TestFeedRoundTripProperty(t *testing.T) {
+	rapid.Check(t, func(t *rapid.T) {
+		want := drawChannel(t, xmlText())
+		var b bytes.Buffer
+		if err := writeFeed(&b, want); err != nil {
+			t.Fatal(err)
+		}
+		var got rss
+		if err := xml.Unmarshal(b.Bytes(), &got); err != nil {
+			t.Fatalf("unmarshal: %v\n%s", err, b.String())
+		}
+		g := got.Channel
+		if g.Title != want.Title || g.Link != want.Link || g.Description != want.Description ||
+			len(g.Items) != len(want.Items) {
+			t.Fatalf("channel: got %+v, want %+v", g, want)
+		}
+		for i, w := range want.Items {
+			gi := g.Items[i]
+			if gi.Title != w.Title || gi.Link != w.Link || gi.GUID != w.GUID ||
+				gi.PubDate != w.PubDate || gi.Description != w.Description ||
+				!slices.Equal(gi.Categories, w.Categories) {
+				t.Fatalf("item %d: got %+v, want %+v", i, gi, w)
+			}
+		}
+	})
+}
+
+func TestBuildFeedFiltersAndLimits(t *testing.T) {
+	var posts []Post
+	for i := range 30 {
+		tag := "articles"
+		if i%2 == 1 {
+			tag = "projects"
+		}
+		posts = append(posts, Post{Date: "2026-09-01", Slug: "p" + string(rune('a'+i)), Tags: []string{tag}, Title: "T"})
+	}
+	posts = append(posts, Post{Date: "bad", Slug: "untagged", Title: "U"})
+
+	ch := buildFeed("https://example.com", posts, map[string]bool{"projects": true}, func(string) string { return "" })
+	if len(ch.Items) != 16 { // 15 articles and the untagged post
+		t.Fatalf("got %d items, want 16", len(ch.Items))
+	}
+	for _, it := range ch.Items {
+		if slices.Contains(it.Categories, "projects") {
+			t.Errorf("muted tag in feed: %+v", it)
+		}
+	}
+	if got := ch.Items[0].PubDate; got != "Tue, 01 Sep 2026 00:00:00 +0000" {
+		t.Errorf("pubDate = %q", got)
+	}
+	if got := ch.Items[15].PubDate; got != "" {
+		t.Errorf("pubDate for a bad date = %q, want empty", got)
+	}
+
+	ch = buildFeed("https://example.com", posts, nil, func(string) string { return "" })
+	if len(ch.Items) != feedSize {
+		t.Fatalf("got %d items, want %d", len(ch.Items), feedSize)
+	}
+}
+
+func TestParseIndex(t *testing.T) {
+	in := "2026-09-22\trebind\tprojects\tRebind: a tool\r\n\n2025-01-01\tx\tlisp, unix\tA \"b\" & c\n"
+	posts, err := parseIndex(strings.NewReader(in))
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []Post{
+		{Date: "2026-09-22", Slug: "rebind", Tags: []string{"projects"}, Title: "Rebind: a tool"},
+		{Date: "2025-01-01", Slug: "x", Tags: []string{"lisp", "unix"}, Title: `A "b" & c`},
+	}
+	if len(posts) != len(want) {
+		t.Fatalf("got %+v", posts)
+	}
+	for i := range want {
+		if posts[i].Slug != want[i].Slug || posts[i].Title != want[i].Title ||
+			posts[i].Date != want[i].Date || !slices.Equal(posts[i].Tags, want[i].Tags) {
+			t.Errorf("post %d = %+v, want %+v", i, posts[i], want[i])
+		}
+	}
+
+	for _, bad := range []string{"a\t../etc/passwd\tx\tT\n", "a\t.hidden\tx\tT\n", "only\tthree\tfields\n"} {
+		if _, err := parseIndex(strings.NewReader(bad)); err == nil {
+			t.Errorf("parseIndex(%q) gave no error", bad)
+		}
+	}
+}
diff --git a/accounts/go.mod b/accounts/go.mod
new file mode 100644
index 0000000..688ddca
--- /dev/null
+++ b/accounts/go.mod
@@ -0,0 +1,20 @@
+module github.com/equwal/recentlywritten/accounts
+
+go 1.26.2
+
+require (
+	modernc.org/sqlite v1.59.0
+	pgregory.net/rapid v1.3.0
+)
+
+require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/mattn/go-isatty v0.0.24 // indirect
+	github.com/ncruces/go-strftime v1.0.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	golang.org/x/sys v0.47.0 // indirect
+	modernc.org/libc v1.75.7 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.12.1 // indirect
+)
diff --git a/accounts/go.sum b/accounts/go.sum
new file mode 100644
index 0000000..50c775d
--- /dev/null
+++ b/accounts/go.sum
@@ -0,0 +1,52 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
+modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
+modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
+modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
+pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
diff --git a/accounts/handlers.go b/accounts/handlers.go
new file mode 100644
index 0000000..b7b826b
--- /dev/null
+++ b/accounts/handlers.go
@@ -0,0 +1,324 @@
+package main
+
+import (
+	"bytes"
+	"database/sql"
+	_ "embed"
+	"errors"
+	"html/template"
+	"log"
+	"net/http"
+	"net/mail"
+	"strings"
+	"time"
+)
+
+//go:embed templates.html
+var templateText string
+
+var pages = template.Must(template.New("").Parse(templateText))
+
+type app struct {
+	cfg  config
+	db   *sql.DB
+	mail mailer
+	now  func() time.Time
+	log  *log.Logger
+}
+
+const sessionCookie = "rw_session"
+
+// routes gives the handler for all paths under /account/. The web server
+// in front sends only these paths to this service.
+//
+// Each change of state uses POST. A GET request from a link in an email
+// only shows a page with a button, because mail scanners open the links
+// in email and must not use a sign-in link or unsubscribe a user. The
+// session cookie is SameSite=Lax, so a form on another site cannot send a
+// POST request with the cookie.
+func (a *app) routes() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /account/{$}", a.home)
+	mux.HandleFunc("POST /account/signin", a.signin)
+	mux.HandleFunc("GET /account/verify", a.verifyPage)
+	mux.HandleFunc("POST /account/verify", a.verify)
+	mux.HandleFunc("POST /account/settings", a.withUser(a.settings))
+	mux.HandleFunc("POST /account/feed/rotate", a.withUser(a.rotateFeed))
+	mux.HandleFunc("POST /account/signout", a.signout)
+	mux.HandleFunc("POST /account/delete", a.withUser(a.deleteAccount))
+	mux.HandleFunc("GET /account/unsubscribe", a.unsubscribePage)
+	mux.HandleFunc("POST /account/unsubscribe", a.unsubscribe)
+	mux.HandleFunc("GET /account/feed/{token}", a.feed)
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// The URLs of some pages contain tokens. These headers keep the
+		// tokens out of caches and out of the Referer header.
+		w.Header().Set("Cache-Control", "no-store")
+		w.Header().Set("Referrer-Policy", "no-referrer")
+		r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
+		mux.ServeHTTP(w, r)
+	})
+}
+
+func (a *app) render(w http.ResponseWriter, status int, name string, data any) {
+	var b bytes.Buffer
+	if err := pages.ExecuteTemplate(&b, name, data); err != nil {
+		a.fail(w, err)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(status)
+	b.WriteTo(w)
+}
+
+func (a *app) fail(w http.ResponseWriter, err error) {
+	a.log.Printf("error: %v", err)
+	http.Error(w, "Internal error", http.StatusInternalServerError)
+}
+
+func (a *app) currentUser(r *http.Request) (user, error) {
+	c, err := r.Cookie(sessionCookie)
+	if err != nil {
+		return user{}, errNotFound
+	}
+	return sessionUser(a.db, c.Value, a.now())
+}
+
+func (a *app) withUser(h func(http.ResponseWriter, *http.Request, user)) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		u, err := a.currentUser(r)
+		if errors.Is(err, errNotFound) {
+			http.Redirect(w, r, "/account/", http.StatusSeeOther)
+			return
+		}
+		if err != nil {
+			a.fail(w, err)
+			return
+		}
+		h(w, r, u)
+	}
+}
+
+func (a *app) setSessionCookie(w http.ResponseWriter, value string, maxAge int) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookie,
+		Value:    value,
+		Path:     "/account/",
+		MaxAge:   maxAge,
+		HttpOnly: true,
+		Secure:   strings.HasPrefix(a.cfg.BaseURL, "https://"),
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+type tagChoice struct {
+	Name string
+	On   bool
+}
+
+func (a *app) home(w http.ResponseWriter, r *http.Request) {
+	u, err := a.currentUser(r)
+	if errors.Is(err, errNotFound) {
+		a.render(w, http.StatusOK, "signin", map[string]string{})
+		return
+	}
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	muted, err := mutedTags(a.db, u.ID)
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	posts, err := loadPosts(a.cfg.SiteDir)
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	var tags []tagChoice
+	for _, t := range allTags(posts) {
+		tags = append(tags, tagChoice{Name: t, On: !muted[t]})
+	}
+	a.render(w, http.StatusOK, "settings", map[string]any{
+		"Email":   u.Email,
+		"EmailOn": u.EmailOn,
+		"Tags":    tags,
+		"FeedURL": a.cfg.BaseURL + "/account/feed/" + u.FeedToken,
+	})
+}
+
+// normalizeEmail returns the address in lower case, or an error if s is
+// not one plain address such as "name@example.com".
+func normalizeEmail(s string) (string, error) {
+	s = strings.TrimSpace(s)
+	addr, err := mail.ParseAddress(s)
+	if err != nil || addr.Name != "" || addr.Address != s || len(s) > 254 {
+		return "", errors.New("not a valid address")
+	}
+	return strings.ToLower(s), nil
+}
+
+func (a *app) signin(w http.ResponseWriter, r *http.Request) {
+	email, err := normalizeEmail(r.PostFormValue("email"))
+	if err != nil {
+		a.render(w, http.StatusBadRequest, "signin", map[string]string{"Error": "Type a valid email address."})
+		return
+	}
+	token, err := createLoginToken(a.db, email, a.now())
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	// The page is the same when createLoginToken sends nothing, so the
+	// page does not tell which addresses have an account.
+	if token != "" {
+		link := a.cfg.BaseURL + "/account/verify?t=" + token
+		err = a.mail.Send(message{
+			To:      email,
+			Subject: "Sign in to Recently Written",
+			Body: "Open this link to sign in to Recently Written:\n\n" + link +
+				"\n\nThe link works one time and expires in 15 minutes.\n" +
+				"If you did not ask for this email, ignore it.\n",
+		})
+		if err != nil {
+			a.fail(w, err)
+			return
+		}
+	}
+	a.render(w, http.StatusOK, "sent", nil)
+}
+
+func (a *app) verifyPage(w http.ResponseWriter, r *http.Request) {
+	a.render(w, http.StatusOK, "verify", r.URL.Query().Get("t"))
+}
+
+func (a *app) verify(w http.ResponseWriter, r *http.Request) {
+	u, err := consumeLoginToken(a.db, r.PostFormValue("t"), a.now())
+	if errors.Is(err, errNotFound) {
+		a.render(w, http.StatusBadRequest, "badlink", nil)
+		return
+	}
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	token, err := createSession(a.db, u.ID, a.now())
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	a.setSessionCookie(w, token, int(sessionTTL.Seconds()))
+	http.Redirect(w, r, "/account/", http.StatusSeeOther)
+}
+
+func (a *app) settings(w http.ResponseWriter, r *http.Request, u user) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Bad request", http.StatusBadRequest)
+		return
+	}
+	posts, err := loadPosts(a.cfg.SiteDir)
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	checked := map[string]bool{}
+	for _, t := range r.PostForm["tag"] {
+		checked[t] = true
+	}
+	// A tag is muted if the form shows it and the user cleared its box.
+	var muted []string
+	for _, t := range allTags(posts) {
+		if !checked[t] {
+			muted = append(muted, t)
+		}
+	}
+	if err := saveSettings(a.db, u.ID, r.PostForm.Get("email_on") == "1", muted); err != nil {
+		a.fail(w, err)
+		return
+	}
+	http.Redirect(w, r, "/account/", http.StatusSeeOther)
+}
+
+func (a *app) rotateFeed(w http.ResponseWriter, r *http.Request, u user) {
+	if err := rotateFeedToken(a.db, u.ID); err != nil {
+		a.fail(w, err)
+		return
+	}
+	http.Redirect(w, r, "/account/", http.StatusSeeOther)
+}
+
+func (a *app) signout(w http.ResponseWriter, r *http.Request) {
+	if c, err := r.Cookie(sessionCookie); err == nil {
+		if err := deleteSession(a.db, c.Value); err != nil {
+			a.fail(w, err)
+			return
+		}
+	}
+	a.setSessionCookie(w, "", -1)
+	http.Redirect(w, r, "/account/", http.StatusSeeOther)
+}
+
+func (a *app) deleteAccount(w http.ResponseWriter, r *http.Request, u user) {
+	if err := deleteUser(a.db, u.ID); err != nil {
+		a.fail(w, err)
+		return
+	}
+	a.setSessionCookie(w, "", -1)
+	a.render(w, http.StatusOK, "deleted", nil)
+}
+
+func (a *app) unsubscribePage(w http.ResponseWriter, r *http.Request) {
+	a.render(w, http.StatusOK, "unsubscribe", r.URL.Query().Get("t"))
+}
+
+// unsubscribe takes the token from the form, or from the URL. A mail
+// client that does RFC 8058 one-click unsubscribe sends a POST request to
+// the URL in the List-Unsubscribe header, with the token in the URL.
+func (a *app) unsubscribe(w http.ResponseWriter, r *http.Request) {
+	token := r.PostFormValue("t")
+	if token == "" {
+		token = r.URL.Query().Get("t")
+	}
+	err := unsubscribe(a.db, token)
+	if errors.Is(err, errNotFound) {
+		a.render(w, http.StatusBadRequest, "badlink", nil)
+		return
+	}
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	a.render(w, http.StatusOK, "unsubscribed", nil)
+}
+
+func (a *app) feed(w http.ResponseWriter, r *http.Request) {
+	u, err := userByFeedToken(a.db, r.PathValue("token"))
+	if errors.Is(err, errNotFound) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	muted, err := mutedTags(a.db, u.ID)
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	posts, err := loadPosts(a.cfg.SiteDir)
+	if err != nil {
+		a.fail(w, err)
+		return
+	}
+	ch := buildFeed(a.cfg.BaseURL, posts, muted, func(slug string) string {
+		return loadBody(a.cfg.SiteDir, slug)
+	})
+	var b bytes.Buffer
+	if err := writeFeed(&b, ch); err != nil {
+		a.fail(w, err)
+		return
+	}
+	w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
+	b.WriteTo(w)
+}
diff --git a/accounts/handlers_test.go b/accounts/handlers_test.go
new file mode 100644
index 0000000..04ba7d7
--- /dev/null
+++ b/accounts/handlers_test.go
@@ -0,0 +1,245 @@
+package main
+
+import (
+	"errors"
+	"io"
+	"net/http"
+	"net/http/cookiejar"
+	"net/http/httptest"
+	"net/url"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+type browser struct {
+	t      *testing.T
+	client *http.Client
+	base   string
+}
+
+// newBrowser starts the app on a test server and returns a client with a
+// cookie jar.
+func newBrowser(t *testing.T, e *testEnv) *browser {
+	srv := httptest.NewServer(e.app.routes())
+	t.Cleanup(srv.Close)
+	e.app.cfg.BaseURL = srv.URL
+	jar, _ := cookiejar.New(nil)
+	return &browser{t: t, client: &http.Client{Jar: jar}, base: srv.URL}
+}
+
+func (b *browser) do(method, path string, form url.Values) (int, string) {
+	b.t.Helper()
+	var body io.Reader
+	if form != nil {
+		body = strings.NewReader(form.Encode())
+	}
+	req, err := http.NewRequest(method, b.base+path, body)
+	if err != nil {
+		b.t.Fatal(err)
+	}
+	if form != nil {
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	}
+	resp, err := b.client.Do(req)
+	if err != nil {
+		b.t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	text, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(text)
+}
+
+var linkToken = regexp.MustCompile(`/account/verify\?t=([A-Z2-7]+)`)
+
+// signIn does the sign-in steps of a person in a browser and returns the
+// settings page.
+func (b *browser) signIn(e *testEnv, email string) string {
+	b.t.Helper()
+	n := len(e.mail.sent)
+	if code, page := b.do("POST", "/account/signin", url.Values{"email": {email}}); code != 200 || !strings.Contains(page, "Check your email") {
+		b.t.Fatalf("signin: %d\n%s", code, page)
+	}
+	if len(e.mail.sent) != n+1 {
+		b.t.Fatalf("sent %d sign-in messages, want 1", len(e.mail.sent)-n)
+	}
+	m := linkToken.FindStringSubmatch(e.mail.sent[n].Body)
+	if m == nil {
+		b.t.Fatalf("no link in:\n%s", e.mail.sent[n].Body)
+	}
+	// A GET request, as from a mail scanner, must not use the token.
+	if code, page := b.do("GET", "/account/verify?t="+m[1], nil); code != 200 || !strings.Contains(page, `value="`+m[1]+`"`) {
+		b.t.Fatalf("verify page: %d\n%s", code, page)
+	}
+	code, page := b.do("POST", "/account/verify", url.Values{"t": {m[1]}})
+	if code != 200 || !strings.Contains(page, "Signed in as "+strings.ToLower(email)) {
+		b.t.Fatalf("verify: %d\n%s", code, page)
+	}
+	return page
+}
+
+var feedURL = regexp.MustCompile(`/account/feed/[A-Z2-7]+`)
+
+func TestSignUpAndPrivateFeed(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	b := newBrowser(t, e)
+
+	if code, page := b.do("GET", "/account/", nil); code != 200 || !strings.Contains(page, `action="/account/signin"`) {
+		t.Fatalf("signed-out page: %d\n%s", code, page)
+	}
+	page := b.signIn(e, "Reader@Example.com")
+	if e.mail.sent[0].To != "reader@example.com" {
+		t.Errorf("sign-in mail went to %q", e.mail.sent[0].To)
+	}
+
+	feed := feedURL.FindString(page)
+	if feed == "" {
+		t.Fatalf("no feed URL on the settings page:\n%s", page)
+	}
+	// The feed needs no cookie, because a feed reader app has none.
+	anon := &browser{t: t, client: http.DefaultClient, base: b.base}
+	code, doc := anon.do("GET", feed, nil)
+	if code != 200 || wellFormed([]byte(doc)) != nil {
+		t.Fatalf("feed: %d\n%s", code, doc)
+	}
+	for _, want := range []string{"<title>Old article</title>", "old-project.html", "Body of old-article &amp;amp; more."} {
+		if !strings.Contains(doc, want) {
+			t.Errorf("feed has no %q:\n%s", want, doc)
+		}
+	}
+
+	// Mute "projects". The feed then leaves out the project post.
+	b.do("POST", "/account/settings", url.Values{"email_on": {"1"}, "tag": {"articles"}})
+	if _, doc := anon.do("GET", feed, nil); strings.Contains(doc, "old-project.html") || !strings.Contains(doc, "old-article.html") {
+		t.Errorf("muted tag still in feed:\n%s", doc)
+	}
+
+	// A new feed URL revokes the old one.
+	_, page = b.do("POST", "/account/feed/rotate", nil)
+	newFeed := feedURL.FindString(page)
+	if newFeed == "" || newFeed == feed {
+		t.Fatalf("feed URL did not change: %q", newFeed)
+	}
+	if code, _ := anon.do("GET", feed, nil); code != 404 {
+		t.Errorf("old feed URL gave %d, want 404", code)
+	}
+	if code, _ := anon.do("GET", newFeed, nil); code != 200 {
+		t.Errorf("new feed URL gave %d, want 200", code)
+	}
+
+	// After sign-out, the settings page asks for an address again.
+	if _, page := b.do("POST", "/account/signout", nil); !strings.Contains(page, `action="/account/signin"`) {
+		t.Errorf("still signed in after sign-out:\n%s", page)
+	}
+}
+
+func TestSignInRejectsBadAddress(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	b := newBrowser(t, e)
+	for _, bad := range []string{"", "no-at-sign", "Name <a@example.com>", "a@example.com\r\nBcc: x@example.com"} {
+		if code, _ := b.do("POST", "/account/signin", url.Values{"email": {bad}}); code != 400 {
+			t.Errorf("address %q gave %d, want 400", bad, code)
+		}
+	}
+	if len(e.mail.sent) != 0 {
+		t.Errorf("sent mail for a bad address: %+v", e.mail.sent)
+	}
+}
+
+func TestUsedLinkFails(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	b := newBrowser(t, e)
+	b.signIn(e, "a@example.com")
+	tok := linkToken.FindStringSubmatch(e.mail.sent[0].Body)[1]
+	if code, page := b.do("POST", "/account/verify", url.Values{"t": {tok}}); code != 400 || !strings.Contains(page, "not valid") {
+		t.Errorf("second use: %d\n%s", code, page)
+	}
+}
+
+func TestUnsubscribe(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	b := newBrowser(t, e)
+	u := e.signUp(t, "a@example.com")
+
+	// The link in the email body shows a button, and changes nothing.
+	if code, page := b.do("GET", "/account/unsubscribe?t="+u.UnsubToken, nil); code != 200 || !strings.Contains(page, "Stop the emails") {
+		t.Fatalf("unsubscribe page: %d\n%s", code, page)
+	}
+	if on := emailOn(t, e, u.ID); !on {
+		t.Fatal("GET request turned off email")
+	}
+
+	// RFC 8058 one-click: the mail client sends a POST to the header URL.
+	code, _ := b.do("POST", "/account/unsubscribe?t="+u.UnsubToken, url.Values{"List-Unsubscribe": {"One-Click"}})
+	if code != 200 {
+		t.Fatalf("one-click unsubscribe gave %d", code)
+	}
+	if emailOn(t, e, u.ID) {
+		t.Fatal("email is still on after unsubscribe")
+	}
+
+	if code, _ := b.do("POST", "/account/unsubscribe", url.Values{"t": {"WRONG"}}); code != 400 {
+		t.Errorf("wrong token gave %d, want 400", code)
+	}
+}
+
+func emailOn(t *testing.T, e *testEnv, id int64) bool {
+	t.Helper()
+	var on bool
+	if err := e.app.db.QueryRow(`SELECT email_on FROM users WHERE id = ?`, id).Scan(&on); err != nil {
+		t.Fatal(err)
+	}
+	return on
+}
+
+func TestDeleteAccount(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	b := newBrowser(t, e)
+	page := b.signIn(e, "a@example.com")
+	feed := feedURL.FindString(page)
+	u, err := userByFeedToken(e.app.db, strings.TrimPrefix(feed, "/account/feed/"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := saveSettings(e.app.db, u.ID, true, []string{"projects"}); err != nil {
+		t.Fatal(err)
+	}
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+
+	if code, page := b.do("POST", "/account/delete", nil); code != 200 || !strings.Contains(page, "Account deleted") {
+		t.Fatalf("delete: %d\n%s", code, page)
+	}
+	for _, table := range []string{"users", "sessions", "muted_tags", "deliveries"} {
+		var n int
+		if err := e.app.db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
+			t.Fatal(err)
+		}
+		if n != 0 {
+			t.Errorf("%s has %d rows after delete", table, n)
+		}
+	}
+	if code, _ := b.do("GET", feed, nil); code != 404 {
+		t.Errorf("feed after delete gave %d, want 404", code)
+	}
+	if _, err := userByFeedToken(e.app.db, u.FeedToken); !errors.Is(err, errNotFound) {
+		t.Errorf("user still there: %v", err)
+	}
+}
+
+func TestStateChangesNeedSession(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	u := e.signUp(t, "a@example.com")
+	b := newBrowser(t, e)
+	// No cookie: each POST goes to the sign-in page and changes nothing.
+	for _, path := range []string{"/account/delete", "/account/settings", "/account/feed/rotate"} {
+		if _, page := b.do("POST", path, url.Values{}); !strings.Contains(page, `action="/account/signin"`) {
+			t.Errorf("%s without a session:\n%s", path, page)
+		}
+	}
+	after, err := userByFeedToken(e.app.db, u.FeedToken)
+	if err != nil || !after.EmailOn {
+		t.Errorf("user changed: %+v, %v", after, err)
+	}
+}
diff --git a/accounts/helpers_test.go b/accounts/helpers_test.go
new file mode 100644
index 0000000..478ec84
--- /dev/null
+++ b/accounts/helpers_test.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+	"errors"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+)
+
+// captureMailer keeps each message. If fail is true, Send returns an error
+// and keeps nothing.
+type captureMailer struct {
+	mu   sync.Mutex
+	sent []message
+	fail bool
+}
+
+func (c *captureMailer) Send(m message) error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if c.fail {
+		return errors.New("smtp is down")
+	}
+	c.sent = append(c.sent, m)
+	return nil
+}
+
+// clock is a time source that a test moves forward.
+type clock struct{ t time.Time }
+
+func (c *clock) now() time.Time { return c.t }
+
+type testEnv struct {
+	app   *app
+	mail  *captureMailer
+	clock *clock
+}
+
+// fataler is the part of testing.TB that the helpers use. *rapid.T has
+// these methods too.
+type fataler interface {
+	Helper()
+	Fatal(args ...any)
+	Fatalf(format string, args ...any)
+}
+
+// newTestEnv makes an app with a :memory: database and a site directory
+// that has the posts in index.
+func newTestEnv(t *testing.T, index []Post) *testEnv {
+	t.Helper()
+	db, err := openDB(":memory:")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { db.Close() })
+	site := t.TempDir()
+	if err := os.MkdirAll(filepath.Join(site, "postdata"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	e := &testEnv{
+		mail:  &captureMailer{},
+		clock: &clock{t: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)},
+	}
+	e.app = &app{
+		cfg:  config{BaseURL: "https://example.com", SiteDir: site},
+		db:   db,
+		mail: e.mail,
+		log:  log.New(io.Discard, "", 0),
+	}
+	e.app.now = e.clock.now
+	e.writeIndex(t, index)
+	return e
+}
+
+// writeIndex writes postdata/index.tsv and a body for each post, as
+// build.sh does.
+func (e *testEnv) writeIndex(t fataler, posts []Post) {
+	t.Helper()
+	var b strings.Builder
+	for _, p := range posts {
+		b.WriteString(p.Date + "\t" + p.Slug + "\t" + strings.Join(p.Tags, " ") + "\t" + p.Title + "\n")
+		body := "<p>Body of " + p.Slug + " &amp; more.</p>\n"
+		if err := os.WriteFile(filepath.Join(e.app.cfg.SiteDir, "postdata", p.Slug+".html"), []byte(body), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := os.WriteFile(filepath.Join(e.app.cfg.SiteDir, "postdata", "index.tsv"), []byte(b.String()), 0o644); err != nil {
+		t.Fatal(err)
+	}
+}
+
+// signUp makes a verified user for email and returns it.
+func (e *testEnv) signUp(t fataler, email string) user {
+	t.Helper()
+	tok, err := createLoginToken(e.app.db, email, e.clock.now())
+	if err != nil || tok == "" {
+		t.Fatalf("createLoginToken: %q, %v", tok, err)
+	}
+	u, err := consumeLoginToken(e.app.db, tok, e.clock.now())
+	if err != nil {
+		t.Fatal(err)
+	}
+	return u
+}
+
+func (e *testEnv) advance(d time.Duration) { e.clock.t = e.clock.t.Add(d) }
+
+var oldPosts = []Post{
+	{Date: "2025-09-11", Slug: "old-article", Tags: []string{"articles"}, Title: "Old article"},
+	{Date: "2025-09-06", Slug: "old-project", Tags: []string{"projects"}, Title: "Old project"},
+}
diff --git a/accounts/mail.go b/accounts/mail.go
new file mode 100644
index 0000000..bbc4e9e
--- /dev/null
+++ b/accounts/mail.go
@@ -0,0 +1,100 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"mime"
+	"mime/quotedprintable"
+	"net"
+	"net/mail"
+	"net/smtp"
+	"strings"
+	"time"
+)
+
+// message is one plain text email to one address.
+type message struct {
+	To      string
+	Subject string
+	Body    string
+	// Unsubscribe is a URL for the List-Unsubscribe header (RFC 2369) and
+	// for one-click unsubscribe (RFC 8058). It is empty for sign-in email.
+	Unsubscribe string
+}
+
+type mailer interface {
+	Send(m message) error
+}
+
+// smtpMailer sends through any SMTP server: a local MTA, or the SMTP
+// endpoint of a hosted provider (Resend, Postmark, Amazon SES).
+type smtpMailer struct {
+	Addr     string // host:port
+	Username string // empty for a server that needs no sign-in
+	Password string
+	From     *mail.Address
+}
+
+// Send uses STARTTLS when the server offers it. net/smtp PlainAuth does
+// not send the password on a connection without TLS, except to localhost.
+func (s smtpMailer) Send(m message) error {
+	var auth smtp.Auth
+	if s.Username != "" {
+		host, _, _ := net.SplitHostPort(s.Addr)
+		auth = smtp.PlainAuth("", s.Username, s.Password, host)
+	}
+	data, err := formatMessage(s.From, m, time.Now())
+	if err != nil {
+		return err
+	}
+	return smtp.SendMail(s.Addr, auth, s.From.Address, []string{m.To}, data)
+}
+
+// logMailer writes each message to w. Use it only for development: the
+// messages contain sign-in links.
+type logMailer struct{ W io.Writer }
+
+func (l logMailer) Send(m message) error {
+	_, err := fmt.Fprintf(l.W, "--- mail to %s\nSubject: %s\n\n%s\n---\n", m.To, m.Subject, m.Body)
+	return err
+}
+
+// formatMessage makes an RFC 5322 message. The subject is encoded as an
+// RFC 2047 word, and the body as quoted-printable, so that non-ASCII text
+// and long lines arrive intact.
+func formatMessage(from *mail.Address, m message, now time.Time) ([]byte, error) {
+	to, err := mail.ParseAddress(m.To)
+	if err != nil {
+		return nil, err
+	}
+	// A line break in a header value would start a new header.
+	subject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Subject)
+	domain := from.Address[strings.LastIndex(from.Address, "@")+1:]
+
+	var b bytes.Buffer
+	h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) }
+	h("From", from.String())
+	h("To", to.String())
+	h("Subject", mime.QEncoding.Encode("utf-8", subject))
+	h("Date", now.Format(time.RFC1123Z))
+	h("Message-ID", "<"+strings.ToLower(newToken())+"@"+domain+">")
+	h("MIME-Version", "1.0")
+	h("Content-Type", "text/plain; charset=utf-8")
+	h("Content-Transfer-Encoding", "quoted-printable")
+	if m.Unsubscribe != "" {
+		h("List-Unsubscribe", "<"+m.Unsubscribe+">")
+		h("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
+	}
+	b.WriteString("\r\n")
+	qp := quotedprintable.NewWriter(&b)
+	// Email lines end with CRLF. A CR or LF alone becomes CRLF.
+	crlf := strings.NewReplacer("\r\n", "\r\n", "\r", "\r\n", "\n", "\r\n")
+	if _, err := qp.Write([]byte(crlf.Replace(m.Body))); err != nil {
+		return nil, err
+	}
+	if err := qp.Close(); err != nil {
+		return nil, err
+	}
+	return b.Bytes(), nil
+}
diff --git a/accounts/mail_test.go b/accounts/mail_test.go
new file mode 100644
index 0000000..295c0ac
--- /dev/null
+++ b/accounts/mail_test.go
@@ -0,0 +1,98 @@
+package main
+
+import (
+	"bytes"
+	"io"
+	"mime"
+	"mime/quotedprintable"
+	"net/mail"
+	"strings"
+	"testing"
+	"time"
+	"unicode/utf8"
+
+	"pgregory.net/rapid"
+)
+
+var testFrom = &mail.Address{Name: "Recently Written", Address: "posts@example.com"}
+
+// TestFormatMessageProperty checks that any subject and body give a
+// message that parses, has exactly the expected headers, and decodes back
+// to the same subject and body.
+func TestFormatMessageProperty(t *testing.T) {
+	rapid.Check(t, func(t *rapid.T) {
+		valid := rapid.String().Filter(utf8.ValidString)
+		m := message{
+			To:          "reader@example.com",
+			Subject:     valid.Draw(t, "subject"),
+			Body:        valid.Draw(t, "body"),
+			Unsubscribe: rapid.SampledFrom([]string{"", "https://example.com/account/unsubscribe?t=ABC"}).Draw(t, "unsub"),
+		}
+		data, err := formatMessage(testFrom, m, time.Unix(0, 0))
+		if err != nil {
+			t.Fatal(err)
+		}
+		msg, err := mail.ReadMessage(bytes.NewReader(data))
+		if err != nil {
+			t.Fatalf("message does not parse: %v\n%q", err, data)
+		}
+		wantHeaders := 8
+		if m.Unsubscribe != "" {
+			wantHeaders += 2
+		}
+		if len(msg.Header) != wantHeaders {
+			t.Fatalf("got headers %v, want %d (a line break in the subject must not add one)", msg.Header, wantHeaders)
+		}
+		subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject"))
+		if err != nil {
+			t.Fatal(err)
+		}
+		wantSubject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Subject)
+		// net/mail removes spaces at the two ends of a plain header value,
+		// but not in an encoded word.
+		if strings.Trim(subject, " \t") != strings.Trim(wantSubject, " \t") {
+			t.Fatalf("subject: got %q, want %q", subject, wantSubject)
+		}
+		body, err := io.ReadAll(quotedprintable.NewReader(msg.Body))
+		if err != nil {
+			t.Fatal(err)
+		}
+		lf := strings.NewReplacer("\r\n", "\n", "\r", "\n")
+		if got := lf.Replace(string(body)); got != lf.Replace(m.Body) {
+			t.Fatalf("body: got %q, want %q", got, m.Body)
+		}
+		if got := msg.Header.Get("List-Unsubscribe"); m.Unsubscribe != "" && got != "<"+m.Unsubscribe+">" {
+			t.Fatalf("List-Unsubscribe = %q", got)
+		}
+	})
+}
+
+func TestFormatMessageRejectsBadAddress(t *testing.T) {
+	if _, err := formatMessage(testFrom, message{To: "not an address"}, time.Now()); err == nil {
+		t.Fatal("no error for a bad address")
+	}
+}
+
+func TestLoadConfigNamesMissingVariables(t *testing.T) {
+	env := map[string]string{"RW_BASE_URL": "https://example.com/", "SMTP_PORT": "2525"}
+	_, err := loadConfig(func(k string) string { return env[k] }, false)
+	if err == nil || err.Error() != "set these environment variables: RW_SITE_DIR, RW_DB_PATH, RW_MAIL_FROM, SMTP_HOST" {
+		t.Fatalf("err = %v", err)
+	}
+
+	env["RW_SITE_DIR"], env["RW_DB_PATH"] = "/srv/site", "/var/lib/rw/rw.db"
+	env["RW_MAIL_FROM"], env["SMTP_HOST"] = "Recently Written <posts@example.com>", "smtp.example.com"
+	c, err := loadConfig(func(k string) string { return env[k] }, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if c.BaseURL != "https://example.com" || c.SMTPAddr != "smtp.example.com:2525" ||
+		c.MailFrom.Address != "posts@example.com" || c.Listen != "127.0.0.1:8081" {
+		t.Fatalf("config = %+v", c)
+	}
+
+	delete(env, "SMTP_HOST")
+	if _, err := loadConfig(func(k string) string { return env[k] }, true); err != nil {
+		t.Fatalf("dev mode needs no SMTP: %v", err)
+	}
+}
diff --git a/accounts/main.go b/accounts/main.go
new file mode 100644
index 0000000..dadedd8
--- /dev/null
+++ b/accounts/main.go
@@ -0,0 +1,132 @@
+// Command accounts is the sign-in, email, and private feed service for
+// recentlywritten.com. It serves the paths under /account/ behind the web
+// server that serves the static site, and it reads the post data that
+// build.sh writes to postdata/ in the site directory.
+//
+// All settings and secrets come from environment variables. See
+// .env.example.
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"log"
+	"net"
+	"net/http"
+	"net/mail"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+)
+
+type config struct {
+	BaseURL      string // for example https://recentlywritten.com, with no final slash
+	SiteDir      string // the web root that build.sh deploys
+	DBPath       string
+	Listen       string
+	MailFrom     *mail.Address
+	SMTPAddr     string
+	SMTPUsername string
+	SMTPPassword string
+	Interval     time.Duration
+}
+
+// loadConfig reads the configuration from getenv. In dev mode the mail
+// settings are not necessary. The error names each missing variable.
+func loadConfig(getenv func(string) string, dev bool) (config, error) {
+	c := config{
+		BaseURL:      strings.TrimRight(getenv("RW_BASE_URL"), "/"),
+		SiteDir:      getenv("RW_SITE_DIR"),
+		DBPath:       getenv("RW_DB_PATH"),
+		Listen:       getenv("RW_LISTEN"),
+		SMTPUsername: getenv("SMTP_USERNAME"),
+		SMTPPassword: getenv("SMTP_PASSWORD"),
+		Interval:     15 * time.Minute,
+	}
+	if c.Listen == "" {
+		c.Listen = "127.0.0.1:8081"
+	}
+	required := []string{"RW_BASE_URL", "RW_SITE_DIR", "RW_DB_PATH"}
+	if !dev {
+		required = append(required, "RW_MAIL_FROM", "SMTP_HOST")
+	}
+	var missing []string
+	for _, k := range required {
+		if getenv(k) == "" {
+			missing = append(missing, k)
+		}
+	}
+	if len(missing) > 0 {
+		return c, fmt.Errorf("set these environment variables: %s", strings.Join(missing, ", "))
+	}
+	if host := getenv("SMTP_HOST"); host != "" {
+		port := getenv("SMTP_PORT")
+		if port == "" {
+			port = "587"
+		}
+		c.SMTPAddr = net.JoinHostPort(host, port)
+	}
+	if from := getenv("RW_MAIL_FROM"); from != "" {
+		addr, err := mail.ParseAddress(from)
+		if err != nil {
+			return c, fmt.Errorf("RW_MAIL_FROM: %w", err)
+		}
+		c.MailFrom = addr
+	}
+	if s := getenv("RW_NOTIFY_INTERVAL"); s != "" {
+		d, err := time.ParseDuration(s)
+		if err != nil || d <= 0 {
+			return c, fmt.Errorf("RW_NOTIFY_INTERVAL: not a positive duration such as 15m")
+		}
+		c.Interval = d
+	}
+	return c, nil
+}
+
+func main() {
+	dev := flag.Bool("dev", false, "write email to standard error, not to SMTP (development only)")
+	flag.Parse()
+	logger := log.New(os.Stderr, "", log.LstdFlags)
+
+	cfg, err := loadConfig(os.Getenv, *dev)
+	if err != nil {
+		logger.Fatal(err)
+	}
+	db, err := openDB(cfg.DBPath)
+	if err != nil {
+		logger.Fatal(err)
+	}
+	defer db.Close()
+
+	var m mailer = logMailer{W: os.Stderr}
+	if !*dev {
+		m = smtpMailer{Addr: cfg.SMTPAddr, Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, From: cfg.MailFrom}
+	}
+	a := &app{cfg: cfg, db: db, mail: m, now: time.Now, log: logger}
+
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	go a.notifyLoop(cfg.Interval, ctx.Done())
+
+	srv := &http.Server{
+		Addr:              cfg.Listen,
+		Handler:           a.routes(),
+		ReadHeaderTimeout: 10 * time.Second,
+		ReadTimeout:       30 * time.Second,
+		WriteTimeout:      60 * time.Second,
+	}
+	go func() {
+		<-ctx.Done()
+		shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		srv.Shutdown(shutdown)
+	}()
+	logger.Printf("listening on %s", cfg.Listen)
+	if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
+		logger.Fatal(err)
+	}
+}
diff --git a/accounts/notify.go b/accounts/notify.go
new file mode 100644
index 0000000..bfccda3
--- /dev/null
+++ b/accounts/notify.go
@@ -0,0 +1,165 @@
+package main
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// notify sends one email to each subscriber who has new posts.
+//
+// A post is new for a user if the service first saw it in the index at or
+// after the time the user signed up. On the first run the posts table is
+// empty, and notify records every post with first_seen 0, so that the
+// posts that were on the site before the service started go to nobody.
+//
+// notify writes a row in deliveries for each new (user, post) before it
+// sends the email, and sends only the posts whose rows it wrote. It also
+// writes the row when the user does not want the post (email off, or all
+// tags of the post muted), so a later change of settings does not send
+// old posts. A second run, or a second process at the same time, never
+// sends a post two times to one user. If the send fails, notify removes the rows, and the next
+// run tries again. If the process stops between the write and the send,
+// the user does not get that email. This design accepts a lost email to
+// prevent a duplicate email.
+func (a *app) notify() error {
+	posts, err := loadPosts(a.cfg.SiteDir)
+	if err != nil {
+		return err
+	}
+	firstSeen, err := recordPosts(a.db, posts, a.now().Unix())
+	if err != nil {
+		return err
+	}
+
+	rows, err := a.db.Query(`SELECT id, email, created_at, email_on, unsub_token FROM users`)
+	if err != nil {
+		return err
+	}
+	var users []user
+	for rows.Next() {
+		var u user
+		if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.EmailOn, &u.UnsubToken); err != nil {
+			rows.Close()
+			return err
+		}
+		users = append(users, u)
+	}
+	rows.Close()
+	if err := rows.Err(); err != nil {
+		return err
+	}
+
+	var errs []error
+	for _, u := range users {
+		if err := a.notifyUser(u, posts, firstSeen); err != nil {
+			errs = append(errs, fmt.Errorf("user %d: %w", u.ID, err))
+		}
+	}
+	return errors.Join(errs...)
+}
+
+// recordPosts adds the posts that the posts table does not have, and
+// returns first_seen for each post in posts.
+func recordPosts(db *sql.DB, posts []Post, now int64) (map[string]int64, error) {
+	tx, err := db.Begin()
+	if err != nil {
+		return nil, err
+	}
+	defer tx.Rollback()
+	var known int
+	if err := tx.QueryRow(`SELECT count(*) FROM posts`).Scan(&known); err != nil {
+		return nil, err
+	}
+	seen := now
+	if known == 0 {
+		seen = 0
+	}
+	firstSeen := map[string]int64{}
+	for _, p := range posts {
+		if _, err := tx.Exec(`INSERT OR IGNORE INTO posts (slug, first_seen) VALUES (?, ?)`, p.Slug, seen); err != nil {
+			return nil, err
+		}
+		var t int64
+		if err := tx.QueryRow(`SELECT first_seen FROM posts WHERE slug = ?`, p.Slug).Scan(&t); err != nil {
+			return nil, err
+		}
+		firstSeen[p.Slug] = t
+	}
+	return firstSeen, tx.Commit()
+}
+
+func (a *app) notifyUser(u user, posts []Post, firstSeen map[string]int64) error {
+	muted, err := mutedTags(a.db, u.ID)
+	if err != nil {
+		return err
+	}
+	var claimed, send []Post
+	for _, p := range posts {
+		if firstSeen[p.Slug] < u.CreatedAt {
+			continue
+		}
+		res, err := a.db.Exec(`INSERT OR IGNORE INTO deliveries (user_id, slug) VALUES (?, ?)`, u.ID, p.Slug)
+		if err != nil {
+			return err
+		}
+		if n, _ := res.RowsAffected(); n == 1 {
+			claimed = append(claimed, p)
+			if u.EmailOn && wanted(p, muted) {
+				send = append(send, p)
+			}
+		}
+	}
+	if len(send) == 0 {
+		return nil
+	}
+	if err := a.mail.Send(a.digest(u, send)); err != nil {
+		for _, p := range claimed {
+			// The next run sends the post again only if this row is gone.
+			if _, derr := a.db.Exec(`DELETE FROM deliveries WHERE user_id = ? AND slug = ?`, u.ID, p.Slug); derr != nil {
+				err = errors.Join(err, derr)
+			}
+		}
+		return err
+	}
+	return nil
+}
+
+func (a *app) unsubscribeURL(u user) string {
+	return a.cfg.BaseURL + "/account/unsubscribe?t=" + url.QueryEscape(u.UnsubToken)
+}
+
+func (a *app) digest(u user, posts []Post) message {
+	subject := fmt.Sprintf("%d new posts on Recently Written", len(posts))
+	if len(posts) == 1 {
+		subject = "New post: " + posts[0].Title
+	}
+	var b strings.Builder
+	b.WriteString("New on Recently Written:\n\n")
+	for _, p := range posts {
+		fmt.Fprintf(&b, "%s\n%s/%s.html\n\n", p.Title, a.cfg.BaseURL, p.Slug)
+	}
+	fmt.Fprintf(&b, "-- \nChange your settings: %s/account/\nStop these emails: %s\n",
+		a.cfg.BaseURL, a.unsubscribeURL(u))
+	return message{To: u.Email, Subject: subject, Body: b.String(), Unsubscribe: a.unsubscribeURL(u)}
+}
+
+// notifyLoop runs notify now and then one time in each interval, until
+// stop closes.
+func (a *app) notifyLoop(interval time.Duration, stop <-chan struct{}) {
+	t := time.NewTicker(interval)
+	defer t.Stop()
+	for {
+		if err := a.notify(); err != nil {
+			a.log.Printf("notify: %v", err)
+		}
+		select {
+		case <-stop:
+			return
+		case <-t.C:
+		}
+	}
+}
diff --git a/accounts/notify_test.go b/accounts/notify_test.go
new file mode 100644
index 0000000..09f440c
--- /dev/null
+++ b/accounts/notify_test.go
@@ -0,0 +1,225 @@
+package main
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"pgregory.net/rapid"
+)
+
+func TestNotifyFirstRunSendsNoOldPosts(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	e.signUp(t, "a@example.com")
+	e.advance(time.Minute)
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	if len(e.mail.sent) != 0 {
+		t.Fatalf("sent %d messages for posts that were on the site before the first run", len(e.mail.sent))
+	}
+}
+
+func TestNotifySendsNewPostOneTime(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	if err := e.app.notify(); err != nil { // first run records the old posts
+		t.Fatal(err)
+	}
+	u := e.signUp(t, "a@example.com")
+	e.advance(time.Hour)
+	newPost := Post{Date: "2026-09-02", Slug: "new-post", Tags: []string{"articles"}, Title: "New post"}
+	e.writeIndex(t, append([]Post{newPost}, oldPosts...))
+
+	for range 3 {
+		if err := e.app.notify(); err != nil {
+			t.Fatal(err)
+		}
+		e.advance(time.Minute)
+	}
+	if len(e.mail.sent) != 1 {
+		t.Fatalf("sent %d messages, want 1", len(e.mail.sent))
+	}
+	m := e.mail.sent[0]
+	if m.To != "a@example.com" || m.Subject != "New post: New post" {
+		t.Errorf("message = %+v", m)
+	}
+	if !strings.Contains(m.Body, "https://example.com/new-post.html") {
+		t.Errorf("body has no link to the post:\n%s", m.Body)
+	}
+	if strings.Contains(m.Body, "old-article") {
+		t.Errorf("body has an old post:\n%s", m.Body)
+	}
+	wantUnsub := "https://example.com/account/unsubscribe?t=" + u.UnsubToken
+	if m.Unsubscribe != wantUnsub || !strings.Contains(m.Body, wantUnsub) {
+		t.Errorf("unsubscribe link missing: header %q, body:\n%s", m.Unsubscribe, m.Body)
+	}
+}
+
+func TestNotifyRetriesAfterSendFailure(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	e.signUp(t, "a@example.com")
+	e.advance(time.Hour)
+	e.writeIndex(t, append([]Post{{Date: "2026-09-02", Slug: "new-post", Title: "New post"}}, oldPosts...))
+
+	e.mail.fail = true
+	if err := e.app.notify(); err == nil {
+		t.Fatal("notify returned no error when the send failed")
+	}
+	e.mail.fail = false
+	for range 2 {
+		if err := e.app.notify(); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if len(e.mail.sent) != 1 {
+		t.Fatalf("sent %d messages, want 1", len(e.mail.sent))
+	}
+}
+
+func TestNotifySkipsPostsFromBeforeSignUp(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	e.advance(time.Hour)
+	e.writeIndex(t, append([]Post{{Date: "2026-09-02", Slug: "earlier", Title: "Earlier"}}, oldPosts...))
+	if err := e.app.notify(); err != nil { // no users yet
+		t.Fatal(err)
+	}
+	e.advance(time.Hour)
+	e.signUp(t, "late@example.com")
+	e.advance(time.Hour)
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	if len(e.mail.sent) != 0 {
+		t.Fatalf("a new user got a post from before the sign-up: %+v", e.mail.sent)
+	}
+}
+
+func TestNotifyObeysSettings(t *testing.T) {
+	e := newTestEnv(t, oldPosts)
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	u := e.signUp(t, "a@example.com")
+	if err := saveSettings(e.app.db, u.ID, true, []string{"projects"}); err != nil {
+		t.Fatal(err)
+	}
+	e.advance(time.Hour)
+	e.writeIndex(t, append([]Post{
+		{Date: "2026-09-02", Slug: "proj", Tags: []string{"projects"}, Title: "Proj"},
+		{Date: "2026-09-02", Slug: "art", Tags: []string{"articles"}, Title: "Art"},
+	}, oldPosts...))
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	if len(e.mail.sent) != 1 || strings.Contains(e.mail.sent[0].Body, "proj.html") {
+		t.Fatalf("muted tag was sent: %+v", e.mail.sent)
+	}
+
+	// Unmute the tag. The muted post must not arrive later.
+	if err := saveSettings(e.app.db, u.ID, true, nil); err != nil {
+		t.Fatal(err)
+	}
+	e.advance(time.Hour)
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	if len(e.mail.sent) != 1 {
+		t.Fatalf("unmute sent an old post: %+v", e.mail.sent[1:])
+	}
+
+	// With email off, nothing goes out.
+	if err := saveSettings(e.app.db, u.ID, false, nil); err != nil {
+		t.Fatal(err)
+	}
+	e.advance(time.Hour)
+	e.writeIndex(t, append([]Post{{Date: "2026-09-03", Slug: "third", Title: "Third"}}, oldPosts...))
+	if err := e.app.notify(); err != nil {
+		t.Fatal(err)
+	}
+	if len(e.mail.sent) != 1 {
+		t.Fatalf("email off, but sent: %+v", e.mail.sent[1:])
+	}
+}
+
+// TestNotifyNoDuplicatesProperty runs random sequences of new posts, new
+// users, send failures, and notify runs. At the end no user has a post two
+// times, and each user has each post that notify first saw at or after
+// the sign-up.
+func TestNotifyNoDuplicatesProperty(t *testing.T) {
+	rapid.Check(t, func(rt *rapid.T) {
+		e := newTestEnv(t, oldPosts)
+		if err := e.app.notify(); err != nil { // the first run records the old posts
+			rt.Fatal(err)
+		}
+		index := append([]Post(nil), oldPosts...)
+		seenAt := map[string]time.Time{}   // slug -> time of the first notify run that saw it
+		joinedAt := map[string]time.Time{} // email -> sign-up time
+		run := func() {
+			for _, p := range index {
+				if _, ok := seenAt[p.Slug]; !ok {
+					seenAt[p.Slug] = e.clock.now()
+				}
+			}
+			_ = e.app.notify()
+		}
+		for _, p := range oldPosts {
+			seenAt[p.Slug] = time.Time{}
+		}
+
+		steps := rapid.SliceOfN(rapid.IntRange(0, 3), 1, 30).Draw(rt, "steps")
+		for i, step := range steps {
+			e.advance(time.Minute)
+			switch step {
+			case 0:
+				p := Post{Date: "2026-09-02", Slug: fmt.Sprintf("p%d", i), Title: fmt.Sprintf("P%d", i)}
+				index = append([]Post{p}, index...)
+				e.writeIndex(rt, index)
+			case 1:
+				email := fmt.Sprintf("u%d@example.com", i)
+				e.signUp(rt, email)
+				joinedAt[email] = e.clock.now()
+			case 2:
+				e.mail.fail = !e.mail.fail
+			case 3:
+				run()
+			}
+		}
+		e.advance(time.Minute)
+		e.mail.fail = false
+		run()
+
+		got := map[string]map[string]int{} // email -> slug -> count
+		for _, m := range e.mail.sent {
+			if got[m.To] == nil {
+				got[m.To] = map[string]int{}
+			}
+			for _, line := range strings.Split(m.Body, "\n") {
+				if s, ok := strings.CutPrefix(line, "https://example.com/"); ok {
+					got[m.To][strings.TrimSuffix(s, ".html")]++
+				}
+			}
+		}
+		for email, joined := range joinedAt {
+			want := 0
+			for _, p := range index {
+				if seenAt[p.Slug].Before(joined) {
+					continue
+				}
+				want++
+				if n := got[email][p.Slug]; n != 1 {
+					rt.Fatalf("%s got %s %d times, want 1", email, p.Slug, n)
+				}
+			}
+			if len(got[email]) != want {
+				rt.Fatalf("%s got %v, want %d posts", email, got[email], want)
+			}
+		}
+	})
+}
diff --git a/accounts/posts.go b/accounts/posts.go
new file mode 100644
index 0000000..dc0ed5a
--- /dev/null
+++ b/accounts/posts.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+)
+
+// Post is one line of the post index that build.sh writes.
+type Post struct {
+	Date  string // YYYY-MM-DD, or empty
+	Slug  string
+	Tags  []string
+	Title string
+}
+
+// validSlug accepts only the file names that build.sh makes. The check
+// keeps a bad index line from reading a file outside postdata/.
+var validSlug = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*$`)
+
+// parseIndex reads postdata/index.tsv. Each line has four fields that
+// tabs separate: date, slug, tags, title. The lines are newest first.
+func parseIndex(r io.Reader) ([]Post, error) {
+	var posts []Post
+	sc := bufio.NewScanner(r)
+	for n := 1; sc.Scan(); n++ {
+		line := strings.TrimRight(sc.Text(), "\r")
+		if line == "" {
+			continue
+		}
+		f := strings.SplitN(line, "\t", 4)
+		if len(f) != 4 || !validSlug.MatchString(f[1]) {
+			return nil, fmt.Errorf("index line %d is not valid", n)
+		}
+		posts = append(posts, Post{
+			Date:  f[0],
+			Slug:  f[1],
+			Tags:  splitTags(f[2]),
+			Title: f[3],
+		})
+	}
+	return posts, sc.Err()
+}
+
+// splitTags reads a front matter tags value such as "articles" or
+// "lisp, unix".
+func splitTags(s string) []string {
+	return strings.FieldsFunc(s, func(r rune) bool {
+		return r == ',' || r == ' ' || r == '\t'
+	})
+}
+
+func loadPosts(siteDir string) ([]Post, error) {
+	f, err := os.Open(filepath.Join(siteDir, "postdata", "index.tsv"))
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return parseIndex(f)
+}
+
+// loadBody returns the HTML body of a post. A missing body gives an
+// empty string, because a feed item without a body is still useful.
+func loadBody(siteDir, slug string) string {
+	b, err := os.ReadFile(filepath.Join(siteDir, "postdata", slug+".html"))
+	if err != nil {
+		return ""
+	}
+	return string(b)
+}
+
+// allTags returns each tag that a post uses, in the order of first use.
+func allTags(posts []Post) []string {
+	seen := map[string]bool{}
+	var tags []string
+	for _, p := range posts {
+		for _, t := range p.Tags {
+			if !seen[t] {
+				seen[t] = true
+				tags = append(tags, t)
+			}
+		}
+	}
+	return tags
+}
+
+// wanted tells if a user who muted the tags in muted gets post p. A post
+// without tags always goes out. A post with tags goes out if one or more
+// of its tags is not muted.
+func wanted(p Post, muted map[string]bool) bool {
+	if len(p.Tags) == 0 {
+		return true
+	}
+	for _, t := range p.Tags {
+		if !muted[t] {
+			return true
+		}
+	}
+	return false
+}
diff --git a/accounts/store.go b/accounts/store.go
new file mode 100644
index 0000000..423ce05
--- /dev/null
+++ b/accounts/store.go
@@ -0,0 +1,239 @@
+package main
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"database/sql"
+	"encoding/hex"
+	"errors"
+	"time"
+
+	_ "modernc.org/sqlite"
+)
+
+// The users table holds only addresses that a sign-in link verified.
+// login_tokens and sessions hold SHA-256 hashes of their tokens, so a copy
+// of the database does not let a person sign in. feed_token and
+// unsub_token are stored as plain text: the site must show them again,
+// and they give access only to public posts and to the unsubscribe step.
+const schema = `
+CREATE TABLE IF NOT EXISTS users (
+	id          INTEGER PRIMARY KEY,
+	email       TEXT NOT NULL UNIQUE,
+	created_at  INTEGER NOT NULL,
+	email_on    INTEGER NOT NULL DEFAULT 1,
+	feed_token  TEXT NOT NULL UNIQUE,
+	unsub_token TEXT NOT NULL UNIQUE
+);
+CREATE TABLE IF NOT EXISTS muted_tags (
+	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+	tag     TEXT NOT NULL,
+	PRIMARY KEY (user_id, tag)
+);
+CREATE TABLE IF NOT EXISTS login_tokens (
+	hash       TEXT PRIMARY KEY,
+	email      TEXT NOT NULL,
+	created_at INTEGER NOT NULL,
+	expires_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS sessions (
+	hash       TEXT PRIMARY KEY,
+	user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+	expires_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS posts (
+	slug       TEXT PRIMARY KEY,
+	first_seen INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS deliveries (
+	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+	slug    TEXT NOT NULL,
+	PRIMARY KEY (user_id, slug)
+);
+`
+
+const (
+	loginTTL     = 15 * time.Minute
+	loginSpacing = 2 * time.Minute // minimum time between two links to one address
+	sessionTTL   = 30 * 24 * time.Hour
+)
+
+var errNotFound = errors.New("not found")
+
+func openDB(path string) (*sql.DB, error) {
+	db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
+	if err != nil {
+		return nil, err
+	}
+	// One connection serializes all writes. The site is small, and this
+	// removes the need to handle SQLITE_BUSY. It also keeps a :memory:
+	// database the same for all queries in tests.
+	db.SetMaxOpenConns(1)
+	if _, err := db.Exec(schema); err != nil {
+		db.Close()
+		return nil, err
+	}
+	return db, nil
+}
+
+// newToken returns a random token with 128 bits of entropy.
+func newToken() string { return rand.Text() }
+
+func hashToken(t string) string {
+	s := sha256.Sum256([]byte(t))
+	return hex.EncodeToString(s[:])
+}
+
+type user struct {
+	ID         int64
+	Email      string
+	CreatedAt  int64
+	EmailOn    bool
+	FeedToken  string
+	UnsubToken string
+}
+
+const userColumns = `id, email, created_at, email_on, feed_token, unsub_token`
+
+func scanUser(row *sql.Row) (user, error) {
+	var u user
+	err := row.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.EmailOn, &u.FeedToken, &u.UnsubToken)
+	if errors.Is(err, sql.ErrNoRows) {
+		err = errNotFound
+	}
+	return u, err
+}
+
+// createLoginToken makes a sign-in token for email. It returns an empty
+// token and no error if a token for email was made less than loginSpacing
+// ago. This stops a person who uses the form to send many emails to one
+// address.
+func createLoginToken(db *sql.DB, email string, now time.Time) (string, error) {
+	if _, err := db.Exec(`DELETE FROM login_tokens WHERE expires_at <= ?`, now.Unix()); err != nil {
+		return "", err
+	}
+	var recent int
+	err := db.QueryRow(`SELECT count(*) FROM login_tokens WHERE email = ? AND created_at > ?`,
+		email, now.Add(-loginSpacing).Unix()).Scan(&recent)
+	if err != nil || recent > 0 {
+		return "", err
+	}
+	t := newToken()
+	_, err = db.Exec(`INSERT INTO login_tokens (hash, email, created_at, expires_at) VALUES (?, ?, ?, ?)`,
+		hashToken(t), email, now.Unix(), now.Add(loginTTL).Unix())
+	if err != nil {
+		return "", err
+	}
+	return t, nil
+}
+
+// consumeLoginToken removes the token and returns the user for its
+// address. It makes the user if the address has no account. A token works
+// one time only.
+func consumeLoginToken(db *sql.DB, token string, now time.Time) (user, error) {
+	var email string
+	err := db.QueryRow(`DELETE FROM login_tokens WHERE hash = ? AND expires_at > ? RETURNING email`,
+		hashToken(token), now.Unix()).Scan(&email)
+	if errors.Is(err, sql.ErrNoRows) {
+		return user{}, errNotFound
+	}
+	if err != nil {
+		return user{}, err
+	}
+	_, err = db.Exec(`INSERT INTO users (email, created_at, feed_token, unsub_token) VALUES (?, ?, ?, ?)
+		ON CONFLICT (email) DO NOTHING`, email, now.Unix(), newToken(), newToken())
+	if err != nil {
+		return user{}, err
+	}
+	return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE email = ?`, email))
+}
+
+func createSession(db *sql.DB, userID int64, now time.Time) (string, error) {
+	if _, err := db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now.Unix()); err != nil {
+		return "", err
+	}
+	t := newToken()
+	_, err := db.Exec(`INSERT INTO sessions (hash, user_id, expires_at) VALUES (?, ?, ?)`,
+		hashToken(t), userID, now.Add(sessionTTL).Unix())
+	return t, err
+}
+
+func sessionUser(db *sql.DB, token string, now time.Time) (user, error) {
+	return scanUser(db.QueryRow(`SELECT u.id, u.email, u.created_at, u.email_on, u.feed_token, u.unsub_token
+		FROM sessions s JOIN users u ON u.id = s.user_id
+		WHERE s.hash = ? AND s.expires_at > ?`, hashToken(token), now.Unix()))
+}
+
+func deleteSession(db *sql.DB, token string) error {
+	_, err := db.Exec(`DELETE FROM sessions WHERE hash = ?`, hashToken(token))
+	return err
+}
+
+func userByFeedToken(db *sql.DB, token string) (user, error) {
+	return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE feed_token = ?`, token))
+}
+
+func mutedTags(db *sql.DB, userID int64) (map[string]bool, error) {
+	rows, err := db.Query(`SELECT tag FROM muted_tags WHERE user_id = ?`, userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	muted := map[string]bool{}
+	for rows.Next() {
+		var t string
+		if err := rows.Scan(&t); err != nil {
+			return nil, err
+		}
+		muted[t] = true
+	}
+	return muted, rows.Err()
+}
+
+// saveSettings sets the email switch and replaces the muted tags.
+func saveSettings(db *sql.DB, userID int64, emailOn bool, muted []string) error {
+	tx, err := db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	if _, err := tx.Exec(`UPDATE users SET email_on = ? WHERE id = ?`, emailOn, userID); err != nil {
+		return err
+	}
+	if _, err := tx.Exec(`DELETE FROM muted_tags WHERE user_id = ?`, userID); err != nil {
+		return err
+	}
+	for _, t := range muted {
+		if _, err := tx.Exec(`INSERT OR IGNORE INTO muted_tags (user_id, tag) VALUES (?, ?)`, userID, t); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
+}
+
+// rotateFeedToken gives the user a new feed token. The old feed URL then
+// stops working.
+func rotateFeedToken(db *sql.DB, userID int64) error {
+	_, err := db.Exec(`UPDATE users SET feed_token = ? WHERE id = ?`, newToken(), userID)
+	return err
+}
+
+// unsubscribe turns off email for the user with the unsubscribe token. It
+// returns errNotFound for an unknown token.
+func unsubscribe(db *sql.DB, token string) error {
+	res, err := db.Exec(`UPDATE users SET email_on = 0 WHERE unsub_token = ?`, token)
+	if err != nil {
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return errNotFound
+	}
+	return nil
+}
+
+// deleteUser removes the user. The foreign keys remove the sessions,
+// muted tags and delivery records of the user.
+func deleteUser(db *sql.DB, userID int64) error {
+	_, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID)
+	return err
+}
diff --git a/accounts/store_test.go b/accounts/store_test.go
new file mode 100644
index 0000000..3a0f9e8
--- /dev/null
+++ b/accounts/store_test.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+	"errors"
+	"regexp"
+	"testing"
+	"time"
+
+	"pgregory.net/rapid"
+)
+
+var tokenForm = regexp.MustCompile(`^[A-Z2-7]{26}$`)
+
+// TestTokensProperty checks that tokens are URL-safe, are all different,
+// and that different tokens have different hashes.
+func TestTokensProperty(t *testing.T) {
+	seen := map[string]bool{}
+	rapid.Check(t, func(t *rapid.T) {
+		tok := newToken()
+		if !tokenForm.MatchString(tok) {
+			t.Fatalf("token %q is not 26 base32 characters", tok)
+		}
+		if seen[tok] {
+			t.Fatalf("token %q came two times", tok)
+		}
+		seen[tok] = true
+		other := rapid.String().Draw(t, "other")
+		if other != tok && hashToken(other) == hashToken(tok) {
+			t.Fatal("two tokens have one hash")
+		}
+	})
+}
+
+// TestLoginTokenProperty checks that a sign-in token works one time for
+// its address, and that no other string works.
+func TestLoginTokenProperty(t *testing.T) {
+	rapid.Check(t, func(rt *rapid.T) {
+		e := newTestEnv(t, nil)
+		email := rapid.StringMatching(`[a-z0-9]{1,10}@[a-z]{1,10}\.[a-z]{2,4}`).Draw(rt, "email")
+		tok, err := createLoginToken(e.app.db, email, e.clock.now())
+		if err != nil || tok == "" {
+			rt.Fatalf("createLoginToken: %q, %v", tok, err)
+		}
+		guess := rapid.String().Filter(func(s string) bool { return s != tok }).Draw(rt, "guess")
+		if _, err := consumeLoginToken(e.app.db, guess, e.clock.now()); !errors.Is(err, errNotFound) {
+			rt.Fatalf("a wrong token gave %v", err)
+		}
+		e.advance(time.Duration(rapid.IntRange(0, int(loginTTL/time.Second)-1).Draw(rt, "wait")) * time.Second)
+		u, err := consumeLoginToken(e.app.db, tok, e.clock.now())
+		if err != nil || u.Email != email {
+			rt.Fatalf("consume: %+v, %v", u, err)
+		}
+		if _, err := consumeLoginToken(e.app.db, tok, e.clock.now()); !errors.Is(err, errNotFound) {
+			rt.Fatalf("second use gave %v", err)
+		}
+	})
+}
+
+func TestLoginTokenExpires(t *testing.T) {
+	e := newTestEnv(t, nil)
+	tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now())
+	e.advance(loginTTL)
+	if _, err := consumeLoginToken(e.app.db, tok, e.clock.now()); !errors.Is(err, errNotFound) {
+		t.Fatalf("expired token gave %v", err)
+	}
+}
+
+func TestLoginTokenSpacing(t *testing.T) {
+	e := newTestEnv(t, nil)
+	if tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()); tok == "" {
+		t.Fatal("first token is empty")
+	}
+	e.advance(loginSpacing - time.Second)
+	if tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()); tok != "" {
+		t.Fatal("second token came too soon")
+	}
+	if tok, _ := createLoginToken(e.app.db, "b@example.com", e.clock.now()); tok == "" {
+		t.Fatal("other address got no token")
+	}
+	e.advance(time.Second)
+	if tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()); tok == "" {
+		t.Fatal("no token after the spacing")
+	}
+}
+
+func TestSignInTwiceKeepsOneUser(t *testing.T) {
+	e := newTestEnv(t, nil)
+	a := e.signUp(t, "a@example.com")
+	e.advance(loginSpacing)
+	b := e.signUp(t, "a@example.com")
+	if a.ID != b.ID || a.FeedToken != b.FeedToken || a.CreatedAt != b.CreatedAt {
+		t.Fatalf("second sign-in changed the user: %+v, %+v", a, b)
+	}
+}
+
+func TestSessionExpires(t *testing.T) {
+	e := newTestEnv(t, nil)
+	u := e.signUp(t, "a@example.com")
+	s, err := createSession(e.app.db, u.ID, e.clock.now())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got, err := sessionUser(e.app.db, s, e.clock.now()); err != nil || got.ID != u.ID {
+		t.Fatalf("sessionUser: %+v, %v", got, err)
+	}
+	e.advance(sessionTTL)
+	if _, err := sessionUser(e.app.db, s, e.clock.now()); !errors.Is(err, errNotFound) {
+		t.Fatalf("expired session gave %v", err)
+	}
+}
diff --git a/accounts/templates.html b/accounts/templates.html
new file mode 100644
index 0000000..1a47790
--- /dev/null
+++ b/accounts/templates.html
@@ -0,0 +1,110 @@
+{{define "head"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <meta name="robots" content="noindex" />
+  <title>{{.}} — Recently Written</title>
+  <link rel="stylesheet" href="/style.css" />
+</head>
+<body>
+<div id="container">
+
+  <div id="top">
+    <a class="site-title" href="/index.html">Recently Written</a>
+    <nav>
+      <a href="/index.html">Home</a>
+      <a href="/rss.xml">rss</a>
+      <a href="/account/">subscribe</a>
+    </nav>
+  </div>
+
+  <h1 class="post-title">{{.}}</h1>
+{{end}}
+
+{{define "foot"}}
+  <div id="footer">
+    <a href="/index.html">← Home</a>
+  </div>
+
+</div>
+</body>
+</html>
+{{end}}
+
+{{define "signin"}}{{template "head" "Subscribe"}}
+  <p>Get an email when a new post goes up, and a private feed for your
+  feed reader app. Type your address. We send you a link to sign in. You
+  need no password.</p>
+  {{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}}
+  <form method="post" action="/account/signin">
+    <input type="email" name="email" required maxlength="254" autocomplete="email" />
+    <button type="submit">Send the link</button>
+  </form>
+  <p>The public feed needs no account: <a href="/rss.xml">rss.xml</a>.</p>
+{{template "foot"}}{{end}}
+
+{{define "sent"}}{{template "head" "Check your email"}}
+  <p>If the address is valid, a sign-in link is on its way. The link works
+  one time and expires in 15 minutes.</p>
+{{template "foot"}}{{end}}
+
+{{define "verify"}}{{template "head" "Sign in"}}
+  <form method="post" action="/account/verify">
+    <input type="hidden" name="t" value="{{.}}" />
+    <button type="submit">Sign in</button>
+  </form>
+{{template "foot"}}{{end}}
+
+{{define "badlink"}}{{template "head" "Link not valid"}}
+  <p>The link is not valid. It was used before, or it expired.
+  <a href="/account/">Ask for a new link</a>.</p>
+{{template "foot"}}{{end}}
+
+{{define "settings"}}{{template "head" "Your subscription"}}
+  <p>Signed in as {{.Email}}.</p>
+
+  <h2 class="section-heading">Email</h2>
+  <form method="post" action="/account/settings">
+    <p><label><input type="checkbox" name="email_on" value="1"{{if .EmailOn}} checked{{end}} />
+    Email me when a new post goes up</label></p>
+    {{if .Tags}}<p>Send posts with these tags (the private feed uses them too):</p>
+    {{range .Tags}}<p><label><input type="checkbox" name="tag" value="{{.Name}}"{{if .On}} checked{{end}} />
+    {{.Name}}</label></p>
+    {{end}}{{end}}
+    <button type="submit">Save</button>
+  </form>
+
+  <h2 class="section-heading">Private feed</h2>
+  <p>Add this URL to a feed reader app on Android, iOS, or a computer
+  (for example Feedly, Inoreader, NetNewsWire, or Feeder). Keep it private.</p>
+  <p><input type="text" readonly value="{{.FeedURL}}" size="60" onclick="this.select()" /></p>
+  <form method="post" action="/account/feed/rotate">
+    <button type="submit">Make a new URL</button> The old URL then stops working.
+  </form>
+
+  <h2 class="section-heading">Account</h2>
+  <form method="post" action="/account/signout">
+    <button type="submit">Sign out</button>
+  </form>
+  <form method="post" action="/account/delete" onsubmit="return confirm('Delete your account?')">
+    <p><button type="submit">Delete my account</button>
+    This removes your address and settings. You cannot undo it.</p>
+  </form>
+{{template "foot"}}{{end}}
+
+{{define "unsubscribe"}}{{template "head" "Unsubscribe"}}
+  <form method="post" action="/account/unsubscribe">
+    <input type="hidden" name="t" value="{{.}}" />
+    <button type="submit">Stop the emails</button>
+  </form>
+{{template "foot"}}{{end}}
+
+{{define "unsubscribed"}}{{template "head" "Unsubscribed"}}
+  <p>You get no more emails. To start them again, or to delete your
+  account, <a href="/account/">sign in</a>.</p>
+{{template "foot"}}{{end}}
+
+{{define "deleted"}}{{template "head" "Account deleted"}}
+  <p>We removed your account.</p>
+{{template "foot"}}{{end}}
diff --git a/build.sh b/build.sh
index c9b7663..f005760 100755
--- a/build.sh
+++ b/build.sh
@@ -63,6 +63,7 @@ cat > "$TEMPLATE" << 'TMPL'
       <a href="esperanto.html">esperanto</a>
       <a href="call.html">contact</a>
       <a href="rss.xml">rss</a>
+      <a href="account/">subscribe</a>
     </nav>
   </div>
 
@@ -219,6 +220,19 @@ absolute() {
     printf '</channel>\n</rss>\n'
 } > "$SITE_DIR/rss.xml"
 
+# ── post data for the accounts service ──────────────────────
+# accounts/ reads these files to send email about new posts and to make
+# the private feed of each user. index.tsv has one line for each post,
+# newest first: date, slug, tags, title. postdata/<slug>.html has the body
+# of the post, with absolute links.
+mkdir -p "$SITE_DIR/postdata"
+while IFS="$TAB" read -r order date slug title; do
+    [ -z "$slug" ] && continue
+    printf '%s\t%s\t%s\t%s\n' "$date" "$slug" "$(meta "$POSTS_DIR/$slug.md" tags)" "$title"
+    pandoc --from markdown --to html5 "$POSTS_DIR/$slug.md" < /dev/null |
+        absolute > "$SITE_DIR/postdata/$slug.html"
+done < "$WORK/sorted.tsv" > "$SITE_DIR/postdata/index.tsv"
+
 echo "Built $(ls "$SITE_DIR"/*.html | wc -l | tr -d ' ') pages → $SITE_DIR/"
 echo "  posts: $(wc -l < "$WORK/posts.tsv" | tr -d ' ')   pages: $(wc -l < "$WORK/pages.tsv" | tr -d ' ')   feed: $(grep -c '<item>' "$SITE_DIR/rss.xml") items"
 
diff --git a/index.html b/index.html
index 6afa43b..95e04dc 100755
--- a/index.html
+++ b/index.html
@@ -29,6 +29,7 @@
       <a href="esperanto.html">esperanto</a>
       <a href="call.html">contact</a>
       <a href="rss.xml">rss</a>
+      <a href="account/">subscribe</a>
     </nav>
   </div>