Champagne

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:

SyntaxResult
# Title, ## TitleLarge and medium headings
**bold**, *italic*Bold and italic text
[text](https://…)Link (http, https and mailto only)
- item, 1. itemBulleted and numbered lists
> textQuote
---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.

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.

StatusMeaning
draftEditable and deletable.
queuedWaiting for the newsletter worker, which checks every 10 seconds.
sendingThe worker is sending one email per recipient at the account's SES rate.
sentAt least one email was accepted. sentAt and recipientCount are set.
failedfailure 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

VariablePurpose
SES_ACCESS_KEY_IDIAM access key for SES and the events queue. Enables sending. Secret.
SES_SECRET_ACCESS_KEYIts secret.
SES_REGIONAWS region of the SES account, for example eu-north-1.
SES_EVENTS_TOPIC_ARNSNS topic each club configuration set reports events to.
SES_EVENTS_QUEUE_URLSQS queue subscribed to that topic. Without it, events are not recorded.
CONSUMER_ORIGINMember site origin used for unsubscribe links. Required with SES.
NEWSLETTER_ALLOWED_RECIPIENTSComma-separated addresses that receive every newsletter outside production.

Sur cette page