Skip to content

Repository files navigation

astro-acumbamail-component

Léelo en castellano

Newsletter signup for Astro, backed by Acumbamail. A platform-agnostic core, an accessible form, and an endpoint you can drop into a route file — double opt-in by default, GDPR consent checked on the server, honeypot instead of CAPTCHA, and no dependencies.

Extracted from a production integration running on Cloudflare Workers.

Sibling of astro-sitemap-pro-component and astro-social-ai-component.

Why this exists

A static Astro site cannot store an email address. Calling the Acumbamail API from the browser would mean publishing your account token in the HTML — and that token doesn't grant access to one list, it grants access to every list on the account: read them, write to them, wipe them.

So every signup form in Astro ends up writing the same four pieces: an endpoint that takes the POST, a form with a bot trap and a consent checkbox, the progressive-enhancement JavaScript that shows the result without a reload, and the copy in every language the site speaks.

This package is those four pieces, already written and tested.

What you get

  • subscribe() — the core. No platform imports, no environment variables, no Request. Injectable fetch, so it's testable without a network.
  • Subscribe.astro — the form. Honeypot, unchecked consent box, aria-live status that takes focus when the result lands, guarded against double submits, survives View Transitions, and submits with JavaScript off (see Without JavaScript).
  • createSubscribeHandler() — the endpoint, in one line, for Cloudflare Workers, Node, Vercel, Netlify or anything that speaks Request/Response.
  • 11 built-in languages — en, es, pt, pt-br, fr, it, de, nl, ca, gl, eu — plus per-string overrides.

Install

npm install github:dcarrero/Astro-Acumbamail-Component#v0.2.0

dist/ is committed, so installing straight from a GitHub tag needs no build step.

Quick start

1. The endpoint

Cloudflare Workers, Astro 6 or 7src/pages/api/subscribe.ts:

import { createSubscribeHandler } from "astro-acumbamail-component/cloudflare";

export const { POST, GET } = createSubscribeHandler({ langs: ["en", "es"], defaultLang: "en" });
export const prerender = false;

Node, Vercel, Netlify, or Astro 5 on Cloudflare — the generic entry point resolves the environment on its own:

import { createSubscribeHandler } from "astro-acumbamail-component/handlers";

export const { POST, GET } = createSubscribeHandler({ langs: ["en", "es"] });
export const prerender = false;

2. The environment

ACUMBAMAIL_AUTH_TOKEN     secret    — the "Identificador de cliente" from your Acumbamail panel
ACUMBAMAIL_LIST_ID_EN     variable  — list id for English
ACUMBAMAIL_LIST_ID_ES     variable  — list id for Spanish
ACUMBAMAIL_DOUBLE_OPTIN   variable  — "1" (default) or "0"

The handler looks for ACUMBAMAIL_LIST_ID_<LANG> first and falls back to ACUMBAMAIL_LIST_ID, so a single-language site only needs that one.

On Cloudflare: wrangler secret put ACUMBAMAIL_AUTH_TOKEN for the token, plain vars for the rest. Locally, .dev.varsand add it to .gitignore, always.

3. The form

---
import Subscribe from "astro-acumbamail-component/Subscribe.astro";
---
<Subscribe lang="es" id="footer" privacyHref="/privacidad/" />

That's it. The form posts to /api/subscribe, the endpoint calls Acumbamail, and the subscriber gets a confirmation email.

Subscribe.astro props

Prop Type Default Notes
action string /api/subscribe Endpoint path.
lang string en Which strings to use; also posted so the endpoint can pick the list.
id string subscribe Prefix for internal ids. Change it if the form appears twice on a page — duplicate ids break the label/field association a screen reader depends on.
privacyHref string /privacy/ Link inside the consent checkbox.
fields Array<string | FieldSpec> [] Extra fields beyond email. Each must exist in the Acumbamail list and be whitelisted in the handler.
labels Partial<Strings> Override any string of the chosen language.
heading string | null null Small label above the form.
note string | null null Fine print under the form — how often you write, what you send.
showEmailLabel boolean false By default the email label is screen-reader only.
honeypot string ab_hp Name of the trap field. Must match the handler's honeypot option.
class string Extra class on the wrapper.

FieldSpec is { name, label?, placeholder?, type?, required?, autocomplete? }.

<Subscribe
  lang="en"
  id="post-footer"
  fields={[{ name: "name", label: "Your name", autocomplete: "given-name" }]}
  heading="Get the newsletter"
  note="Every two weeks. Nothing else."
/>

Handler options

createSubscribeHandler({
  langs: ["en", "es"],          // accepted languages; anything else falls back to defaultLang
  defaultLang: "en",
  fields: ["name"],             // whitelist of custom fields accepted from the form
  mergeFields: { source: "web" }, // fixed fields added to every signup
  doubleOptin: true,            // default; env ACUMBAMAIL_DOUBLE_OPTIN="0" turns it off
  honeypot: "ab_hp",            // must match the component's prop; false disables it
  consentField: "consent",      // "gdpr" is always accepted as an alias
  redirect: { ok: "/thanks/", error: "/error/" }, // 303 for no-JS submits; JSON when JSON is asked for
  listId: (lang, env) => env[`LIST_${lang}`],     // or a plain string
  timeoutMs: 10_000,
  onSuccess: ({ email, lang }) => { /* analytics; throwing here won't break the response */ },
})

