# 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.

Source: https://docs.champagne.dev-ukab.fr/en/graphql/newsletters

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 [#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`).

```graphql
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 [#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](/en/graphql/person-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 [#staff-operations]

```graphql
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                                  |
| `![description](https://…)` | 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.

```graphql
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 [#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.

```graphql
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 [#member-subscription]

```graphql
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 [#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. |
