# The MailingLists kit — a newsletter done properly

> `Osysharp.MailingLists` keeps an app's mailing lists: sign-up with double opt-in, consent recorded word for word, a link in every mail that shows the reader's lists and switches them (and the one-click `List-Unsubscribe` header), bounces and spam reports that stop an address, CSV export and erasure — and sending a campaign one mail per reader through the app's own outbox, never twice. Sign-up form, confirm page, preferences page, a reader's switches and a staff desk come with it.

<!-- id: ui-mailing-lists-kit · area: ui · stability: stable · html: https://osysharp.com/reference/ui/mailing-lists-kit/ -->

## Summary        {#summary}
A list people sign up to owes them four things, and the kit does all four so an app does not have to get them
right by hand: the address is **confirmed from its own mailbox** before anything is sent; the **words they agreed to**
are kept, with when and where; every mail carries **their own way out**, with the one-click header Gmail and Yahoo
require; and an address that **bounces or reports spam** is never mailed again until its owner confirms it afresh.

```osy syntax
// app.osy
use Osysharp.MailingLists@0;

// model/lists.osy
app.MailingLists = new MailingListSetup {
  Lists = [ new ListInfo { Key = "letter", Name = "the monthly letter", Description = "New things, once a month." } ],
  Mailer = new MyListMailer(),            // hands each mail to the app's own outbox
};
```

## Signature      {#signature}
```osy syntax
SignUpToList(email, listKey, source, words)        // a form's sign-up — "" or the sentence to show
ConfirmSubscription(ListConfirmation link) · ReaderChoices(ListReaderLink link) · ReaderAddress(link)
LeaveWithLink(link, listKey) · JoinWithLink(link, listKey, words) · OneClickUnsubscribe(link, listKey)
PreferencesLink(reader) · OneClickLink(reader, listKey)                 // the URLs a mail carries
ListChoicesFor(email) · ChooseList(email, listKey, on, source, words)   // a signed-in reader's own lists
SendToList(ListCampaign campaign, string listKey) → ListSendResult    // one mail per confirmed reader, at most once
ReadersOf(listKey) · ListSummaries() · ListReaders(listKey) · ExportReaders(listKey) · ReaderRecord(email)
EraseReader(email) · StopReader(reader, why) · StopAddress(email, complained, detail)

ListSignupForm(listKey, words, source:) · ListConfirm(ListConfirmation link) · ListPreferences(ListReaderLink link)
ListChoices(email) · DeskMailingLists()
```

## Description    {#description}

### Double opt-in, and nothing said to a stranger   {#opt-in}
`SignUpToList` leaves a row any visitor may create and nobody reads back; a workflow handles it as the app — finds or
makes the reader, records the consent, and mails the confirmation (at most one an hour per address). It answers the
same whether the address is new, waiting or already on the list, so the form cannot be used to learn who reads it.
Nothing goes to a list's reader until they have confirmed.

### Consent is a record   {#consent}
Every join, confirmation and departure is a `ListConsent` row: the list, `At`, the `Source` (`"checkout"`, `"footer"`,
`"unsubscribe link"`, `"account"`) and the exact `Words` beside the box or button. Pass the words your page shows —
they are what is kept. `ReaderRecord(email)` prints all of it for a person asking what is held about them.

