The Roleplay Value of Physical Mail
In an age where most FiveM servers rely entirely on phone notifications and digital messaging, a physical mail and letter system adds a layer of immersion that stands out. Players can write handwritten-style letters to other characters, send packages containing items, receive official government notices, get bills from businesses, and even find mysterious anonymous notes. This creates roleplay opportunities that digital communication simply cannot replicate. A threatening letter left in someone's mailbox carries more weight than a text message. A love letter written by hand feels more personal than an email. Court summons delivered by mail feel more official. Beyond the roleplay value, a mail system creates a natural job opportunity for postal workers who pick up, sort, and deliver mail across the city. This tutorial covers building a complete mail system from database schema through delivery mechanics.
Database Schema for the Mail System
The database needs to handle letters, packages, mailbox assignments, and delivery states. Each mail item has a sender identifier, a recipient identifier, a type distinguishing letters from packages, a subject line, the body content for letters, attached item data for packages, timestamps for creation and delivery, and status flags tracking whether the mail is pending, in transit, delivered, or read. Mailboxes are tied to property addresses so players who own or rent housing have a personal mailbox. Public mailboxes placed around the city serve players without housing, requiring them to visit a post office to collect their mail. The schema should also support system-generated mail for automated notices like bills, court documents, job applications, and government announcements that scripts can trigger without a player sender.
-- SQL schema
CREATE TABLE IF NOT EXISTS mail_items (
id INT AUTO_INCREMENT PRIMARY KEY,
sender_id VARCHAR(64),
sender_name VARCHAR(64) DEFAULT 'Unknown',
recipient_id VARCHAR(64) NOT NULL,
mail_type ENUM('letter','package','notice') DEFAULT 'letter',
subject VARCHAR(128) NOT NULL,
body TEXT,
attachments JSON DEFAULT '[]',
postage_paid INT DEFAULT 0,
status ENUM('pending','transit','delivered','read','returned')
DEFAULT 'pending',
mailbox_id INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
delivered_at TIMESTAMP NULL,
read_at TIMESTAMP NULL,
INDEX idx_recipient (recipient_id, status),
INDEX idx_status (status)
);
CREATE TABLE IF NOT EXISTS mailboxes (
id INT AUTO_INCREMENT PRIMARY KEY,
owner_id VARCHAR(64),
property_id INT DEFAULT NULL,
location_x FLOAT NOT NULL,
location_y FLOAT NOT NULL,
location_z FLOAT NOT NULL,
box_type ENUM('residential','public','business') DEFAULT 'residential',
capacity INT DEFAULT 20,
UNIQUE KEY unique_owner (owner_id)
);
Writing and Sending Letters
The letter writing interface should feel like composing a real letter. Present players with a NUI that resembles a piece of paper with fields for the recipient's name, a subject line, and a free-text body. Use a handwriting-style font in the NUI to reinforce the physical letter aesthetic. The recipient field should search the character database by name rather than requiring players to know internal identifiers. When the player finishes writing and clicks send, the client sends the data to the server which validates the content, checks that the sender has enough money to cover postage, deducts the cost, and inserts the mail item into the database with a pending status. Impose reasonable limits on body length, perhaps 500 characters for standard letters and 1000 for premium postage, to prevent abuse while still allowing meaningful messages. The letter then enters the delivery pipeline and waits for a postal worker to process it or for the automatic delivery timer to expire.
-- server/mail.lua
local Config = {
postageCost = 50,
premiumPostage = 150,
maxBodyLength = 500,
premiumMaxBody = 1000,
autoDeliverTime = 900, -- 15 minutes if no postal worker
packagePostage = 200,
}
RegisterNetEvent('mail:send', function(data)
local src = source
local sender = GetPlayerIdentifier(src, 0)
-- Validate recipient exists
local recipient = MySQL.single.await([[
SELECT identifier, CONCAT(firstname,' ',lastname) as name
FROM characters WHERE CONCAT(firstname,' ',lastname) LIKE ?
LIMIT 1
]], {'%' .. data.recipientName .. '%'})
if not recipient then
TriggerClientEvent('mail:notify', src, 'Recipient not found.')
return
end
-- Check postage funds
local cost = data.premium and Config.premiumPostage or Config.postageCost
local maxLen = data.premium and Config.premiumMaxBody or Config.maxBodyLength
if #data.body > maxLen then
TriggerClientEvent('mail:notify', src, 'Letter exceeds maximum length.')
return
end
-- Deduct postage (framework-specific money removal)
local paid = exports['framework']:RemoveMoney(src, cost, 'cash')
if not paid then
TriggerClientEvent('mail:notify', src, 'Not enough cash for postage.')
return
end
-- Insert mail
MySQL.insert([[
INSERT INTO mail_items
(sender_id, sender_name, recipient_id, mail_type,
subject, body, postage_paid, status)
VALUES (?, ?, ?, 'letter', ?, ?, ?, 'pending')
]], {
sender, data.senderName, recipient.identifier,
data.subject, data.body, cost
})
TriggerClientEvent('mail:notify', src, 'Letter sent! Postage: $' .. cost)
end)
Package System with Item Attachments
Packages extend the mail system beyond text by allowing players to send physical items to each other. A player visits a post office, selects items from their inventory to include in a package, writes an optional note, and pays the package postage rate. The server removes the items from the sender's inventory and stores them as a JSON blob in the mail item's attachments column. When the recipient opens the package at their mailbox or the post office, the server deserializes the attachment data and adds each item back to the recipient's inventory, checking for available space first. If the recipient's inventory is full, the package remains in their mailbox until they make room. This creates a legitimate way to send items to offline players and opens up roleplay for care packages, evidence drops, and gift exchanges. Validate all item data on the server to prevent duplication exploits, and log every package transaction for admin review.
-- server/packages.lua
RegisterNetEvent('mail:sendPackage', function(data)
local src = source
local sender = GetPlayerIdentifier(src, 0)
-- Validate items exist in sender inventory
local validItems = {}
for _, item in ipairs(data.items) do
local hasItem = exports['ox_inventory']:Search(src, 'count', item.name)
if hasItem < item.count then
TriggerClientEvent('mail:notify', src,
'You do not have enough ' .. item.name)
return
end
table.insert(validItems, {
name = item.name,
count = item.count,
meta = item.metadata or {}
})
end
-- Remove items from sender
for _, item in ipairs(validItems) do
exports['ox_inventory']:RemoveItem(src, item.name, item.count)
end
-- Deduct postage
local paid = exports['framework']:RemoveMoney(
src, Config.packagePostage, 'cash')
if not paid then
-- Refund items if payment fails
for _, item in ipairs(validItems) do
exports['ox_inventory']:AddItem(src, item.name, item.count)
end
return
end
MySQL.insert([[
INSERT INTO mail_items
(sender_id, sender_name, recipient_id, mail_type,
subject, body, attachments, postage_paid, status)
VALUES (?, ?, ?, 'package', ?, ?, ?, ?, 'pending')
]], {
sender, data.senderName, data.recipientId,
data.subject or 'Package',
data.note or '',
json.encode(validItems),
Config.packagePostage
})
end)
Postal Worker Job and Delivery Routes
The postal worker job transforms mail delivery from a background process into active roleplay content. Players who clock in at the post office receive a delivery van and a route of pending mail items to deliver. The route is generated by querying all pending mail items, grouping them by delivery zone, and creating an ordered sequence of waypoints that minimizes travel distance. Each stop on the route places a blip on the map and a marker at the destination mailbox. When the postal worker reaches the marker and presses the interaction key, the mail item transitions from pending to delivered status and becomes available in the recipient's mailbox. Postal workers earn a base salary plus per-delivery bonuses, creating an honest income source. For servers without active postal workers, implement an automatic delivery timer that transitions pending mail to delivered status after a configurable delay, ensuring mail eventually arrives even when nobody is working the postal route.
Mailbox Interaction and Reading Interface
When a player approaches their mailbox and interacts with it, the server queries all mail items addressed to them with a delivered or read status. The NUI displays an inbox-style list showing each mail item with its sender name, subject, type icon, and timestamp. Unread letters appear with a highlight or badge indicator. Clicking a letter opens it in a full-view mode styled like a physical letter on paper, with the sender and date at the top, the body text in the center, and a close button. For packages, the view shows the attached note and a list of contained items with an "Open Package" button that transfers the items to the player's inventory. Mark letters as read when opened and remove them from the mailbox after a configurable retention period to prevent database bloat. Add a reply button that pre-fills the recipient field with the original sender's name, making back-and-forth correspondence convenient. For system notices, style them differently with an official letterhead to distinguish them from player-written mail.
System Notifications and Automated Mail
Beyond player-to-player communication, the mail system becomes a powerful tool for other server scripts to deliver immersive notifications. Instead of bland toast notifications, scripts can send formal mail. A court system sends official summons on government letterhead. A business sends invoices and payment reminders. A property management script sends rent due notices and eviction warnings. A faction system sends recruitment letters or mission briefings. Create a simple server export that any resource can call to queue a system mail item without needing to understand the mail system's internals. The export accepts a recipient identifier, subject, body, mail type, and optional attachments. This turns your mail system into a universal notification layer that feels integrated into the game world rather than being an intrusive UI overlay that breaks immersion.
-- server/exports.lua
-- Universal mail export for other resources
function SendSystemMail(recipientId, subject, body, mailType, attachments)
mailType = mailType or 'notice'
attachments = attachments or '[]'
if type(attachments) == 'table' then
attachments = json.encode(attachments)
end
MySQL.insert([[
INSERT INTO mail_items
(sender_id, sender_name, recipient_id, mail_type,
subject, body, attachments, status)
VALUES ('system', 'City of Los Santos', ?, ?, ?, ?, ?, 'pending')
]], {recipientId, mailType, subject, body, attachments})
end
exports('SendSystemMail', SendSystemMail)
-- Usage from another resource:
-- exports['mailsystem']:SendSystemMail(
-- playerId, 'Rent Due',
-- 'Your rent of $2,500 is due in 3 days.',
-- 'notice'
-- )