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.
Consent is a record#
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#
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#
- The EmailTemplates kit — mail that looks designed — the parts a campaign's mail is built from
- [Email] — mail rendered from data —
[Email]templates - [Authority] —
[Authority(Extensible)]: grants an app adds on top of the kit's answers - grants (a link that opens one row) —
ListReaderLinkandListConfirmationare grants