### Links that need no sign-in   {#links}
Every link a mail carries is a platform [grant](https://osysharp.com/reference/security/grants/) the kit declares:

- `ListReaderLink` — the "manage your mail" link and the one-click unsubscribe. It reads ITS reader's row and that
  reader's memberships, and nothing else; it works for a year, and a fresh one goes out with every letter.
- `ListConfirmation` — the double opt-in mail's button. It confirms the address it was sent to, for seven days.

The platform checks a link before any of the kit runs — signature, app, which of the two it is, expiry — so a
confirmation link cannot manage lists, a reader link cannot confirm, and a forged or edited one does nothing. The pages
take them as typed parameters, so a link that does not check out never opens the page: the visitor gets the app's
`app.Ui { InvalidLinkSurface }` page instead (see [UI surfaces (app.Ui)](https://osysharp.com/reference/config/ui/)). Erasing a reader takes back every link ever sent to
them (`ListReaderLink.RevokeAll`). What a link asks for is still deposited into the reader's own workflow
(`[CorrelateOn]`), which makes the change. `SECURITY-INVARIANT(list-links-are-signed)`.

Because the link reads its reader, **the preferences page shows where they stand**: the address, and a switch per list
that is on for the lists they get. Switching one off leaves that list; switching it on joins it again, with the words
shown recorded as consent. With more than one list there is also *Unsubscribe from everything*. A signed-in reader gets
the same switches through `ListChoices(email)`.

There is no signing key to set — a grant is signed with the app's own key.

### The app says who runs it   {#security}
The kit cannot name the app's roles or its principal, so it asks two questions, and the app answers each in one line —
who runs the lists (reads every reader, makes a list, sends), and which address is a signed-in reader's own:

```osy syntax
policy RunsMailingLists => IsStaff;
policy OwnsListAddress(string email) => email == user.Email;
```

Unanswered, nobody runs the lists and no account reads an address; a reader with no account still reaches their own
row through the signed link in each mail. Every table stays `[Authority(Extensible)]`, so an app that wants a finer
split adds `partial entity … { security { … } }` grants of its own on top.

### Sending   {#sending}
Derive from `ListCampaign` (its `Key()` and its mail `For(reader, unsubscribe)`) and call `SendToList`. Each mail goes
to the app's `ListMailer` with the one-click address (`ListMailOut.Unsubscribe`) and a `Key`; a `ListDelivery` row
per campaign and reader makes a second run mail nobody twice. With Resend, send `Headers =
ListUnsubscribeHeaders(mail.Unsubscribe)`. Route the one-click POST to `OneClickUnsubscribe` (`Raw = true` — the body is
`List-Unsubscribe=One-Click`).

### What stops mail to an address that bounced or complained?   {#suppression}
Verify the provider's webhook first (`ResendWebhook.Verify` for Resend), then `StopAddress(email, complained,
detail)`: the reader is stopped until a fresh confirmation from their mailbox.

## Examples       {#examples}

### A letter, wired end to end   {#example-letter}
```osy title="one list, an outbox, the grants, and the pages a mail links to" test app=ui-mailing-lists-kit
app LetterExample {
  use Osysharp.MailingLists;
  use Osysharp.EmailTemplates;
  use Osysharp.Workflow;
  use Osysharp.Storage;
  use Osysharp.Ui;
  model "model/**/*.osy";
}

using Osysharp.MailingLists;
using Osysharp.Ui;

[Role] enum Level { Authenticator, Staff }
[Principal] entity Person {
  [Required, Unique, MaxLength(255)] string Email;
  security { allow read when IsAuthenticated where Id == user.Id; }
}
[RoleGrant] entity Grant {
  [Required] Person Person;
  [Required] Level Level;
  security { allow read when IsAuthenticated; }
}
policy IsStaff => Grant.Any(g => g.Person == user && g.Level == Level.Staff);

// The outbox a list hands its mail to — delivered (and retried) by the app.
entity Outbox {
  [Required, MaxLength(255)] string To;
  [Required, MaxLength(200)] string Subject;
  string Text;
  [MaxLength(600)] string? ListUnsubscribe;
  security { allow read, create when IsStaff; }
}
public class OutboxMailer : ListMailer {
  public override void Send(ListMailOut mail) {
    new Outbox { To = mail.To, Subject = mail.Mail.Subject, Text = mail.Mail.Text,
                 ListUnsubscribe = mail.Unsubscribe == "" ? null : mail.Unsubscribe };
  }
}

app.MailingLists = new MailingListSetup {
  Lists = [ new ListInfo { Key = "letter", Name = "the monthly letter", Description = "New things, once a month." } ],
  Brand = "Acme", Address = "1 Main St, Springfield",
  Mailer = new OutboxMailer(),
};

policy RunsMailingLists => IsStaff;
policy OwnsListAddress(string email) => email == user.Email;

[Route("/")] [AllowAnonymous]
component Home() {
  render { ListSignupForm("letter", "Yes, send me the monthly letter. I can leave at any time.", source: "home"); }
}
[Route("/newsletter/confirm/{link}")] [AllowAnonymous] component Confirm(ListConfirmation link) { render { ListConfirm(link); } }
[Route("/newsletter/{link}")] [AllowAnonymous] component Letters(ListReaderLink link) { render { ListPreferences(link); } }
[Route("/newsletter/link-gone")] [AllowAnonymous] component LinkGone() { render { Text("That link has run out."); } }
app.Ui = new AppUi { InvalidLinkSurface = LinkGone };
[Route("/staff/readers")] [Authorize(IsStaff)] component Readers() { render { DeskMailingLists(); } }
```

```osy title="a page's sign-up form, the two pages a mail links to, and the desk" syntax
ListSignupForm("letter", "Yes, send me the monthly letter. I can leave at any time.", source: "footer");

[Route("/newsletter/confirm/{link}")] [AllowAnonymous] component Confirm(ListConfirmation link) { render { ListConfirm(link); } }
[Route("/newsletter/{link}")] [AllowAnonymous] component Letters(ListReaderLink link) { render { ListPreferences(link); } }
[Route("/staff/subscribers")] [Authorize(IsStaff)] component Readers() { render { DeskMailingLists(); } }
```

## See also       {#see-also}
- [The EmailTemplates kit — mail that looks designed](https://osysharp.com/reference/ui/email-templates-kit/) — the parts a campaign's mail is built from
- [[Email] — mail rendered from data](https://osysharp.com/reference/ui/email/) — `[Email]` templates
- [[Authority]](https://osysharp.com/reference/security/contribution-authority/) — `[Authority(Extensible)]`: grants an app adds on top of the kit's answers
- [grants (a link that opens one row)](https://osysharp.com/reference/security/grants/) — `ListReaderLink` and `ListConfirmation` are grants
