
Mail List Manager
Mail List Manager is a self-hosted mailing-list manager built on ASP.NET Core and SQL Server, with a Vue 3 self-service SPA and a native SwiftUI macOS client. Each list is a real mail account — IMAP or POP3 in, SMTP out — that the software polls on its own schedule, so it runs against the Gmail, Workspace or Microsoft 365 mailbox you already have, over OAuth. It ships as two containers and runs on your database, your mailbox and your server. $99 a year for updates, and the licence is yours to keep.
The list is yours. So is the mailbox it lives in.
Mail List Manager is a self-hosted mailing-list manager. An ASP.NET Core REST API on .NET
10, ASP.NET Identity and JWT over SQL Server or Azure SQL, a background poller that reads each
list's own mailbox and fans the post out to the membership, and a Vue 3 SPA for self-service
subscription and archive search. Administrators get a native SwiftUI admin client for macOS as
well. It ships as two containers — nginx serving the SPA, and a chiselled non-root .NET image
for the API — and docker compose up runs the whole stack on your laptop.
Every list is a real email account. IMAP or POP3 in, SMTP out. The software logs in and polls it, which means the list can be the Gmail, Workspace or Microsoft 365 mailbox you already pay for, authenticated over OAuth. There is no service in the middle, no per-subscriber meter, and nobody else holding your membership table.
List-Id: <announce.lists.example.com>
List-Post: <mailto:announce@lists.example.com>
List-Unsubscribe: <https://lists.example.com/unsubscribe/9f2c1a7e>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
List-Archive: <https://lists.example.com/lists/announce/archive>
Precedence: list
Auto-Submitted: auto-generated
X-Original-From: "Dana Reid" <dana@somewhere-else.example>
That is the header block on outbound mail. List-Unsubscribe-Post is RFC 8058 one-click, which
is what Gmail and Outlook look for. The original Message-ID is preserved, so replies thread
correctly in the recipient's client instead of arriving as orphans. And the From line is the
part that decides whether any of it gets delivered at all.
Relaying the author's address — fails alignment
From: "Dana Reid" <dana@somewhere-else.example>
Return-Path: <announce@lists.example.com>
Sending as the list — aligns, author preserved
From: "Announce" <announce@lists.example.com>
Return-Path: <announce@lists.example.com>
X-Original-From: "Dana Reid" <dana@somewhere-else.example>
In the first, the list is sending mail claiming to be a domain it has no authority over, and
SPF and DMARC are evaluated against that domain. In the second they are evaluated against a
domain you control, and the author survives in X-Original-From rather than in the envelope.
Seven checks stand between your list and a mail loop.
A mailing list that talks to another mailing list can generate mail forever. Mail List Manager runs seven independent checks on every inbound message, and any single one of them discards it.
- Our own
X-MailListManager-Looptoken — mail this system already sent. - An
X-MailListManager-Listheader, or aList-Idmatching any list in this installation. That second clause is what catches sibling cross-post cycles between two of your own lists. Fromis the list's own address.- RFC 3834
Auto-Submittedset to anything other thanno. Precedence: bulk,listorjunk.- Bounce signals: a null
Return-Path: <>, anX-Failed-Recipientsheader, or amultipart/reportbody. - More than 30
Receivedheaders.
Behind those, a unique index on (MailingListId, MessageId) in the archive makes reprocessing
the same message impossible, whatever happens upstream.
Rejected mail is archived, not vanished
A discarded message is still written to the archive with the reason it was discarded. List managers tick "include rejected" on the archive page and see exactly what was dropped and why. When a member swears they posted and nobody received it, that is a question with an answer rather than an argument.
Subscribe, unsubscribe and help are answered by mail, before anything else.
A message sent to list+subscribe@, list+unsubscribe@ or list+help@ — or one whose subject
is exactly subscribe or unsubscribe — asks the list a question rather than posting to it.
These commands settle before the membership gate and before ModeratorsOnlyPost. Joining,
leaving and asking for help therefore work by mail even on an announce-only list where nobody
but a moderator may post.
The handler assumes the From line is a lie:
- A mailed unsubscribe unsubscribes nobody. It mails back the one-click link. A spoofed
Fromchanges nothing about the membership. - A banned or unknown address gets total silence, so the list cannot be used as an oracle for who is on it.
helpis answered only to a known address, or on a public list, so the list cannot be turned into a reflector.Re:andFwd:are deliberately not stripped, so a vacation responder bouncing a subject line around never counts as a command.
Command mail is archived separately and visible only to list managers.
Connecting a mailbox is the setup, and it is the whole setup.
Incoming over IMAP on 993 or POP3; outgoing over SMTP on 587 with STARTTLS, or implicit TLS. Password authentication, or OAuth 2.0 XOAUTH2 against Google and Microsoft, with one-click Gmail and Outlook preset buttons in the UI. "Test connection" round-trips incoming and outgoing separately so you know which half is wrong. "Poll now" forces a poll rather than making you wait.
Access tokens refresh automatically with a five-minute skew. An OAuth grant that comes back
without a refresh token is rejected rather than stored, because storing it would only mean a
silent failure an hour later. LOGIN and PLAIN SASL are stripped from the mechanism list
before XOAUTH2 is attempted.
Passwords, refresh tokens and access tokens are encrypted at rest with the ASP.NET Data
Protection API under the purpose MailListManager.MailCredentials.v1. No endpoint returns any
of them, ever.
Four switches decide what a list is.
A list has a name, a slug matching ^[a-z0-9][a-z0-9-]*$, a description, and its own email
address and display name. Then four booleans:
IsPublic— anyone may self-subscribe, and the list appears in the public directory.RequiresApproval— self-subscribes land in PendingApproval instead of going straight through.ModeratorsOnlyPost— announce-only.IsActive— a paused list is neither polled nor delivered to.
Poll interval is per list, 30 to 86400 seconds, default 300.
Membership
Self-service subscribe with an emailed confirmation carrying a single-use token, a stable per-subscription unsubscribe token, and moderator-managed membership alongside it. Statuses are PendingConfirmation, PendingApproval, Active, Unsubscribed and Banned. Per-list roles are Member, Moderator and Owner. Delivery mode is Individual or NoMail.
Two global roles: Administrator, and ListManager — a ListManager may create lists and owns what they create. Only an Administrator may grant Owner, delete a list, or assign Identity roles. You cannot strip your own Administrator role, and you cannot deactivate your own account.
Delegated user administration
An active Owner or Moderator of a private list may create and manage accounts provisioned for
that list alone, scoped by ManagedByListId. Adding an address that already has an account
deliberately does not set that field — so adding an administrator to your list does not hand
you their account. Delegates cannot assign roles. Every action records the acting user.
The macOS admin client
A native SwiftUI app against the same REST API — no second backend, no separate schema. It covers list administration, the member roster and member editor, and the user administration screens. It also imports members from a CSV, which the web UI does not do. That import is client-side: it loops the same per-member API call the roster uses, one row at a time, and reports what was created, skipped and failed. There is no bulk endpoint behind it, so a file of a hundred thousand rows will take exactly as long as that sounds.
The archive is a record, and it treats list mail as untrusted.
Every inbound post is stored and searchable by subject, sender address, sender display name, body text and date range, with paging. Threaded conversation view computes reply depth from a precomputed thread root rather than walking a graph on every render.
Attachments are stored and served as downloads. They are never rendered inline. Archived HTML bodies are shown as escaped source rather than rendered, because a list post is content a stranger wrote and mailed you.
Quiet lists stop hammering their mailbox.
The poller doubles a list's interval after three consecutive empty polls, capped at 1800 seconds. Any fetch resets it. A dormant announce list checked every five minutes settles down to every thirty on its own.
One SMTP connection is opened per poll and reused for every message in it. A failed send drops the client, so the next message reconnects rather than inheriting a poisoned session and taking the rest of the batch down with it.
The caps: 50 messages per poll, 4 lists polled concurrently, 50 BCC recipients per SMTP transaction, a 15-second scheduler tick.
Operational honesty in the boot sequence
A message is deleted from the mailbox only after the archive row commits, so a crash mid-poll costs you a duplicate at worst, never a lost post. The poller runs in-process in the API by default or in a standalone worker — never both.
Outside Development, the API refuses to start against a database with pending migrations and
names them, rather than running against a schema it does not match. Set
Database:MigrateOnStartup=true to opt into applying them instead. Startup throws if the
connection string is missing or the JWT signing key is under 32 bytes. No secret is baked into
any image. Refresh tokens are stored as SHA-256 hashes and revoked on use. nginx at the edge
handles CSP, HSTS, a method allow-list and rate limiting — 20 r/s on /api, 5 r/s on
/api/auth/*.
Here is what it does not do.
- Delivery is individual mail only. There is no digest mode.
- Posting is by email. There is no web composer.
- SQL Server or Azure SQL only. No PostgreSQL, MySQL or SQLite in production.
- Archive search is
LIKE-based, not a full-text index. Fine for tens of thousands of messages; it is not a search engine. - You do not receive your own posts back. The author is excluded from the fan-out. We are reconsidering this.
- Bounces are detected and discarded, not scored. There is no per-subscriber bounce counter and no automatic disabling of a failing address.
- A failed poll is recorded and shown in the UI, not alerted on. Nothing notifies you when a mailbox stops answering.
RequiresApprovalhas no dedicated approval queue. A pending member is activated from the member-edit form.
Deployment
Two containers: nginx with the built SPA, and the chiselled non-root .NET API.
docker compose up brings up the whole stack locally, database included.
The documented production target is Azure Web App for Containers using sidecars. Nothing Azure-specific is compiled in — it is two OCI images and a SQL Server connection string, so anywhere that runs those runs this.
Pricing
$99 per year. That buys one year of updates.
It is a licence you keep. When the subscription lapses the software does not stop, does not phone home and does not degrade. The version you have at that moment is yours to run indefinitely, on as many lists as you like. What you stop receiving is new versions. Renew and updates resume.
Because you host it, there is no per-subscriber fee, no per-message fee, and no cap on lists, members or archived mail. A hosted list service prices the thing that grows — your subscriber count — which means the better your list does, the more it costs you. Here the licence is flat and your remaining costs are your own server, your own database and your own mailbox.
Questions
Why a subscription, when your other products are one-time? Because this one tracks somebody else's schedule. OAuth flows change, TLS requirements tighten, and the deliverability rules that Gmail and Microsoft enforce get rewritten without asking us. Keeping mail flowing through those changes is ongoing work rather than a finished artifact, and $99 a year is what funds it. The products that are done are sold as done.
What happens when I stop paying? Nothing happens. The build you have keeps running — no phone home, no kill switch, no feature that quietly turns itself off. You simply stop getting new versions until you renew.
Do you host it for me? No. You run it. Two containers and a docker compose up on your own
hardware, or Azure Web App for Containers if you would rather it were somebody's cloud. The
database and the mailbox are yours either way, which is the entire point.
Can I use my existing Gmail, Workspace or Microsoft 365 mailbox? Yes, over OAuth — there
are preset buttons for both. The list is just a mail account that the software polls. Create
announce@yourdomain, grant access, set the poll interval, done.
Will mail from the list actually arrive? The mechanics are right: the envelope From is
the list's own address so SPF and DMARC evaluate against a domain you control, the author is
preserved in X-Original-From rather than forged, the List-* headers are complete, and RFC
8058 one-click unsubscribe is set on single-recipient batches with the self-service page for
larger ones. Those are the signals mailbox providers look for. What we cannot do for you is
publish SPF, DKIM and DMARC records for your list's domain and keep your sending IP off
blocklists. That part is the operator's job, and it is yours now.
Your list, your database, your mailbox, your server. Nobody else needs a copy of who subscribed.