<!-- mailto.foo/sdk -->

# @mailto.foo/sdk integration

Headless email subscription/contact-form SDK. Two entry points: `src/index.ts` (the `Mailto` class, published as ESM/CJS) and `src/cdn.ts` (auto-enhancement script for the CDN bundle, built on top of `Mailto`).

## Core API

```js
import { Mailto } from '@mailto.foo/sdk'

const mailto = new Mailto({ publishKey: 'pk_your_publish_key' })
await mailto.subscribe({ email: 'user@example.com', /* ...any extra string fields */ })
```

`subscribe(data)` accepts `{ email: string; [key: string]: string }` — `email` is the only required field. Any additional string key/value pairs (name, company, message, plan, source, etc.) are passed straight through to the API and show up alongside the subscriber in the mailto.foo dashboard. There's no schema to register; just add the fields you need to the object (or to the `<form>` as extra `<input name="...">` elements when using the CDN auto-enhancement).

- `subscribe(data)` first calls `GET {API_BASE}/config/:publishKey/config` to fetch publisher config (5s timeout via `AbortController`).
- If the publisher has `turnstileSiteKey` set, it loads Cloudflare Turnstile, renders an invisible widget, and obtains a token before submitting — falling back to a visible modal overlay if the invisible challenge doesn't resolve to a token. This whole flow is internal; callers don't need to do anything extra.
- It then `POST`s `{ data, config: { publishKey, token } }` to `{API_BASE}/subscribe`.
- On a non-OK response it throws an `Error` whose message is the API's `error` field (JSON responses) or raw text body (non-JSON responses).
- On success it resolves with `void` — there is no return payload to inspect.

`API_BASE` is a hardcoded constant: `https://api.mailto.foo`. There is intentionally no env var or build-time override — allowing one would let anyone embedding the SDK silently redirect subscribe/config traffic to an arbitrary host while still presenting as `@mailto.foo/sdk`.

## CDN auto-enhancement (`src/cdn.ts`)

The CDN bundle scans the DOM for `form[action]` elements whose action matches `/((?:pk_|ph_public_)[A-Za-z0-9_-]+)\/subscribe\/?(?:[?#].*)?$/` and wires each one up automatically — no manual JS needed. Forms pointing at a mailto.foo subscribe path with an unrecognized key prefix have their submit blocked (with a `console.warn`) instead of letting the browser navigate to a 404.

1. Calls `mailto.injectHoneypot(form)` (see Bot protection below) before attaching the submit handler.
2. Intercepts `submit`, calls `preventDefault()`.
3. Disables and marks loading (`data-loading="true"`) on every element matching `.mailto-foo-button`, `button[type="submit"]`, or `input[type="submit"]`.
4. Dispatches `mailto:submitting` (bubbling `CustomEvent`, no detail) on the form.
5. Calls `mailto.subscribe()` with all form fields collected via `FormData` — including any extra fields you've added (see "Extra/custom fields" above) and the injected honeypot field.
6. On success: resets the form, dispatches `mailto:success`. If `data-verbose` is present, also clears `.mailto-foo-error` and sets `.mailto-foo-success` text to "Thanks for subscribing!".
7. On failure: dispatches `mailto:error` with `detail: { error: <Error> }`. If `data-verbose` is present, also clears `.mailto-foo-success` and sets `.mailto-foo-error` text to `err.message`.
8. Always re-enables buttons in a `finally` block (only if `data-verbose`).

By default the CDN script is **silent** — it only fires events and resets the form; it does not touch any status elements or button states. Add `data-verbose` to the `<form>` to opt into automatic UI management: status `<div>`s are auto-created if absent (appended as the last children), buttons are disabled during submission, and success/error text is written automatically. Exposes `window.Mailto = Mailto`.

```html
<!-- Default: silent mode — only events fire, no automatic UI changes -->
<form action="https://api.mailto.foo/pk_xxx/subscribe">
  <input type="email" name="email" required>
  <button type="submit">Subscribe</button>
</form>

<script>
form.addEventListener('mailto:submitting', () => showLoading())
form.addEventListener('mailto:success', () => showThankYou())
form.addEventListener('mailto:error', (e) => showError(e.detail.error.message))
</script>

<!-- data-verbose: auto-manages status divs and button loading state -->
<form action="https://api.mailto.foo/pk_xxx/subscribe" data-verbose>
  <input type="email" name="email" required>
  <div class="mailto-foo-error"></div>
  <div class="mailto-foo-success"></div>
  <button type="submit" class="mailto-foo-button">Subscribe</button>
</form>
```

## Bot protection / honeypot

`Mailto#injectHoneypot(form)` fetches `GET {API_BASE}/config/:publishKey/hint?fields=<comma-separated existing field names>` and, if the publisher has it enabled, appends a hidden `<input>` (visually hidden, `tabindex="-1"`, `aria-hidden="true"`, `autocomplete="off"`) to the form using a field name returned by the API. The returned `field` name and an optional `sessionId` are cached on the `Mailto` instance and sent as `f` / `sid` on the next `subscribe()` call.

- The CDN auto-enhancement calls this automatically for every enhanced form — no setup needed.
- For manual/headless usage, call `await mailto.injectHoneypot(form)` once before the form can be submitted if you want this protection (then call `subscribe()` as usual — the honeypot field will be included via `FormData`/your data object).
- Don't strip unexpected hidden input fields from a mailto.foo form — they may be the honeypot field used for bot detection.

## Error handling

Always wrap manual `subscribe()` calls in try/catch — it throws a plain `Error`:

```js
try {
  await mailto.subscribe({ email })
} catch (err) {
  console.error(err.message) // e.g. "Email already subscribed"
}
```

## Gotchas / non-obvious details

- The publish key (`pk_xxx`) is meant to be exposed client-side — it only authorizes submissions, not reads.
- `subscribe()` makes **two** network round-trips per call: a config fetch, then the actual subscribe POST (plus a Turnstile challenge round-trip when bot protection is enabled). Don't assume it's a single fetch when debugging latency or mocking requests in tests.
- Turnstile script loading is memoized via the module-level `turnstileScriptPromise` — it's only ever injected once per page, even across multiple forms/instances.
- The form-action regex requires the action URL to end in `/<publishKey>/subscribe` (optionally with a trailing slash or query/hash) and the key to start with `pk_` or `ph_public_` — forms with other URL shapes are silently skipped, but forms pointing at *some* `<key>/subscribe` path on a mailto.foo host with an unrecognized prefix have submission blocked with a console warning.
- Extra form fields beyond `email` are passed straight through to the API as arbitrary string key/value pairs (`SubscribeData` is `{ email: string; [key: string]: string }`).
