Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The MailingLists kit — a newsletter done properly

use Osysharp.MailingLists@0; · app.MailingLists = new MailingListSetup { Lists = [ … ], Mailer = … };

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

stable1 example compiled by CIuiemailkitconsent

Source on GitHub: Osysharp.MailingLists — github.com/osysharp/mailinglists ↗

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.

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

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#

Double opt-in, and nothing said to a stranger#

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.

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.

Every link a mail carries is a platform grant 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)). 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#

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:

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#

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

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#

A letter, wired end to end#

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(); } }
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#

Related

The EmailTemplates kit — mail that looks designed

`Osysharp.EmailTemplates` ships the parts a designed mail is built from — a layout with its preview line, logo and a…

[Email] — mail rendered from data

An `[Email]` component is a mail template. It is rendered on the server — from any function, a workflow step included —…

[Authority]

What an app that imports your entity may do to the `security { }` you declared on it. The default is `Narrowable` — the…

grants (a link that opens one row)

A `grant` is a link the platform signs that lets whoever holds it reach ONE row of ONE entity, for a while, without…