Docs

X3D Mailbox

Free offline mailbox with item and money attachments plus /redeem codes, no database required — Mail and Safe tabs, any framework through adapter files.

Buy / Get freeConfig builder

Installation

  1. Unzip and rename the folder to x3d_mailbox (other scripts call the exports by this name)
  2. Drop it in resources/
  3. Add to server.cfg:
ensure x3d_mailbox

Done — data is stored in data/mailbox.json inside the resource. No SQL to run.

Players open the mailbox with /mailbox or the F7 key (Config.Key, empty = no keybind).

In the panel: two tabs, Mail (can expire) and Safe (a second channel with its own unread/Receive All count — handy for redeem-code rewards), claim one at a time or Receive All in a single click (scoped to whichever tab is open), delete a mail once its reward is claimed, see a countdown to expiry, redeem a code from a field right in the panel, and get pinged immediately if a new mail arrives while online.

No database, no framework, no license required. Works with ESX out of the box, and standalone with no framework installed.

Framework adapters — 4 adapters under addons/

Folder Purpose
addons/framework/ Framework.IsBusy() (client) — can the mailbox be opened right now (dead / pause menu); Framework.Identifier(src) / Framework.IsAdmin(src) (server) — the player's permanent key and admin check
addons/notify/ Notify(message, kind) (client) — notifications
addons/reward/ Reward.CanCarry / Reward.GiveItem / Reward.GiveMoney (server) — item/money system
addons/storage/ Storage.Get/Set/All/Flush (server) — where the data lives (see next section)

Every file defaults to working with ESX (it checks GetResourceState('es_extended') itself) and carries commented QBCore / ox_lib examples at the bottom to copy over. With no framework at all the mailbox still opens — it just can't deliver attachments until Reward.GiveItem/GiveMoney is wired up (a console print warns you if it isn't).

Storage — JSON by default, or switch to MySQL

Default: a single JSON file (Config.Storage.File, default data/mailbox.json), loaded once on resource start, written back every Config.Storage.FlushSeconds seconds (default 30) and on player drop / resource stop. An unreadable file is moved to data/mailbox.broken.json and a fresh one is started — the old data is never overwritten.

To switch to MySQL:

  1. Set Config.Storage.Driver = 'oxmysql' — the x3d_mailbox table is created automatically and no @oxmysql include is needed
  2. Restart the resource — nothing else to edit. (Only replace the four Storage.* functions in addons/storage/server.lua if you want a custom storage backend.)

Exports

-- target can be a server id (number) or an identifier (string)
exports['x3d_mailbox']:SendMail(target, {
    title   = 'Event prize',
    body    = 'Thanks for joining',
    sender  = 'Staff',            -- omit = uses the mailbox title
    box     = 'mail',             -- 'mail' (default, can expire) or 'safe' (the second channel)
    expires = os.time() + 86400,  -- unix timestamp, omit = never expires
    items   = { { item = 'bread', count = 5 } },
    money   = { { account = 'money', amount = 500 } },
})

exports['x3d_mailbox']:SendMailAll({ title = 'Announcement', body = 'Server restarting tonight' })

exports['x3d_mailbox']:GetMails(src)          --> { {id, title, body, sender, at, expires, read, claimed, box, items, money}, ... }
exports['x3d_mailbox']:GetUnread(src)         --> number (every box)
exports['x3d_mailbox']:GetUnread(src, 'safe') --> number (just one box — 'mail' or 'safe')

GetMails filters out expired mails automatically, and attaches a Thai label from Config.ItemLabels/Config.AccountLabels to the copies it returns (the stored originals are never touched). GetUnread only counts unread, unexpired mails — it returns a plain number, no labels.

SendMail also accepts the shape other mailboxes use — subject, cash/bank, items = {{ name, count }}, expiresInDays — so moving off an old mailbox is a rename of the export.

Events — server-internal (not a net event)

AddEventHandler('x3d_mailbox:onSend', function(identifier, mail) end)
AddEventHandler('x3d_mailbox:onClaim', function(src, mail, ok) end)

x3d_mailbox:onSend fires as soon as a mail is created (from an export or an admin command).

x3d_mailbox:onClaim always fires after a claim is attempted — mail.claimed is true either way (the flag is set before payout on purpose, to block a duplicate claim; see Safety below). Check the 3rd argument for the real result — ok is true only when every attachment was delivered without throwing:

AddEventHandler('x3d_mailbox:onClaim', function(src, mail, ok)
    if not ok then print(('mail %s claimed by %s but a reward failed'):format(mail.id, src)) end
end)

Admin commands

