Offline delivery with escrowed item attachments.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayMail(); and it wins.
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).
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.