# Newsletter

> The footer signup block — off, a third-party embed, or a self-hosted D1 list — the honeypot, duplicates, the subscriber count, and exporting the list.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/grow/newsletter/

---

The newsletter block sits in the footer of every content page, with a heading and a blurb from
`newsletter`. `features.newsletter` picks the mode: `'off'`, `'embed'` for a provider's own form,
or `'d1'` for a signup form that writes to your database.

> **Free edition**
>
> `'embed'` works in the free edition: the component ships and needs no server. `'d1'` does not —
> that edition has no `/api/newsletter/` route and no database, and its config is pinned to `'off'`.

## Setup

**embed**

1. Paste the provider's form.

   ```ts title="site.config.ts"
   features: { newsletter: 'embed' },
   newsletter: {
     heading: 'New tools, weekly',
     blurb: 'One email a week. No spam.',
     embedHtml: '<form action="https://provider.example/subscribe" method="post"><input type="email" name="email" /><button>Subscribe</button></form>',
   },
   ```

   `pnpm check:config` answers `site.config.ts OK — <name> (<url>)`.

2. Build, and read the zero-JS line.

   ```sh title="Terminal"
   pnpm build
   ```

   `zero-js: <n> content pages, 0 islands, 0 external scripts — ok`. An embed carrying
   `<script src="…">` turns that into `— fail` and stops the build (see below).

3. Deploy with `pnpm deploy` or a push.

**d1**

1. Switch the mode. The `subscribers` table is in the shipped migrations, so a site that has
   run `pnpm cf:setup` already has it.

   ```ts title="site.config.ts"
   features: { newsletter: 'd1' },
   newsletter: { heading: 'New tools, weekly', blurb: 'One email a week. No spam.' },
   ```

2. Deploy.

   ```sh title="Terminal"
   pnpm deploy
   ```

3. Sign up once from the live footer. The browser comes back to the same page with
   `#newsletter-thanks` in the URL and "Thanks — you are on the list." in view.

## Verify

| What to check | Where | You should see |
| --- | --- | --- |
| The block | the footer of any content page | heading, blurb, and the form (`d1`) or your embed |
| The row | `pnpm exec wrangler d1 execute DB --remote --command "SELECT count(*) AS n FROM subscribers WHERE unsubscribed_at IS NULL"` | `n` counting your test address |
| The count | `/admin/stats/` | **Newsletter subscribers** — `n/a` in any mode but `d1` |
| Zero JS | the build log | `zero-js: … 0 external scripts — ok` |

## How it works

**`embed`** renders `newsletter.embedHtml` verbatim under the heading and blurb, with nothing
sanitised: a provider's `<form>`, an `<iframe>` or an inline `<script>` all pass through. The one
constraint is the build check in `integrations/budget.ts`: every page but `/search/` must carry
no `<astro-island>` and no `<script src>`, and the check fails the build with
`content pages must ship zero JavaScript`, naming the pages. Inline scripts are counted and
allowed; only a downloaded script fails. A provider snippet that loads its own JavaScript
therefore cannot be embedded as given.

**`d1`** is a plain `<form method="post">` to `/api/newsletter/` — the trailing slash matters,
because `/api/newsletter` earns a 308 first — handled by `src/pages/api/newsletter.ts` with no
JavaScript on either side. The endpoint checks the honeypot before anything else: with
`features.honeypot` on the form carries a hidden `company` field, and a filled one gets the same
"thanks" a person gets while nothing is written. Then the address: trimmed, lower-cased,
validated, at most 254 characters. Then one statement, `INSERT … ON CONFLICT DO NOTHING`, so a
repeat signup writes nothing, spends nothing of the D1 write quota, and is indistinguishable
from a first one — the endpoint is not an oracle for who is on the list. The answer is a 303 to
the page the form was on (a same-origin `Referer`, else the home page) with a fragment:
`#newsletter-thanks`, `#newsletter-invalid`, or `#newsletter-unavailable` when D1 refused the
write. All three messages are in the static HTML and `:target` reveals one; the address itself
never enters the URL.

The table is `subscribers(email, source, created_at, unsubscribed_at)`, the email as primary
key. `/admin/stats/` counts the rows with `unsubscribed_at` empty.

**There is no unsubscribe flow.** No route and no link in any mail: the starter sends no
newsletter, only its transactional messages. The column exists for you. Set it by hand and the
address leaves the count; a later signup from that address stays unsubscribed, because
`DO NOTHING` never touches an existing row.

```sh title="Terminal"
pnpm exec wrangler d1 execute DB --remote --command "UPDATE subscribers SET unsubscribed_at = unixepoch() WHERE email = 'gone@example.com'"
```

**Exporting the list** for the tool that actually sends:

```sh title="Terminal"
pnpm exec wrangler d1 execute DB --remote --json --command "SELECT email, created_at FROM subscribers WHERE unsubscribed_at IS NULL ORDER BY created_at" > subscribers.json
```

`created_at` is unix seconds.

## When it is off or degraded

| State | Visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `'off'` (the default) | no block; `/api/newsletter/` answers 404 | stats: `n/a` | — |
| `'embed'` with no `embedHtml` | heading and blurb, no form | stats: `n/a` | set `embedHtml` |
| `'embed'` with a `<script src>` | the build fails | — | use the provider's plain form or a hosted page |
| Honeypot filled | "Thanks — you are on the list."; nothing stored | nothing | — |
| Bad address | "That does not look like an email address." | nothing | — |
| D1 refuses the write (the free plan's daily cap, say) | "Sign-up is temporarily unavailable." | `newsletter: signup failed` in the Worker log; the report has no newsletter check | wait, or Workers Paid |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `features.newsletter` | `'off'` | `'off'`, `'embed'` or `'d1'` |
| `features.honeypot` | `true` | the hidden field on the `d1` form |
| `newsletter.heading` | `'Stay in the loop'` | the block's heading |
| `newsletter.blurb` | `'New {nouns} and updates, once a week. No spam.'` | the line under it |
| `newsletter.embedHtml` | — | the `embed` markup, verbatim |

See [`features`](https://zerodirs.com/docs/configure/reference/#features) and [`newsletter`](https://zerodirs.com/docs/configure/reference/#newsletter).

## Files

- src/
  - components/site/
    - Newsletter.astro the block, the three fragments, the honeypot
    - Footer.astro where it mounts
  - pages/api/newsletter.ts `handleSignup()`, the 303
  - db/schema.ts `subscribers`
  - templates/admin/StatsPage.astro the subscriber count
  - config/defaults.ts `DEFAULT_NEWSLETTER`
- integrations/budget.ts the zero-JS check

## Related

- [Email](https://zerodirs.com/docs/submissions/email/) — The transactional mail the starter does send, and through which provider.
- [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/) — The D1 write quota a signup spends.
- [Admin](https://zerodirs.com/docs/submissions/admin/) — The stats page the count appears on.
- [Security](https://zerodirs.com/docs/run/security/) — The honeypot rule the signup form shares with the submission form.