fields is a whitelist on purpose. Without it, a direct POST could write into any custom field of your list.

Responses

Case Status Body
Success 200 {"ok":true}
Honeypot filled 200 {"ok":true} — and nothing happens
Unreadable body (wrong content-type, malformed) 400 {"ok":false,"error":"bad_request"}
Malformed / missing email 400 {"ok":false,"error":"invalid_email"}
Consent not given 400 {"ok":false,"error":"consent_required"}
Token or list id missing 500 {"ok":false,"error":"misconfigured"}
Acumbamail rate limit hit 429 {"ok":false,"error":"rate_limited"}
Acumbamail rejected it 502 {"ok":false,"error":"provider_error"}
Acumbamail unreachable / timed out 502 {"ok":false,"error":"provider_unreachable"}
GET 405 Allow: POST
Cross-origin POST 403 Cut by Astro before the endpoint runs

The error codes are public API. The form maps them to messages, so changing them breaks anyone who has them mapped. rate_limited is worth retrying later; the rest are not.

Without JavaScript

The form submits fine with JavaScript off — but by default the browser then renders the raw {"ok":true}, which leaves the person staring at JSON outside your site. If that matters to you, set redirect:

createSubscribeHandler({
  redirect: { ok: "/thanks/", error: "/subscribe-error/" },
})