/mailsend <server id or identifier> <title> <body…> and /mailsendall <title> <body…> — always callable from the in-game server console; a player needs the Config.AdminAce ace (default x3d_mailbox.admin) to run them.

Safety properties, backed by tests

  • Can't be claimed twice — the claimed flag is written to storage before anything is paid out, with no yield between the check and the write; an InFlight[identifier] lock also blocks two simultaneous claim events.
  • A broken reward can't lock a player out — the payout is wrapped in pcall; the mail is still marked claimed even if it throws (a misspelled item key from /mailsend won't leave the player retrying forever). A partial delivery tells the player with its own message instead of staying silent, and never reverts what already landed (that would double-pay it).
  • Counts are always floored to whole numbers — fractional or negative attachments never reach the player's inventory.
  • The player key is always the permanent identifier, never the server id.
  • The Config.MaxMails cap never deletes an unclaimed, unexpired mail — it trims claimed or expired mails first, oldest first. A mail that's been read but not claimed (and hasn't expired) still has live attachments and is never deleted.
  • Receive All is one action, not N — it pays out through the exact same logic as a single claim, scoped to whichever box tab is open (Mail and Safe never claim each other's mail); a mail that doesn't fit is skipped and stays claimable, never paid twice.
  • Delete only works on a mail with nothing left to claim — claim it first (or it never had attachments) — the one exception is an expired mail, which is always deletable since it can no longer be claimed at all — and a client-supplied mail id is only ever looked up inside that player's own list.
  • /redeem and the redeem net event share the same throttle and lock — spamming the command can't outrun the rate limit, and the redeem function itself is single-flight per player so two calls can't race each other even if storage starts yielding (e.g. an oxmysql swap).
  • Config.Codes keys are normalised at boot — a key typed in the wrong case is fixed automatically instead of silently never matching what a player types (with a console warning naming what changed).
  • Rate-limited to 1 call per second per player per action (open/read/claim/claimAll/delete/redeem use separate buckets).
pip install lupa
python tools/lua-test/x3d_mailbox_check.py

Config (config.lua)

Setting Purpose
Config.Command / Config.Key Open command (/mailbox) and keybind (F7)
Config.MaxMails Mails kept per player
Config.AdminAce Ace required for /mailsend /mailsendall
Config.Storage.File / .FlushSeconds JSON file location and flush interval
Config.ItemLabels / Config.AccountLabels Thai display labels for the mailbox
Config.ItemImage Path to your inventory's item icons ({item} = item name), empty = no icon
Config.ExpiryWarnHours A mail with less than this many hours left shows a red countdown in the panel (default 24)
Config.CanCarryExport Your inventory's capacity-check export — empty = ESX canCarryItem. Seeing "bag full" on an empty bag? Set this — ESX's own weight numbers are stale when another inventory owns them.
  • Config.DefaultExpireDays — mail sent without an expiry expires after this many days (the safe never expires)
  • Config.CheckCarry — false = claim even with a full bag
  • Config.NotifyUnreadOnJoin — tell the player their unread count when they load in
  • Item names fall back to the framework's item label when Config.ItemLabels has no entry

Redeem codes

A player types /redeem <code>, or types the code into the field right in the mailbox panel, and the reward is delivered as mail — a full bag can never lose it, it simply waits in the mailbox.

Permanent codes live in config.lua:

Config.Codes = {
    WELCOME2026 = {
        maxUses       = 0,        -- 0 = unlimited server-wide
        oncePerPlayer = true,     -- one per player (default)
        title         = 'Welcome',
        items         = { { item = 'bread', count = 2 } },
        money         = { { account = 'bank', amount = 1000 } },
    },
    EVENTONLY = {
        maxUses = 100,            -- first 100 players only
        expires = 1790000000,     -- unix time the code stops working
        box     = 'safe',         -- deliver into the safe instead of mail (optional, default 'mail')
        money   = { { account = 'money', amount = 5000 } },
    },
}

Runtime codes from another resource (gone on restart):

exports['x3d_mailbox']:AddCode('EVENT2026', { maxUses = 50, money = { { account = 'bank', amount = 2000 } } })
exports['x3d_mailbox']:RemoveCode('EVENT2026')
exports['x3d_mailbox']:GetCodeUsage('EVENT2026')   --> { uses = 12, by = { ['license:xxx'] = true } }
exports['x3d_mailbox']:Redeem(src, 'EVENT2026')    --> ok, reason

AddEventHandler('x3d_mailbox:onRedeem', function(src, code, def) end)

Usage is stored beside the mail under the system key __redeem: it survives a restart, and the broadcast command never writes into it.

Support

Discord: discord.gg/x3d-developer · Web: x3d-developer.com

View product page