pc-live/server/mail.lua
Bjoern Flessing be502d2834 IC-Computer: Schreibtisch, Fenster und Apps
Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine
Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen.
Andere Resources haengen sich als App ein.

Auf ESX umgestellt:

  - Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt.
    Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die
    Resource waere nie hochgekommen.
  - Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung
    und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen.
  - pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die
    Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei
    Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in
    der config.lua.

Mail ueberarbeitet:

  - Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute
    koennen dasselbe Firmenpostfach gleichzeitig offen haben.
  - Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes
    Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren
    trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe
    Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine
    Eigenschaft der Nachricht, sondern der Person.
  - Signaturen gehoeren ebenfalls zum Postfach.
  - Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher
    Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter
    niemand mehr aendern oder absagen.

Neue Apps: Webhosting, Bleeter und dessen Verwaltung.

Behoben:

  - prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen
    Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und
    kehrt nie zurueck. Ersetzt durch eigene Dialoge.
  - Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht,
    schreibt ins Leere und das Fenster bleibt leer.

Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine
eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis
zum Schreibtischsymbol.
2026-08-09 12:02:11 +00:00

373 lines
16 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ─────────────────────────────────────────────────────────────────────────────
-- Mail handlers
-- ─────────────────────────────────────────────────────────────────────────────
Mail = {}
-- Rate limiting: identifier -> { count, resetAt }
local _rateMap = {}
--- Darf diese Person das Postfach benutzen?
--- '' bedeutet "persoenliches Postfach" und ist immer erlaubt. Ohne diese
--- Pruefung koennte der Client jede beliebige Adresse mitschicken und damit in
--- fremden Ordnern lesen und schreiben.
---@return string die gepruefte Adresse, oder '' bei fehlendem Zugriff
local function CheckMailbox(identifier, address)
address = tostring(address or '')
if address == '' then return '' end
if GetResourceState('ic-mail') ~= 'started' then return '' end
local ok = exports['ic-mail']:CanAccessAddress(identifier, address)
return ok and address or ''
end
--- Postfachliste samt Signaturen an einen Spieler schicken.
--- Steht hier als Funktion, weil sie aus mehreren Ereignissen gebraucht wird
--- ein TriggerEvent auf das Netzwerkereignis waere kein Ersatz: dort ist
--- `source` nicht der Spieler, und der Aufruf liefe ins Leere.
local function SendMailboxes(src, identifier)
local mailboxes = {}
local personalAddr = ''
if GetResourceState('ic-mail') == 'started' then
personalAddr = exports['ic-mail']:GetPrimaryAddress(identifier) or ''
local addrs = exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}
table.sort(addrs, function(a, b)
if a.type == 'personal' and b.type ~= 'personal' then return true end
if a.type ~= 'personal' and b.type == 'personal' then return false end
return (a.address or '') < (b.address or '')
end)
mailboxes = addrs
end
local personalSig = ''
for _, mb in ipairs(mailboxes) do
if mb.address == personalAddr then personalSig = mb.signature or '' end
end
TriggerClientEvent('pc-live:client:sharedMailboxes', src, mailboxes, personalAddr, personalSig)
end
local function CheckRateLimit(identifier)
local now = os.time()
local entry = _rateMap[identifier]
if not entry or now >= entry.resetAt then
_rateMap[identifier] = { count = 1, resetAt = now + 60 }
return true
end
if entry.count >= Config.MailRateLimit then
return false
end
entry.count = entry.count + 1
return true
end
-- ── Fetch inbox ─────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getInbox', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
local rows = DB.GetInboxOnly(user.identifier)
local folders = DB.GetFolders(user.identifier, '')
TriggerClientEvent('pc-live:client:inboxData', src, rows, folders)
end)
-- ── Fetch sent ───────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getSent', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
local rows = DB.GetSent(user.identifier)
TriggerClientEvent('pc-live:client:sentData', src, rows)
end)
-- ── Fetch folder mails ───────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:getFolderMails', function(folderId, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(folderId) ~= "number" then return end
local box = CheckMailbox(user.identifier, address)
if not DB.GetFolder(folderId, user.identifier, box) then return end
local rows = DB.GetFolderMails(folderId, user.identifier)
TriggerClientEvent('pc-live:client:folderMailsData', src, folderId, rows)
end)
-- ── Ordner eines Postfachs holen ─────────────────────────────────────────────
-- Wird beim Wechsel des Postfachs gerufen: jedes Postfach hat eigene Ordner.
RegisterNetEvent('pc-live:server:getFolders', function(address)
local src = source
local user = ActiveSessions[src]
if not user then return end
local box = CheckMailbox(user.identifier, address)
TriggerClientEvent('pc-live:client:foldersData', src, box,
DB.GetFolders(user.identifier, box))
end)
-- ── Create folder ────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:createFolder', function(name, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
name = Utils.Sanitize(tostring(name or "")):sub(1, 60)
if #name < 1 then return end
local box = CheckMailbox(user.identifier, address)
local id = DB.CreateFolder(user.identifier, box, name)
TriggerClientEvent('pc-live:client:folderCreated', src, { id = id, name = name, address = box })
-- Im geteilten Postfach sehen die anderen den Ordner sofort.
if box ~= '' then
for playerSrc, session in pairs(ActiveSessions) do
if playerSrc ~= src and CheckMailbox(session.identifier, box) ~= '' then
TriggerClientEvent('pc-live:client:foldersData', playerSrc, box,
DB.GetFolders(session.identifier, box))
end
end
end
end)
-- ── Delete folder ────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:deleteFolder', function(folderId, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(folderId) ~= "number" then return end
local box = CheckMailbox(user.identifier, address)
if not DB.GetFolder(folderId, user.identifier, box) then return end
DB.DeleteFolder(folderId, user.identifier, box)
TriggerClientEvent('pc-live:client:folderDeleted', src, folderId, box)
if box ~= '' then
for playerSrc, session in pairs(ActiveSessions) do
if playerSrc ~= src and CheckMailbox(session.identifier, box) ~= '' then
TriggerClientEvent('pc-live:client:folderDeleted', playerSrc, folderId, box)
end
end
end
end)
-- ── Move mail to folder ──────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:moveMail', function(mailId, folderId, address)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(mailId) ~= "number" then return end
-- folderId may be nil (move to inbox)
if folderId ~= nil and type(folderId) ~= "number" then return end
local box = CheckMailbox(user.identifier, address)
if folderId ~= nil and not DB.GetFolder(folderId, user.identifier, box) then return end
DB.MoveMailToFolder(mailId, folderId, user.identifier, box)
TriggerClientEvent('pc-live:client:mailMoved', src, mailId, folderId)
end)
-- ── Read mail ───────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:readMail', function(mailId)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(mailId) ~= "number" then return end
local mail = DB.GetMail(mailId, user.identifier)
if not mail then return end
DB.MarkRead(mailId, user.identifier)
mail.is_read = 1
TriggerClientEvent('pc-live:client:mailContent', src, mail)
end)
-- ── Send mail ────────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:sendMail', function(payload)
local src = source
local user = ActiveSessions[src]
if not user then return end
-- Validate payload shape
if type(payload) ~= "table" then return end
local toId = Utils.Sanitize(tostring(payload.to or "")):sub(1, 120)
local subject = Utils.Sanitize(tostring(payload.subject or "")):sub(1, Config.MailMaxSubject)
local body = Utils.Sanitize(tostring(payload.body or "")):sub(1, Config.MailMaxBody)
if #toId < 3 or #subject < 1 or #body < 1 then
Bridge.Notify(src, "Invalid mail data.", "error")
return
end
-- Rate limit
if not CheckRateLimit(user.identifier) then
Bridge.Notify(src, "You are sending mails too fast. Please wait.", "error")
return
end
-- Absender-Adresse ermitteln (ic-mail optional)
local fromDisplay = user.identifier
if GetResourceState and GetResourceState('ic-mail') == 'started' then
local addr = exports['ic-mail']:GetPrimaryAddress(user.identifier)
if addr then fromDisplay = addr end
-- Versand aus geteiltem Postfach: Zugriff prüfen
local fromMailbox = Utils.Sanitize(tostring(payload.fromMailbox or '')):sub(1, 120)
if fromMailbox ~= '' and fromMailbox ~= fromDisplay then
local addrs = exports['ic-mail']:GetAddressesForIdentifier(user.identifier) or {}
for _, a in ipairs(addrs) do
if a.address == fromMailbox then
fromDisplay = fromMailbox
break
end
end
end
end
-- Empfänger auflösen: @-Adresse → Identifier-Liste, sonst direkt
local recipients = {}
if toId:find('@') and GetResourceState and GetResourceState('ic-mail') == 'started' then
local resolved = exports['ic-mail']:ResolveAddress(toId)
if resolved and resolved.identifiers and #resolved.identifiers > 0 then
recipients = resolved.identifiers
else
Bridge.Notify(src, "Empfänger-Adresse nicht gefunden.", "error")
return
end
else
recipients = { toId }
end
-- Eine Kennung fuer diese Zustellung. Alle Kopien tragen sie, damit das
-- Einsortieren in einen Ordner im geteilten Postfach fuer alle gilt.
local deliveryId = ('%08x-%04x-%04x-%04x-%012x'):format(
os.time(), math.random(0, 0xFFFF), math.random(0, 0xFFFF),
math.random(0, 0xFFFF), math.random(0, 0xFFFFFFFFFFFF))
-- Mail an alle Empfänger senden (pc_live_mail + ic-mail Dual-Write)
for _, recipId in ipairs(recipients) do
DB.SendMailExtended(recipId, fromDisplay, user.identifier, toId, subject, body, deliveryId)
-- ic-mail parallel benachrichtigen (zentrale Mailplattform)
pcall(function()
if GetResourceState('ic-mail') == 'started' then
exports['ic-mail']:SendSystemMail(recipId, subject, body)
end
end)
end
Bridge.Notify(src, "Mail gesendet.", "success")
TriggerClientEvent('pc-live:client:mailSent', src)
-- Online-Empfänger benachrichtigen
for playerSrc, session in pairs(ActiveSessions) do
for _, recipId in ipairs(recipients) do
if session.identifier == recipId then
TriggerClientEvent('pc-live:client:newMailNotify', playerSrc)
end
end
end
end)
-- ── Delete mail ─────────────────────────────────────────────────────────────
RegisterNetEvent('pc-live:server:deleteMail', function(mailId)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(mailId) ~= "number" then return end
DB.DeleteMail(mailId, user.identifier)
TriggerClientEvent('pc-live:client:mailDeleted', src, mailId)
end)
-- ── Geteilte Postfächer (ic-mail Integration) ────────────────────────────────
RegisterNetEvent('pc-live:server:getSharedMailboxes', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
SendMailboxes(src, user.identifier)
end)
-- ── Mail-Adresse prüfen (Ersteinrichtung) ────────────────────────────────────
RegisterNetEvent('pc-live:server:checkMailAddress', function()
local src = source
local user = ActiveSessions[src]
if not user then return end
if GetResourceState('ic-mail') ~= 'started' then return end
local existing = exports['ic-mail']:GetPrimaryAddress(user.identifier)
if existing then
-- Adresse vorhanden → Status senden, Postfach lädt der Client selbst nach
TriggerClientEvent('pc-live:client:mailAddressStatus', src, true, existing, nil)
return
end
local realms = exports['ic-mail']:GetRealms() or {}
local citizenRealms = {}
for _, r in ipairs(realms) do
if r.realm_type == 'citizen' and r.active then
table.insert(citizenRealms, r)
end
end
TriggerClientEvent('pc-live:client:mailAddressStatus', src, false, nil, citizenRealms)
end)
RegisterNetEvent('pc-live:server:createMailAddress', function(username)
local src = source
local user = ActiveSessions[src]
if not user then return end
if type(username) ~= 'string' or #username < 2 or #username > 40 then
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Benutzername muss 2-40 Zeichen haben')
return
end
username = username:lower():gsub('[^a-z0-9._%-]', ''):gsub('^%.+', ''):gsub('%.+$', '')
if #username < 2 then
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Ungültiger Benutzername')
return
end
if GetResourceState('ic-mail') ~= 'started' then
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Mail-System nicht verfügbar')
return
end
local existing = exports['ic-mail']:GetPrimaryAddress(user.identifier)
if existing then
TriggerClientEvent('pc-live:client:mailCreateResult', src, true, nil, existing)
return
end
local addr = exports['ic-mail']:EnsurePersonalAddress(user.identifier, username)
if addr then
TriggerClientEvent('pc-live:client:mailCreateResult', src, true, nil, addr)
else
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Benutzername bereits vergeben')
end
end)
-- ── Signaturen (ic-mail) ─────────────────────────────────────────────────────
-- Die Signatur gehoert zum Postfach: eine Firmenadresse traegt dieselbe
-- Fusszeile, egal wer sie gerade bedient. Geprueft wird in ic-mail.
RegisterNetEvent('pc-live:server:setSignature', function(address, signature)
local src = source
local user = ActiveSessions[src]
if not user then return end
if GetResourceState('ic-mail') ~= 'started' then
TriggerClientEvent('pc-live:client:signatureSaved', src, false, 'Mailsystem nicht verfügbar.')
return
end
local ok, err = exports['ic-mail']:SetSignature(
tostring(address or ''), user.identifier, tostring(signature or ''))
TriggerClientEvent('pc-live:client:signatureSaved', src, ok == true, err)
-- Postfachliste nachschieben, damit die neue Signatur sofort greift.
if ok then SendMailboxes(src, user.identifier) end
end)