Email: team mailboxes, the postmaster and invitations
Tharga.Team.Email is the mail layer of the toolkit: reading a mailbox over IMAP, sending over SMTP, and the
MIME handling both need. Four things use it:
- Support cases by email, in
Tharga.Team.Support, which now depends on this package. - Team mailboxes — an address per team that receives real mail, described below.
- Invitation email, which moved here from
Tharga.Team.Servicein 3.29. - Mail to users — categories, unsubscribe links and a persisted queue, with messages and automatic notices on top. New in 4.2.
The package exists so that MailKit reaches only hosts that use mail. Tharga.Team.Blazor never references it:
the views depend on contracts in Tharga.Team alone.
The transport
Everything above goes through one transport, IMailClient, which never throws: every outcome is a result.
- Sending:
OutboundMailcarries the text body, and optionallyBodyHtml(sanitized by the caller) sent asmultipart/alternative,Attachments(MailAttachment, with aContentIdfor an image the HTML shows ascid:, sent beside it asmultipart/related), threading headers, a reply address, andUnsubscribeUrl, sent asList-Unsubscribe— withList-Unsubscribe-Postfor one-click unsubscribing when it is https. - Reading:
InboundMailcarries the envelope and the text, and alsoBodyHtml— exactly as the sender wrote it, not sanitized; run it throughSafeHtmlbefore showing it — andAttachments(InboundAttachment: attachments, inline images with their Content-ID, and forwarded mail as.eml, with file names sanitized). A delivery status notification fillsBouncedMessageIdandBounceReason. - Who sent it: every mail read is checked by DKIM.
SenderVerifiedis true when a signature verifies, signsFromand the whole body, and its domain is theFromdomain or a parent of it;SenderDomainnames that domain. Keys are looked up in DNS (cached ten minutes, a miss one minute, a lookup cut off after five seconds), and anything that goes wrong means not verified. It vouches for the domain's mail system, not for a person. - A mailbox that could not be read returns
MailFetchResult.Errorwith the server's reason, never the password. MailOptionsgainedFirstReadSince, where a mailbox with no read position starts, and the file limitsMaxAttachmentBytes(25 MB),MaxAttachmentTotalBytes(50 MB) andMaxAttachmentCount(20).HtmlText.ToPlainText(html)is the plain-text rendering the toolkit uses for HTML: paragraphs kept, list items as-lines, quotes as>lines, links astext (address).IMailBounceHandlerreceives a bounce support read that names no support answer; see Bounces.
Team mailboxes
Every team can receive mail at an address nobody can guess, and a host decides what becomes of it.
builder.Services.AddThargaTeamRepository(o =>
{
o.RegisterTeamRepository<TeamEntity, TeamMember>(); // provides the key and mail stores
});
builder.Services.AddThargaTeamMailbox(o =>
{
o.Domain = "example.app"; // every team address is on this domain
o.Suffix = "-case"; // {key}-case@example.app
o.Mail.Imap.Host = builder.Configuration["Mailbox:Imap:Host"];
o.Mail.Imap.UserName = builder.Configuration["Mailbox:Imap:UserName"];
o.Mail.Imap.Password = builder.Configuration["Mailbox:Imap:Password"];
});
builder.Services.AddScoped<IInboundMailContentStore, MyBlobContentStore>(); // where raw mail is kept
Without IMAP settings the mailbox issues and rotates addresses but reads nothing. With them, a poller reads the
mailbox, and refuses to start without an IInboundMailContentStore, naming the interface.
Addresses
A team's address is {key}{suffix}@{domain} — for example 8fjgthitwne3-case@example.app. The key is random,
lowercase letters and digits (twelve by default, about 62 bits), and names no team: an address built from a team
id can be guessed, and then anyone can feed mail into a team.
An item inside the team — a case, a ticket — has an address of its own: {key}{suffix}+{itemKey}@{domain}. The
item key is the host's and is handed back with every mail sent to that address. ITeamMailboxService.GetItemAddressAsync
builds it on the team's current key.
Rotating
A team holds at most two keys: the primary, used for everything new, and a secondary, still accepted until it retires.
EnsureKeyAsyncissues the first key, and does nothing when the team has one. Two instances issuing at the same moment end with one key.RotateKeyAsyncmakes a new primary and keeps the old one as the secondary forRetirementPeriod(default 90 days). A secondary from an earlier rotation retires at once.- Mail to a secondary still reaches the team, marked
RetiringKeywith the retirement date, so the team can tell the sender the new address in time. - Retired keys are kept, never reissued: mail to an old address reaches the postmaster, never a team that drew the same key later.
- Host code can issue a key on its own authority through
ITeamMailboxProvisioning— when a feature is switched on, or to show an item's address to whoever may read the item. It is an internal service: it checks nothing, so it is never injected into a component, and a host calls it only behind a check of its own.
Receiving
One catch-all mailbox receives everything. There is no mailbox per team to provision or rotate; the whole
domain lands in one, and each message is routed by the address it was actually delivered to —
Delivered-To, X-Original-To, Envelope-To, most trustworthy first — never the To header, which a forward or
a BCC does not reflect.
| Delivered to | Goes to | MailRouteReason |
|---|---|---|
| A team's primary key | That team | PrimaryKey |
| A team's secondary key, before it retires | That team, marked | RetiringKey |
| A retired key | The postmaster | RetiredKey |
| A key nobody holds, or no address of ours | The postmaster | UnknownAddress |
Nothing is dropped. A mail that reached an organisation's address may already count as received by it, even at a retired or mistyped address, so whatever no team claims is kept by the postmaster.
Each delivery is kept once, however many instances read the mailbox and however often. A mail's id is derived from its Message-Id and the address it was delivered to — one mail sent to two teams is two deliveries — and the store refuses an id it already holds. Unlike the support event ledger, this does not expire.
A mail that cannot be kept is retried, not skipped. The raw message is saved first and the mail recorded after, so a crash in between leaves only content that the next read overwrites. If either fails, the poller stops just before that mail and reads it again next time; the mails before it in the batch are kept.
The mailbox is only ever read: nothing is marked, moved or deleted on the server.
A forwarding catch-all names its own mailbox first. A catch-all that forwards to a mailbox — cPanel's Default
Address, for example — writes that mailbox into Delivered-To and keeps the address the sender wrote in
Envelope-To. Routing reads every delivery header and skips any address that is not a team's, so the mail still
reaches its team; and for mail no team claims, the postmaster records the first address on your domain that is not
the catch-all mailbox itself (TeamMailboxOptions.Mail.Imap.UserName), so staff see who the sender meant.
Setting one up:
- Point the domain's catch-all at one mailbox, and give that mailbox's IMAP settings to
TeamMailboxOptions.Mail. - Turn off any automatic folders for plus-addressing (
name+tag@) on that mailbox: item addresses use+, and the poller reads one folder (Mail.Folder,INBOXby default). - Send a test mail to an address nobody owns and check that the postmaster shows that address, not the mailbox's.
Health
ITeamMailboxHealth.CheckAsync connects to the catch-all mailbox and opens its folder read-only, and returns a
MailboxHealth (IsConfigured, IsReachable, Error) without throwing. Put it on your health endpoint: the
poller only logs a failed read, so a wrong password otherwise shows as mail that never arrives.
What a host does with the mail
Nothing is registered anywhere automatically. ITeamMailboxService lists a team's mail, opens one, returns its
raw content, and records what became of it with SetOutcomeAsync(teamKey, mailId, outcome, owner) — an outcome and
an owner in the host's own terms ("registered", a case id). Recording null returns the mail to the unhandled
list. Who handled it and when is kept.
ITeamMailNotifier raises MailUpdated for every mail received, handled, routed, archived or restored
(TeamMailChange Received, Handled, Rerouted, Archived, Restored; the postmaster's own changes carry no
team). It is a singleton raised in this process only, outside any circuit: a component marshals with InvokeAsync,
and a host with several instances relays it over its own backplane. It carries no content — a handler reads the mail
back through the service, where access is checked.
Content
The toolkit keeps the envelope — sender, recipients, subject, Message-Id, route — and hands the raw message
(.eml) to IInboundMailContentStore, which the host implements: blob storage, a file share. Nothing large
reaches the toolkit's database. SaveAsync must overwrite: the same delivery read twice saves the same content
under the same id.
Scopes
| Scope | Kind | Grants |
|---|---|---|
mailbox:read |
Team | List and open the team's mail, see its addresses |
mailbox:handle |
Team | Record what became of a mail |
mailbox:manage |
Team | Issue and rotate the team's address |
mailbox:postmaster:read |
System | List and open mail no team owns, and its files |
mailbox:postmaster:manage |
System | Route such a mail to a team, or archive and restore it |
Three team scopes, because three different people use a mailbox: someone handling mail every day should not
rotate the address every sender knows. Their access levels are the host's — ReadAccessLevel and
HandleAccessLevel default to User, ManageAccessLevel to Administrator, and null registers a scope grant-only,
held only through a role.
The postmaster
Mail no team owns. IPostmasterService is a system service: no team scope reaches it, because unrouted mail may
hold any team's material.
- The listing is deliberately thin — sender, subject, time and reason, and for an archived mail when and by whom — enough to decide what to open.
- Opening is the audited act.
OpenAsync,GetContentAsync,GetBodyAsyncandOpenAttachmentAsyncare recorded every time, undermailbox:postmaster:read. RouteAsync(mailId, toTeamKey)delivers a mail to a team, markedRerouted. It refuses a team that does not exist, and a mail someone else already routed returnsnull. The parameter is notteamKeybecause the team is where the mail goes, not who is asking: routing is authorized by the system scope alone.- Retention: mail the postmaster holds longer than
PostmasterRetention(default 90 days) is deleted with its content, archived or not, and every deletion is recorded.
When a team is removed, its mailbox goes with it: its mail, the content kept for it, and its keys.
Held and archived
A mail the postmaster holds waits for someone to route it — or to put it away without routing it: spam, a mail sent by mistake, one already dealt with elsewhere.
ArchiveAsync(mailId)records the outcomePostmasterMailOutcome.Archived("archived") on the mail, withHandledAtandHandledBy— the archiver's name as it was then — and announcesTeamMailChange.Archived. The mail leavesListAsync()andGetCountAsync(), and stays the postmaster's: it can still be opened, read, restored and routed, and it goes with the same retention as the rest.RestoreAsync(mailId)clears the outcome, who and when, puts the mail back among the held mail and announcesTeamMailChange.Restored.- Both are idempotent and take
mailbox:postmaster:manage, recorded as changes. Archiving an archived mail, or restoring a held one, returns it unchanged and announces nothing; a mail routed to a team, or one the postmaster no longer holds, returnsnull— neither works on a team's mail. - Routing an archived mail delivers it as any other: the outcome is cleared on the way, so the team receives it unhandled.
ListAsync(PostmasterMailState state, cursor, pageSize)listsHeldorArchivedmail. Both are ordered by when the mail was received, newest first, with the same cursor — archived mail is not ordered by when it was archived; each summary says when (PostmasterMailSummary.ArchivedAt) and by whom (ArchivedBy).ListAsync()without a state is the held mail, as before.
Reading a mail
A view cannot parse a mail itself, so the postmaster reads it for one:
GetBodyAsync(mailId)returns aReceivedMailBody:Text(the text part, or the formatting rendered as text),Html(the formatting throughSafeHtml— formatting, web and mail links, remote images replaced by their alt text,cid:references kept; null when there is none),Attachments(every file as aSupportAttachment: attachments, inline images and forwarded mail as.eml, signatures left out, eachIdits position counted from 1) andImages(the inline images the formatting names, of a type a browser shows without running anything, up toReceivedMailBody.MaxInlineImageBytes, 2 MB, to show in place). Nothing is cut: quoted history stays, since the point is to find out whose the mail is. Null when the postmaster holds no such mail or its content is no longer kept.OpenAttachmentAsync(mailId, attachmentId)returns one file as aSupportAttachmentContentfor the caller to dispose, or null. Serve it asSupportAttachmentContentTypes.ServedAssays, never as the declared type.
A host with its own REST surface adds endpoints for listing by state, archiving, restoring, the body and a file,
each calling IPostmasterService so the scope and the record apply.
Views
Both are in Tharga.Team.Blazor and take a TimeTemplate to render moments the host's way.
PostmasterView— reads like the support mailbox. A switch shows the Held or the Archived mail; each mail is an expandable card whose header shows the subject (or "(no subject)"), the sender, when it arrived, why it reached the postmaster and, once archived, when and by whom. Opening a card is the audited open, once per mail however often the list is read again: it shows who the mail went to (From, To, Cc, Delivered to, Received) and the message as a conversation bubble — the sender, when it was written, the formatting sanitized with its own images in place, or the text as paragraphs — with its files to open or save throughOpenAttachmentAsync. Inside a held mail it is routed to a team chosen from a list (when the reader may list teams) or typed, or archived after a confirmation; an archived mail is restored. A reader who may read but not decide is told so.ShowTitle(defaulttrue) shows the title and the count of held mail; set it tofalsewhen the view sits in a tab whose header shows the count fromGetCountAsync().RefreshAsync()reads the list again for a host that hears of a change on another instance; the in-process notifier is listened to as before.TeamMailAddressView— the selected team's address with a copy button, a retiring address as a warning with its date, and changing the address only after a confirmation that says senders must be told.AllowChangehides creating and changing for a reader withoutmailbox:manage.
A reader without the grant sees a refusal, never the data. Texts are in PostmasterViewText and
TeamMailAddressViewText.
Invitation email
AddThargaEmailInvitations() registers the SMTP invitation sender. Settings are EmailOptions, from
o.Email on the Blazor options as before, or passed to the call:
builder.Services.AddThargaEmailInvitations(o =>
{
o.SmtpHost = "smtp.example.com";
o.FromAddress = "hello@example.com"; // an address a reply reaches
});
It sends through the same transport as everything else, obeys the outbound mail policy, and throws when the server
refuses, so the invite dialog can tell the inviter. A sender the host registered itself — AddEmailService<T>() —
still wins, whichever is registered first.
With mail to users registered, this sender is replaced: invitations are recorded in
its outbox, sent from UserMailOptions.Mail in one immediate attempt, marked bounced when they come back, and
shown in the invited person's mail history. AddThargaEmailInvitations() is then unnecessary.
Moving to 4.2
Coming from 3.x, follow Migrating to 4.0 in the implementation guide first.
OutboundMail,InboundMailandMailFetchResultgained trailing positional parameters —OutboundMail:Attachments,BodyHtml,UnsubscribeUrl;InboundMail:BouncedMessageId,BounceReason,SenderVerified,SenderDomain,Attachments,BodyHtml;MailFetchResult:Error. Recompile; code that deconstructs them positionally adds the new elements.InboundMail.BodyHtmlis raw. Sanitize it before showing it anywhere.InboundMail.HadAttachmentsis true when files were present, whether or not they were carried inAttachments; mail read from a mailbox now carries them.- Every mail read is DKIM-checked over DNS, so a host reading mail needs outbound DNS. Without it nothing is verified and nothing breaks.
- Dependencies: the package adds
DnsClientandAngleSharp, takes the ASP.NET Core framework reference privately (Data Protection and the unsubscribe route of mail to users), and no longer listsMicrosoft.Extensions.Logging.AbstractionsandMicrosoft.Extensions.Options, which the framework provides. - With
AddThargaUserMail, invitations go through mail to users — see above.
The postmaster
IPostmasterServicegained five members:ListAsync(PostmasterMailState, cursor, pageSize)(an overload, so code compiled againstListAsync(cursor, pageSize)keeps running),ArchiveAsync,RestoreAsync,GetBodyAsyncandOpenAttachmentAsync. A host implementing or decorating it adds them; one with its own REST surface adds the endpoints.GetCountAsync()andListAsync()leave archived mail out. Nothing is archived until someone archives it, so existing data counts as before.ITeamMailStoregained three default members —ListUnroutedAsync(PostmasterMailState, …),ArchiveUnroutedAsyncandRestoreUnroutedAsync. A host's own store keeps compiling, lists held mail through its old method, and refuses archiving withNotSupportedExceptionuntil it implements them. It should also leave archived mail (outcome"archived") out ofListUnroutedAsync(cursor, pageSize)andCountUnroutedAsync, and clear the outcome,HandledAtandHandledByinRouteAsync. The built-in store does all of it.TeamMailChangegainedArchivedandRestored, after the existing values; aswitchover it handles them.PostmasterMailSummarygainedArchivedAtandArchivedBy;ReceivedMailBodyandReceivedMailImageare new.InboundMailItem.HandledByon an archived mail is the archiver's name, where on a team's mail it is the subject of whoever recorded the outcome.- MongoDB: the
InboundMailcollection gains the indexTeamKey_Outcome_ReceivedAt(team, outcome, received time descending, mail id), created at start-up like the others. PostmasterViewlists cards rather than buttons, opens a mail when its card opens, and callsListAsync(state, …)andGetBodyAsync; a host whose tests click a subject button to open a mail clicks the card instead. It takesShowTitleand offersRefreshAsync(). The texts are inPostmasterViewText; translate the new keys —team.mailbox.postmaster.state.label("Which mail"),.state.held("Held"),.state.archived("Archived"),.noneArchived("No mail has been archived."),.archived.by("Archived by {0}"),.archived.when("Archived"),.archive("Archive"),.confirmArchive.title("Archive this mail?"),.confirmArchive.body("The mail goes to no team. It moves to the archived mail, where it can be restored until it is deleted with the rest."),.confirmArchive.accept("Archive"),.confirmArchive.cancel("Cancel"),.archived("The mail was archived."),.restore("Restore"),.restored("The mail is back among the held mail."),.noText("The mail has no text."),.contentUnavailable("What the mail said is no longer kept."),.manageNotPermitted("You may read this mail, but not route, archive or restore it.") and.noSuchTeam("No team has that key."). The files reuse theteam.support.attachments.*keys. No existing key was reworded.
Moving to 3.29
Important
The built-in SMTP invitation sender moved to Tharga.Team.Email. Setting o.Email no longer registers a
sender by itself. Install Tharga.Team.Email and call builder.Services.AddThargaEmailInvitations();. Until
you do, startup logs a warning and invitations are offered as a link to copy. A custom ITeamEmailSender needs
no change.
SmtpTeamEmailSenderis gone. It lived inTharga.Team.Serviceand usedSystem.Net.Mail; the replacement uses MailKit. Code that referenced the type directly registersAddThargaEmailInvitations()instead.- The mail transport moved from
Tharga.Team.Support.Emailto the namespaceTharga.Team.Email, andISupportMailClientis nowIMailClient. Support configuration (SupportCaseOptions.Email) is unchanged apart from the namespace ofMailOptions. UseSslon port 587 or 25 now means STARTTLS, required, as it did for the old invitation sender. The support transport used to try TLS from the first byte there and could not connect; elsewhere (465, 993) nothing changes.