Newsletters
Staff-written member newsletters sent through each club's own Amazon SES tenant, with test sends, delivery statistics, one-click unsubscribe and member opt-out.
Staff open Newsletters in the back office to write a newsletter, send a test, then send it to the club's members. Delivery goes through Amazon SES, separately from transactional mail. Each club sends from its own domain through its own SES tenant, so one club's bounces or complaints never affect another club's reputation.
Club sending domain
Before a club can send, an administrator opens Settings → Integrations → Newsletter sending and
enters a sending domain (a subdomain reserved for newsletters, such as news.example.com), a sender
name and an address name (default newsletter).
query NewsletterSettings($clubId: ID!) {
newsletterSettings(clubId: $clubId) {
configured
restrictedTo
testRecipients
sender {
domain
senderName
senderEmail
status
checkedAt
records {
type
name
value
}
}
}
}
mutation ConfigureNewsletterSender($input: NewsletterSenderInput!) {
configureNewsletterSender(input: $input) {
senderEmail
status
}
}
mutation RefreshNewsletterSender($clubId: ID!) {
refreshNewsletterSender(clubId: $clubId) {
status
checkedAt
}
}configureNewsletterSender and refreshNewsletterSender require settings_manage;
newsletterSettings requires newsletters_manage or settings_manage, so the staff who configure the sender in Settings can read its status. NewsletterSenderInput requires clubId,
domain and senderName; localPart defaults to newsletter. The domain is trimmed, lowercased
and stripped of a trailing dot, and must be a valid host name; the address name must contain only
lowercase letters, digits, ., _ and -; the sender name is 1–100 characters without <, >,
" or line breaks.
Configuring creates (or reuses) the SES domain identity, a configuration set reporting delivery
events, and the club's tenant, then links them. The result lists the DNS records to add: three DKIM
CNAME records and a recommended DMARC TXT record. status is pending until SES sees the DKIM
records, verified once it does, and failed when the identity no longer exists in SES.
refreshNewsletterSender checks again and returns null when no domain is configured. Settings show
the records while the domain is not verified, and behind Show DNS records once it is. The sender
name is the display name recipients see in their inbox ("Name" <address>); the form prefills it
with the club name. Saving again
replaces the domain and sender. configured reports whether SES credentials are set on the
platform; restrictedTo lists the allowed recipients outside production (null in production).
Audience
A newsletter goes to every person of the club who:
- has a membership with no expiration date, or one expiring today or later;
- has not unsubscribed from newsletters;
- is not banned or disabled;
- when the newsletter targets tags, carries at least one of them.
Tags are the club's member tags. A newsletter with no tag goes to every subscribed member. Its tags are saved with the draft; tags deleted afterwards are ignored, and sending is refused when every targeted tag was deleted, so a targeted newsletter never widens to the whole club.
Addresses are trimmed, lowercased and deduplicated. Outside production (NODE_ENV other than
production), the club audience is ignored: every newsletter and every test goes only to
NEWSLETTER_ALLOWED_RECIPIENTS, and an empty list sends nothing.
Staff operations
query Newsletters($clubId: ID!) {
newsletters(clubId: $clubId) {
id
subject
status
recipientCount
sentAt
createdAt
}
}
query NewsletterAudience($clubId: ID!, $tagIds: [ID!]!) {
newsletterAudience(clubId: $clubId, tagIds: $tagIds) {
members
recipients
}
}
query Newsletter($clubId: ID!, $id: ID!) {
newsletter(clubId: $clubId, id: $id) {
id
subject
previewText
body
tags {
id
label
color
}
status
audienceCount
recipientCount
failure
sentAt
updatedAt
stats {
sent
delivered
bounced
complained
unsubscribed
failed
}
}
}
mutation CreateNewsletter($input: NewsletterInput!) {
createNewsletter(input: $input) {
id
status
}
}
mutation UpdateNewsletter($id: ID!, $input: NewsletterInput!) {
updateNewsletter(id: $id, input: $input) {
id
updatedAt
}
}
mutation DeleteNewsletter($clubId: ID!, $id: ID!) {
deleteNewsletter(clubId: $clubId, id: $id)
}
mutation SendNewsletterTest($clubId: ID!, $id: ID!, $emails: [String!]!) {
sendNewsletterTest(clubId: $clubId, id: $id, emails: $emails)
}
mutation SendNewsletter($clubId: ID!, $id: ID!) {
sendNewsletter(clubId: $clubId, id: $id) {
id
status
}
}These operations require the newsletters_manage permission in the requested club; club
administrators always have it. IDs must be UUIDs. newsletters returns the club's 200 most recent
newsletters, newest first; it has no pagination, filters or alternative sorting. newsletter
returns null for a missing newsletter or one from another club.
NewsletterInput requires clubId. subject, previewText and body default to empty strings
and tagIds to an empty list, so New newsletter creates an empty draft and opens it. Values are
trimmed; the subject and preview text are at most 200 characters and the body at most 20,000, and
NUL characters are rejected. A subject and a body are required to send or test. Tag IDs must be
UUIDs, at most 50, duplicates removed; IDs that are not tags of the club are dropped.
The body uses a small Markdown-like syntax, which the editor's toolbar inserts:
| Syntax | Result |
|---|---|
# Title, ## Title | Large and medium headings |
**bold**, *italic* | Bold and italic text |
[text](https://…) | Link (http, https and mailto only) |
- item, 1. item | Bulleted and numbered lists |
> text | Quote |
--- | Divider |
 | Image on its own line, https only |
[[Label](https://…)] | Call-to-action button on its own line |
A blank line separates paragraphs and single line breaks are kept. Anything else, including HTML, is shown as typed: content is always escaped and links with another scheme stay plain text. Each email has an HTML part and a plain-text part where formatting is reduced to readable text. The email adds the club name as a header, and the club address and a bilingual unsubscribe link as a footer.
query NewsletterPreview($input: NewsletterPreviewInput!) {
newsletterPreview(input: $input)
}newsletterPreview requires newsletters_manage and returns the complete HTML email for unsaved
subject, previewText and body (each defaulting to empty), with the same validation as drafts.
Its unsubscribe link does not unsubscribe anyone. The editor shows it in a sandboxed frame next to
the form and refreshes it shortly after each change.
| Status | Meaning |
|---|---|
draft | Editable and deletable. |
queued | Waiting for the newsletter worker, which checks every 10 seconds. |
sending | The worker is sending one email per recipient at the account's SES rate. |
sent | At least one email was accepted. sentAt and recipientCount are set. |
failed | failure explains why. It can be edited and sent again, but not deleted. |
newsletterAudience counts the audience for any tag selection, so the editor can show it before
saving: members is the number of subscribed members targeted, recipients the number of addresses
it would actually be sent to (the allowed recipients outside production). The back office shows the
tags as a "To" line above the subject, with the member count beneath; its menu also searches,
creates and deletes the club's tags.
audienceCount is only computed for a draft or failed newsletter fetched with newsletter; it is
null in the list. stats is only computed for a newsletter fetched with newsletter once sending
started: sent counts emails accepted by SES, delivered those the receiving server accepted,
bounced permanent bounces, complained spam reports, unsubscribed recipients who used their
link and failed emails SES refused. Open and click tracking is not used.
updateNewsletter returns null for a missing newsletter and a validation error once it has been
queued. deleteNewsletter only deletes drafts and returns false otherwise.
sendNewsletterTest sends the current content, with the subject prefixed by [Test], to 1–5
addresses from newsletterSettings.testRecipients: the club's team (staff with access to the club
and the owner of its space), or NEWSLETTER_ALLOWED_RECIPIENTS outside production. Other addresses
are refused, so a newsletter permission cannot be used to email strangers from the club's domain.
The back office lists these addresses as checkboxes, with the signed-in staff member selected. The
test's unsubscribe link does not unsubscribe anyone. sendNewsletter queues a draft or
failed newsletter with a non-empty body; it returns null for a missing newsletter. Both return a
validation error when sending is not configured, when the club's sending domain is not verified, or
with SES's explanation when it refuses an email.
Every recipient has a delivery record, so an interrupted sending resumes where it stopped: a
newsletter left in sending for 10 minutes is queued again, only recipients not yet attempted are
sent, and an email whose outcome is unknown is not sent twice. A newsletter where SES refused every
email becomes failed with SES's reason. Sending a failed newsletter again discards its
previous delivery records and sends to its current audience, with its edited content and tags.
Unsubscribing and delivery events
Every email carries a personal link to /newsletter/unsubscribe?token=… on the member site, where
the recipient confirms. It also carries List-Unsubscribe and
List-Unsubscribe-Post: List-Unsubscribe=One-Click headers pointing to
POST /api/newsletters/unsubscribe?token=…, which mail clients call directly. A GET on that URL
redirects to the confirmation page without unsubscribing, so link scanners cannot unsubscribe
anyone. Unknown tokens answer 404.
mutation UnsubscribeNewsletter($token: String!) {
unsubscribeNewsletter(token: $token) {
clubName
}
}unsubscribeNewsletter is public: holding the link is the authorization. It returns null for an
unknown token and the club name otherwise; repeating it is harmless.
SES publishes delivery, bounce, complaint and rejection events to an SNS topic delivered to an SQS queue that the API reads continuously. Events update the matching delivery record; a permanent bounce or a complaint also unsubscribes that person from the club's newsletters.
Member subscription
query MyNewsletterSubscription($clubId: ID!) {
myNewsletterSubscription(clubId: $clubId)
}
mutation SetMyNewsletterSubscription($clubId: ID!, $subscribed: Boolean!) {
setMyNewsletterSubscription(clubId: $clubId, subscribed: $subscribed)
}Members are subscribed by default and can change it under My account → Notifications → Preferences. Both operations require member authentication in the club and return the resulting subscription state. Staff cannot use them.
Configuration
| Variable | Purpose |
|---|---|
SES_ACCESS_KEY_ID | IAM access key for SES and the events queue. Enables sending. Secret. |
SES_SECRET_ACCESS_KEY | Its secret. |
SES_REGION | AWS region of the SES account, for example eu-north-1. |
SES_EVENTS_TOPIC_ARN | SNS topic each club configuration set reports events to. |
SES_EVENTS_QUEUE_URL | SQS queue subscribed to that topic. Without it, events are not recorded. |
CONSUMER_ORIGIN | Member site origin used for unsubscribe links. Required with SES. |
NEWSLETTER_ALLOWED_RECIPIENTS | Comma-separated addresses that receive every newsletter outside production. |