Now a no-JS submit gets a 303 to those pages (post/redirect/get, so a reload doesn't resubmit), while anything sending Accept: application/json — including this component's own script — still gets JSON. Without error, failures go to the ok URL with ?error=<code> appended, so one page can handle both.

Abuse and rate limiting

The endpoint is public and there is no rate limiting. With double opt-in on, anyone can loop curl against it with someone else's address and Acumbamail will send that person a confirmation email every time — filling their inbox from your domain, hurting your sending reputation and burning your account quota. The honeypot doesn't help here: a curl client simply doesn't fill it.

If your form is worth attacking, put something in front of the endpoint: rate limiting by IP (Cloudflare Rate Limiting rules, or a KV counter) and/or a challenge such as Turnstile. This component doesn't ship either, on purpose — it can't know your infrastructure — but the exposure is real and it's yours to close.

Acumbamail's own documented limits are per function (5 req/s or 10 req/min on several of them); addSubscriber declares none. A 429 from them comes back as rate_limited.

Using the core directly

import { subscribe } from "astro-acumbamail-component/core";

const result = await subscribe(
  { email: "someone@example.com", consent: true, fields: { name: "Ana" } },
  { authToken: TOKEN, listId: "12345" },
);
// { ok: true, id: 987 } | { ok: false, error: "provider_error", detail: "HTTP 502" }

It never throws — every failure comes back as { ok: false, error }. Pass fetch to test it without a network; that's what the package's own tests do.

Theming

Styles are scoped and driven by --ab-* variables that fall back to a host theme (--ink, --muted, --accent, --border-strong, --surface, --ok, --bad) and then to neutral values.

Map the host theme's variables — that is the path that works. Define them anywhere they'll be inherited (:root, a wrapper, whatever):

:root {
  --accent: #2563eb;
  --border-strong: #767676;   /* keep it at 3:1 against your background — WCAG 1.4.11 */
  --radius: 0;
}

Overriding the --ab-* variables from outside needs a bit of care: the component declares them in its own scoped selector, which Astro compiles to .ab-subscribe[data-astro-cid-…] (specificity 0,2,0). A plain .ab-subscribe { --ab-accent: red } of yours (0,1,0) loses and does nothing — this README claimed otherwise until 0.2.0. Repeat the class to win:

.ab-subscribe.ab-subscribe {
  --ab-accent: #2563eb;
}

Decisions worth knowing

Each of these came out of writing the integration for real.

  • Double opt-in is the default. The address doesn't enter the list until its owner clicks the link in the confirmation email. It costs subscribers and the ones left are real — and it leaves a record of consent, which is what the GDPR asks you to be able to show.

  • Consent is checked on the server. required on the checkbox is a convenience for the person filling the form, not a guarantee for the person storing the data: a direct POST skips it. And the box is never pre-checked — consent has to be an affirmative act.

  • A honeypot, not a CAPTCHA. A hidden website field; if it arrives filled, the endpoint answers success and does nothing — a bot that gets an error retries another way, one that gets success moves on. Double opt-in already filters fake addresses, and a CAPTCHA is a barrier for real people in exchange for little.

  • Deliberately permissive email validation. A strict regex rejects valid addresses — long TLDs, +, hyphenated domains — and blocks no fake ones: a@b.c passes any validator and may not exist. What decides whether an address is real is the confirmation email.

  • Always set langs. Without it, the posted lang picks which ACUMBAMAIL_LIST_ID_* variable is read, so a crafted POST can aim at any list you have configured. With langs, anything unexpected falls back to defaultLang.

  • One list per language, not one list with a language field. In Acumbamail a campaign is sent to a list, so splitting at signup is what saves you from segmenting on every send.

  • No local copy of the addresses. A second database holding the same emails is a second place they can leak from and a second obligation to answer on an erasure request, in exchange for nothing — exporting from Acumbamail is a CSV.

  • No CSRF check in the endpoint. Astro ships security.checkOrigin on by default and rejects cross-origin form POSTs with a 403 before your code runs. Writing another Origin check would be code that never executes and that someone will maintain believing it's the real defence.

    The precise scope, read from Astro's own middleware: it blocks non-safe methods whose Content-Type is form-like (application/x-www-form-urlencoded, multipart/form-data, text/plain) or absent, when Origin doesn't match the request's own origin. A cross-origin POST with Content-Type: application/json isn't blocked there — it's blocked by the browser, which requires a CORS preflight that a site without CORS headers never answers. Both paths are covered; they're just covered by different things.

Environment notes

  • Astro.locals.runtime.env doesn't exist from Astro 6 on. It's what most circulating examples teach; it compiles and blows up at runtime with a 500. On Workers use astro-acumbamail-component/cloudflare, which reads cloudflare:workers. On Astro 5 the generic handler still reads context.locals.runtime.env, so both work.
  • With Astro 7.1.6, @astrojs/cloudflare@14.2.0 breaks the build ("beginContentEntryCollection" is not exported…); 14.1.7 works. Pin the exact version.
  • In wrangler.jsonc, main must be "@astrojs/cloudflare/entrypoints/server".

The Acumbamail API, verified

POST https://acumbamail.com/api/1/addSubscriber/
Content-Type: application/x-www-form-urlencoded
Parameter Required What it is
auth_token yes The "Identificador de cliente" from the panel. Secret.
list_id yes Numeric list id
merge_fields[email] yes The address. Fields always go inside merge_fields[…]
merge_fields[<name>] no Any custom field of the list, by its name
double_optin no 1 sends a confirmation email; 0 subscribes straight away
update_subscriber no 1 makes the call idempotent
response_type no json or XML. JSON is the default anyway
complete_json no 1 makes success return {"email": …, "id": …} instead of a bare integer

The trailing slash matters: without it the API answers 301, and fetch turns a redirected POST into a GET, dropping the body.

On success it returns the subscriber id (a number, or the object above with complete_json=1). On failure, an object with error or errors. There is no ok field, and an invalid token answers 401 with plain text, not JSON — so you have to look at the body as well as the status. A successful signup may also come back as 201.

Documented status codes: 200 ok, 201 modified, 400 bad parameters, 401 auth failed, 429 too many requests, 500 server error.

Until 0.2.0 this component sent _response_type, with a leading underscore. That parameter does not exist: the API ignored it silently and returned JSON because JSON is its default. Fixed in 0.2.0 — if you copied that detail from here, it's wrong.

Scope

This component collects addresses. It doesn't send the campaigns — those are written and sent from the Acumbamail panel — and it doesn't manage the list: getSubscribers, deleteSubscriber and friends are not implemented, and shouldn't be reachable from a public endpoint anyway.

It fits any form whose destination is a list: newsletter signup, lead capture with extra fields, waiting list, resource download. It is not a contact form: the message would end up as a field on a subscriber, and the sender would be signed up to marketing without asking for it.

Development

npm install
npm test      # builds, then runs every test with an injected fetch

What has been verified

  • 54 unit tests over the core, the handlers and the strings, with fetch injected — no network, no server. Every bug fixed in 0.2.0 has a test that fails without the fix.
  • View Transitions, the CSS custom properties, the focus move and the rendered markup checked in a real Chrome against a real build.
  • Built and rendered in a real Astro 5 project and a real Astro 7 + @astrojs/cloudflare@14.1.7 project, installed as a packed tarball (the same path as installing from a GitHub tag).
  • Every response in the table above exercised end to end: against node ./dist/server/entry.mjs with the Node adapter, and against wrangler dev --local on Workers — including the cross-origin 403, which Astro cuts before the handler runs.
  • With a deliberately wrong token, the request does reach Acumbamail: it answers 401 and the core turns that into provider_error.
  • Not yet verified: the success path against a real token and list id, which needs an account. Everything up to the API call is covered.

License

MIT © David Carrero Fernández-Baillo

About

Newsletter signup for Astro backed by Acumbamail: platform-agnostic core, accessible form with honeypot and server-checked GDPR consent, double opt-in by default, and endpoint handlers for Cloudflare Workers, Node and any Request/Response runtime. 10 languages, zero dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages