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 builderInstallation
- Unzip and rename the folder to
x3d_mailbox(other scripts call the exports by this name) - Drop it in
resources/ - 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:
- Set
Config.Storage.Driver = 'oxmysql'— thex3d_mailboxtable is created automatically and no@oxmysqlinclude is needed - Restart the resource — nothing else to edit. (Only replace the four
Storage.*functions inaddons/storage/server.luaif 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
claimedflag is written to storage before anything is paid out, with no yield between the check and the write; anInFlight[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/mailsendwon'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.MaxMailscap 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.
/redeemand 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.Codeskeys 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/redeemuse 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 bagConfig.NotifyUnreadOnJoin— tell the player their unread count when they load in- Item names fall back to the framework's item label when
Config.ItemLabelshas 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