Offline delivery with escrowed item attachments.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayMail(); and it wins.
IMailAttachmentStore game SPI
The item store Mail's attachments move through: escrowed OUT of the sender's bag on send, materialized into the recipient's bag on claim. Why this is a seam. Mail's core is message delivery; attachments are the optional half, so a text-only game installs Mail with no item piece at all (messages only — attachment slots are ignored on send and unclaimable on claim). The Hosting composition answers this seam from the Container engine's character bag when it is installed, and a game can plug its own store. Mail itself references no item piece.
-
bool Give(long characterId, byte[] item, int count)Adds count of item to a character's bag.
-
bool Take(long characterId, byte[] item, int count)Removes count of item from a character's bag.
-
bool TryGetSlot(long characterId, int slot, out byte[] item, out int count)Reads one occupied slot of a character's bag.
IMailPolicy game SPI
SPI the GAME implements to gate every player-to-player send — where block lists, cross-faction rules, and postage costs (via Economy) live. Called BEFORE a mail is stored; returning false refuses the send (the sender gets MailNotificationCodes.SendRejected). SYSTEM mail via SendSystem bypasses this policy.
public sealed class MyMailPolicy : IMailPolicy
{
public bool AllowSend(ISession sender, long fromCharacterId, long toCharacterId)
=> !_blocks.IsBlocked(toCharacterId, fromCharacterId); // e.g. honor the recipient's block list
} -
bool AllowSend(ISession sender, long fromCharacterId, long toCharacterId)Approve (or veto) a player-to-player send.
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IMailRecipientResolver provider
The identity seam Mail resolves recipients through, so the piece never hard-references the Crossplay.Characters spatial piece (it only needs a name↔id directory, not character data). Two directions, both pure identity: display name → recipient owner id (TryResolve) — works OFFLINE, which is the whole point of mail (the recipient need not be online to receive it);owner id → display name (DisplayName) — the "from" line stamped on a sent mail. A character-based game bridges this to its character store (the Hosting CharactersMailRecipientResolver, zero piece-to-piece reference — the EconomyMailDailyRewardGrantor / EconomyMarketWallet pattern); a Core-only game supplies its own name directory. With neither composed, the deny default (NullMailRecipientResolver) is used, and mail-by-name fails gracefully (send-by-id / system mail still work).
-
string DisplayName(long ownerId)The display name to stamp as a mail's "from" for the given sender owner id; returns a stable fallback (e.g. "#42") when the id has no known name.
-
bool TryResolve(string displayName, out long ownerId)Resolves a recipient's display name to the abstract owner id its mailbox is keyed by. Returns false when no such recipient exists — the send is refused (TargetNotFound), never a throw.
IMailStore provider
Mail persistence SPI (in-memory default; document-backed override across restarts). Register a durable implementation with services.AddSingleton<IMailStore, MyStore>() before AddCrossplayMail (a TryAdd seam).
-
void Delete(long mailId)Removes a mail.
-
MailRecord Get(long mailId)Fetches a mail by id, or null when absent.
-
IReadOnlyList<MailRecord> ListFor(long characterId)Lists a recipient's mailbox, newest first.
-
long NextId()Allocates the next unique mail id.
-
void Save(MailRecord mail)Inserts or replaces a mail (upsert keyed by Id).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IMailService
Server-side Mail API game code calls to send SYSTEM mail — rewards, compensation, event grants — with pre-built attachments and no inventory escrow (the items are minted straight onto the mail). Player-to- player sends come over the wire instead and go through IMailPolicy. Resolve it from DI: [Inject] IMailService mail. Mailboxes are keyed by the recipient's OWNER id (the selected character id when Characters is installed, otherwise the account id).
-
long SendSystem(long toCharacterId, string fromName, string subject, byte[] body, byte[][] attachmentItems = null, int[] attachmentCounts = null)Sends a mail with pre-built attachments (SYSTEM mail — no inventory escrow; bypasses IMailPolicy).
-
event Action<long, long, long> SentRaised after a PLAYER mail was delivered (stored in the recipient's box, attachments escrowed), never on a refused send and never for SendSystem: (fromOwnerId, toOwnerId, mailId). The hook for a "sent a mail to another player" quest or achievement counter - AllowSend runs before checks that can still refuse the send, so it cannot count. Defaulted to an event that never fires (rule 25), so an existing implementation or decorator keeps compiling; the shipped MailService raises it.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
MailOptions
Configurable mail rules (defaults here, never inline).
-
int MaxAttachments { get; set; }Most attachments a single mail may carry (a send exceeding this is refused).
-
int MaxBodyBytes { get; set; }Largest body accepted (bytes); longer bodies are truncated server-side.
-
int MaxMailsPerBox { get; set; }Cap on mails per mailbox; a send to a full box is refused with BoxFull.
-
int MaxSubjectLength { get; set; }Longest subject accepted; longer subjects are truncated server-side.
Wire messages 10
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
MailListRequest Client → server: request my mailbox headers. Carries no fields; answered by a MailboxList.
MailboxList Server → client: the requester's mailbox headers, newest first. Reply to a MailListRequest, and re-pushed after box-changing operations (e.g. delete).
MailOpenRequest Client → server: open one mail (marks it read; answered by a MailContent).
MailContent Server → client: the opened mail — opaque body + opaque attachment blobs for display. Reply to a MailOpenRequest. Attachment arrays are empty once the mail has been claimed.
MailClaimRequest Client → server: claim a mail's attachments into my inventory. Answered by a MailClaimResponse; requires an item store to be installed.
MailClaimResponse Server → client: the verdict for a MailClaimRequest. Partial delivery (bag filled mid-claim) leaves the remainder attached and reports failure with the relevant reason code.
MailSendRequest Client → server: send a mail. Attachments reference MY inventory slots — the server removes the items (escrow into the mail) so items are never duplicated. Answered by a MailSendResponse.
MailSendResponse Server → client: the verdict for a MailSendRequest.
MailDeleteRequest Client → server: delete a mail. Refused while attachments remain unclaimed (nothing is destroyed); on success the server re-pushes the mailbox list.
MailReceived Server → client: a new mail arrived. This is the live-delivery push for a recipient who is online at send time; an offline recipient's mail simply waits in the box for their next MailListRequest (offline delivery is the whole point of mail).
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IMailClient
The Mail piece, client side: list your mailbox, open a mail (opaque body — your game renders it), claim attachments into your inventory, send with attachments escrowed from your slots.
-
void RefreshMailbox()Requests the mailbox headers (raises MailboxReceived).
-
void Open(long mailId)Opens one mail (raises MailOpened).
-
void Claim(long mailId)Claims a mail's attachments into your inventory (raises ClaimResolved).
-
void Send(string toCharacterName, string subject, byte[] body, int[] attachmentSlots = null)Sends a mail; attachment slots reference YOUR inventory (escrowed by the server).
-
void Delete(long mailId)Deletes a mail (server refuses while unclaimed attachments remain).
-
event Action<MailHeader[]> MailboxReceived -
event Action<MailContent> MailOpened -
event Action<MailClaimResponse> ClaimResolved -
event Action<MailSendResponse> SendResolved -
event Action<MailHeader> MailArrived
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayMailView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.