feat(dashboard): Mail page with every mail, translated text and deleted mail

New Mail page (/mail, characters_mail, under Moderation): every in-game
mail newest first and live, with sender and receiver linked to their
character and account, the attachment with its icon and name (waiting
or claimed), and the state (unread, read, deleted by the player with
the time). Filters: state, character (sent or received), account,
text. Open shows the body, the attachment's item ID, subkey and data.

Locale keys in mail (%[MissionEmail_12_subjectText], the game's sender
name) are shown as the client shows them, from locale.xml; the stored
text stays under "Stored text". LocaleText::Expand, unit tested.

The character Mailbox uses the same view. Staff see mail the player
deleted (marked) and a link to the character's mail on the Mail page;
the owner sees only what is still in the mailbox, without account IDs.
/api/characters/:id/mail keeps its old fields and adds the new ones.

Check: /mail with each filter, Open on game, staff and player mail,
a mission mail's subject translated; a character's Mailbox as staff
and as the owner after deleting a mail in game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:44:38 -05:00
parent 180585f9c6
commit 9a5a9a274f
11 changed files with 360 additions and 25 deletions

View File

@@ -41,6 +41,7 @@
#include <sstream>
#include <map>
#include "Locale.h"
#include "LocaleText.h"
#include <bcrypt/BCrypt.hpp>
#include <algorithm>
#include <ctime>
@@ -128,6 +129,56 @@ namespace {
});
}
std::string Localized(const std::string& text) {
return LocaleText::Expand(text, [](const std::string& key) -> const std::string& { return Locale::GetPhrase(key); });
}
// One mail row for the Mail page and mailboxes: the stored text, the text as the client shows it (locale keys
// translated), both ends with their accounts, the attachment and the state. The first fields are the ones the
// mailbox API always had.
nlohmann::json MailJson(const IMail::MailRecord& m) {
nlohmann::json attachment = nullptr;
if (m.attachmentCount > 0) {
attachment = { {"id", std::to_string(m.attachmentId)}, {"lot", m.attachmentLot}, {"count", m.attachmentCount},
{"subkey", std::to_string(m.attachmentSubkey)}, {"name", m.attachmentLot > 0 ? ClientAssets::ObjectName(m.attachmentLot).value_or("") : std::string{}},
{"claimed", m.attachmentLot <= 0}, {"config", m.attachmentConfig} };
}
const bool waiting = m.attachmentLot > 0 && m.attachmentCount > 0;
return {
{"id", std::to_string(m.id)},
{"sender", m.senderName},
{"subject", m.subject},
{"body", m.body},
{"time_sent", m.timeSent},
{"read", m.read},
{"attachment_lot", waiting ? m.attachmentLot : 0},
{"attachment_count", waiting ? m.attachmentCount : 0},
{"attachment_name", waiting ? ClientAssets::ObjectName(m.attachmentLot).value_or("") : std::string{}},
{"sender_id", std::to_string(m.senderId)},
{"sender_name", m.senderName},
{"sender_text", Localized(m.senderName)},
{"sender_kind", m.senderId != LWOOBJID_EMPTY ? "player" : m.senderName.starts_with("[GM] ") ? "staff" : "game"},
{"sender_account_id", m.senderAccountId},
{"receiver_id", std::to_string(m.receiverId)},
{"receiver_name", m.receiverName},
{"receiver_account_id", m.receiverAccountId},
{"subject_text", Localized(m.subject)},
{"body_text", Localized(m.body)},
{"attachment", attachment},
{"deleted_at", m.deletedAt}
};
}
IMail::eMailState MailState(const std::string& name) {
using eMailState = IMail::eMailState;
if (name == "unread") return eMailState::UNREAD;
if (name == "read") return eMailState::READ;
if (name == "attachment") return eMailState::ATTACHMENT;
if (name == "claimed") return eMailState::CLAIMED;
if (name == "deleted") return eMailState::DELETED;
return eMailState::ANY;
}
// Parse the numeric ID at the given path segment, replying 400 if it is not a valid number
template<typename T>
std::optional<T> RequireId(const HTTPContext& context, size_t segment, HTTPReply& reply) {
@@ -928,26 +979,30 @@ namespace {
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"}, {"warnings", check.warnings}, {"removed", removeIds.size()} });
});
Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0, "A character's mailbox (up to 100 most recent). With characters_mail, or the character's owner",
Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0,
"A character's mailbox (up to 100 most recent), newest first: [{id, sender, subject, body, time_sent, read, attachment_lot, attachment_count, "
"attachment_name, sender_id, sender_name, sender_text, sender_kind, sender_account_id, receiver_id, receiver_name, receiver_account_id, "
"subject_text, body_text (locale keys translated), attachment: {id, lot, count, subkey, name, claimed, config} or null, deleted_at}]. "
"With characters_mail, including mail the player deleted; the character's owner sees only what is still in the mailbox",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
if (!charId) return;
const auto info = Database::Get()->GetCharacterInfo(*charId);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
if (!Can(context, "characters_mail") && info->accountId != context.accountId) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
const bool staff = Can(context, "characters_mail");
if (!staff && info->accountId != context.accountId) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
// The mailbox: what this character received (the Mail page also has what it sent)
IMail::MailQuery query;
query.characterId = *charId;
query.includeDeleted = staff;
query.limit = 500;
nlohmann::json mail = nlohmann::json::array();
for (const auto& m : Database::Get()->GetMailForPlayer(*charId, 100)) {
mail.push_back({
{"id", std::to_string(m.id)},
{"sender", m.senderUsername},
{"subject", m.subject},
{"body", m.body},
{"time_sent", m.timeSent},
{"read", m.wasRead},
{"attachment_lot", m.itemLOT},
{"attachment_count", m.itemCount},
{"attachment_name", m.itemLOT > 0 ? ItemName(m.itemLOT) : ""}
});
for (const auto& m : Database::Get()->GetMailHistory(query)) {
if (m.receiverId != *charId || mail.size() >= 100) continue;
auto row = MailJson(m);
// Other players' accounts are for staff
if (!staff) row["sender_account_id"] = row["receiver_account_id"] = 0;
mail.push_back(std::move(row));
}
JsonReply(reply, eHTTPStatusCode::OK, mail);
});
@@ -1460,6 +1515,32 @@ namespace {
}
void RegisterMailRoutes() {
Route(eHTTPMethod::POST, "/api/tables/mail", Perm("characters_mail"),
"Every in-game mail for the Mail page (DataTables), newest first, deleted mail included; rows as /api/characters/:id/mail. "
"Body adds {state (unread, read, attachment, claimed, deleted), character (name or ID; sent or received), account (ID)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto request = ParseDataTablesRequest(context.body);
const auto body = ParseBody(context);
if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto text = [&body](const char* name) {
const auto it = body->find(name);
return it != body->end() && it->is_string() ? it->get<std::string>() : std::string{};
};
IMail::MailQuery query;
query.search = request->search;
query.state = MailState(text("state"));
// A name or ID that matches no character matches no mail
if (const auto character = text("character"); !character.empty()) query.characterId = ResolveCharacter(character).value_or(-1);
if (const auto account = text("account"); !account.empty()) query.accountId = GeneralUtils::TryParse<uint32_t>(account).value_or(UINT32_MAX);
query.offset = request->start;
query.limit = std::clamp<uint32_t>(request->length, 1, 500);
IMail::MailQuery all;
nlohmann::json rows = nlohmann::json::array();
for (const auto& m : Database::Get()->GetMailHistory(query)) rows.push_back(MailJson(m));
JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", Database::Get()->CountMailHistory(all)},
{"recordsFiltered", Database::Get()->CountMailHistory(query)}, {"data", rows} });
});
Route(eHTTPMethod::POST, "/api/mail/send", Perm("mail_send"), "Send in-game mail. Body: {recipient_id ('0' = everyone), subject, body, attachment_lot, attachment_count}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);

View File

@@ -441,6 +441,7 @@ void RegisterDashboardRoutes() {
});
SimplePage("/reports", Perm("reports_view"), "reports.jinja2", "reports", "Economy and world reports");
SimplePage("/pet_names", Perm("moderate_pet_names"), "pet_names.jinja2", "pet_names", "Pet names");
SimplePage("/mail", Perm("characters_mail"), "mail.jinja2", "mail", "Every in-game mail, deleted mail included");
SimplePage("/send_mail", Perm("mail_send"), "send_mail.jinja2", "send_mail", "Send mail");
SimplePage("/api_docs", 0, "api_docs.jinja2", "api_docs", "API documentation");
SimplePage("/webhooks", Perm("webhooks"), "webhooks.jinja2", "webhooks", "Outgoing webhooks for alerts");

View File

@@ -0,0 +1,28 @@
#pragma once
#include <functional>
#include <string>
// Text the game stores with locale keys in it, as the client shows it
namespace LocaleText {
/**
* Every %[KEY] in `text` replaced with the phrase `lookup` gives for KEY (the client's locale.xml); a key without a
* phrase stays as written. Mail from the game is stored this way ("%[MissionEmail_12_subjectText]"). Pure; unit tested.
*/
inline std::string Expand(const std::string& text, const std::function<const std::string&(const std::string&)>& lookup) {
std::string out;
size_t pos = 0;
while (pos < text.size()) {
const auto start = text.find("%[", pos);
const auto end = start == std::string::npos ? std::string::npos : text.find(']', start + 2);
if (end == std::string::npos) break;
out.append(text, pos, start - pos);
const auto& phrase = lookup(text.substr(start + 2, end - start - 2));
if (phrase.empty()) out.append(text, start, end - start + 1);
else out += phrase;
pos = end + 1;
}
out.append(text, pos, std::string::npos);
return out;
}
}

View File

@@ -0,0 +1,75 @@
/**
* How one in-game mail row is shown on the Mail page and in a character's mailbox: who sent it and to whom (linked to
* their characters and accounts), its subject and body as the client shows them (locale keys translated), the
* attachment with its icon, and its state (unread, read, attachment waiting or claimed, deleted by the player).
* Rows come from /api/tables/mail and /api/characters/:id/mail.
*/
(function () {
'use strict';
function account(id) {
return id ? ' <a class="small text-body-secondary" href="/accounts/' + esc(id) + '" title="Their account">(account)</a>' : '';
}
var MailView = {
// Sender: a player's character, the game, or a staff member ("[GM] name")
sender: function (m, withAccount) {
if (m.sender_id && m.sender_id !== '0') return fmt.character(m.sender_id, m.sender_name) + (withAccount ? account(m.sender_account_id) : '');
return '<span title="' + esc(m.sender_kind === 'staff' ? 'Sent from the dashboard' : 'Sent by the game') + '">' + esc(m.sender_text) + '</span> ' +
fmt.badge(m.sender_kind === 'staff' ? 'Staff' : 'Game', m.sender_kind === 'staff' ? 'warning' : 'secondary');
},
receiver: function (m, withAccount) {
return fmt.character(m.receiver_id, m.receiver_name) + (withAccount ? account(m.receiver_account_id) : '');
},
attachment: function (m, large) {
var a = m.attachment;
if (!a) return '<span class="text-body-secondary">-</span>';
var size = large ? 40 : 28;
var icon = a.lot ? '<img src="/api/icon/' + esc(a.lot) + '" width="' + size + '" height="' + size + '" class="me-1 align-middle" alt="" loading="lazy">' : '';
var name = a.lot ? esc(a.count) + '× ' + esc(a.name || ('LOT ' + a.lot)) + ' <span class="small text-body-secondary">(' + esc(a.lot) + ')</span>'
: esc(a.count) + ' item(s)';
var state = a.claimed ? ' ' + fmt.badge('Claimed', 'secondary') : ' ' + fmt.badge('Waiting', 'info');
return '<span class="text-nowrap">' + icon + name + '</span>' + state;
},
state: function (m) {
if (m.deleted_at) return '<span title="Deleted by the player ' + esc(fmt.unix(m.deleted_at)) + '">' + fmt.badge('Deleted', 'danger') + '</span>';
return m.read ? fmt.badge('Read', 'secondary') : fmt.badge('Unread', 'primary');
},
// The subject as shown, with the stored text on hover when it had locale keys
subject: function (m) {
var title = m.subject_text !== m.subject ? ' title="Stored as ' + esc(m.subject) + '"' : '';
return '<span' + title + '>' + esc(m.subject_text || '(no subject)') + '</span>';
},
// The whole mail, for a dialog or a mailbox list
card: function (m) {
var stored = m.subject_text !== m.subject || m.body_text !== m.body || m.sender_text !== m.sender_name;
var a = m.attachment;
var rows = [
['From', MailView.sender(m, true)],
['To', MailView.receiver(m, true)],
['Sent', esc(fmt.unix(m.time_sent))],
['State', m.deleted_at ? fmt.badge('Deleted by the player', 'danger') + ' ' + esc(fmt.unix(m.deleted_at)) + ' <span class="small text-body-secondary">(' + (m.read ? 'read' : 'never read') + ' before; the player no longer sees it)</span>'
: (m.read ? fmt.badge('Read', 'secondary') : fmt.badge('Unread', 'primary'))]
];
if (a) {
rows.push(['Attachment', MailView.attachment(m, true)]);
if (a.id && a.id !== '0') rows.push(['Item ID', '<code>' + esc(a.id) + '</code>' + (a.subkey && a.subkey !== '0' ? ' <span class="small text-body-secondary">subkey <code>' + esc(a.subkey) + '</code></span>' : '')]);
if (a.config) rows.push(['Item data', '<pre class="small mb-0">' + esc(a.config) + '</pre>']);
}
return '<div class="border rounded p-2 mb-2' + (m.deleted_at ? ' border-danger-subtle' : '') + '">' +
'<div class="d-flex justify-content-between gap-2"><strong>' + MailView.subject(m) + '</strong><span class="small text-body-secondary text-nowrap">#' + esc(m.id) + '</span></div>' +
'<dl class="row small mb-2 mt-1">' + rows.map(function (r) { return '<dt class="col-sm-3">' + r[0] + '</dt><dd class="col-sm-9 mb-1">' + r[1] + '</dd>'; }).join('') + '</dl>' +
'<div style="white-space: pre-wrap;">' + esc(m.body_text) + '</div>' +
(stored ? '<details class="small mt-2"><summary class="text-body-secondary">Stored text (locale keys)</summary><div class="font-monospace" style="white-space: pre-wrap;">' +
esc('From: ' + m.sender_name + '\nSubject: ' + m.subject + '\n\n' + m.body) + '</div></details>' : '') +
'</div>';
}
};
window.MailView = MailView;
})();

View File

@@ -0,0 +1,53 @@
/**
* The Mail page: every in-game mail (DataTables on /api/tables/mail), filtered by state, character or account.
* Opens with filters from the address: /mail#character=<id>, /mail#account=<id>, /mail#state=deleted.
*/
(function () {
'use strict';
var filters = { state: '', character: '', account: '' };
(location.hash.replace(/^#/, '').split('&')).forEach(function (part) {
var kv = part.split('=');
if (kv.length === 2 && kv[0] in filters) filters[kv[0]] = decodeURIComponent(kv[1]);
});
document.getElementById('mailState').value = filters.state;
document.getElementById('mailCharacter').value = filters.character;
document.getElementById('mailAccount').value = filters.account;
var rows = {};
var table = serverTable('#mailTable', '/api/tables/mail', [
{ data: 'time_sent', orderable: false, render: function (d) { return '<span class="text-nowrap">' + esc(fmt.unix(d)) + '</span>'; } },
{ data: 'sender_name', orderable: false, render: function (d, t, m) { return MailView.sender(m); } },
{ data: 'receiver_name', orderable: false, render: function (d, t, m) { return MailView.receiver(m); } },
// The subject opens the whole mail
{ data: 'subject', orderable: false, render: function (d, t, m) {
rows[m.id] = m;
return '<a href="#" data-mail="' + esc(m.id) + '" title="Open the mail">' + esc(m.subject_text || '(no subject)') + '</a>';
} },
{ data: 'attachment', orderable: false, render: function (d, t, m) { return MailView.attachment(m); } },
{ data: 'deleted_at', orderable: false, render: function (d, t, m) { return MailView.state(m); } }
], {
liveTable: 'mail',
dataTable: { language: { searchPlaceholder: 'Subject, text or name' } },
extra: function () { return { state: filters.state, character: filters.character, account: filters.account }; }
});
function setFilter(name, value) {
filters[name] = value;
var hash = Object.keys(filters).filter(function (k) { return filters[k]; }).map(function (k) { return k + '=' + encodeURIComponent(filters[k]); }).join('&');
try { history.replaceState(null, '', hash ? '#' + hash : location.pathname); } catch (e) {}
table.ajax.reload();
}
document.getElementById('mailState').addEventListener('change', function () { setFilter('state', this.value); });
document.getElementById('mailCharacter').addEventListener('change', function () { setFilter('character', this.value.trim()); });
document.getElementById('mailAccount').addEventListener('change', function () { setFilter('account', this.value.trim()); });
document.getElementById('mailTable').addEventListener('click', function (e) {
var open = e.target.closest('[data-mail]');
if (!open || !rows[open.dataset.mail]) return;
e.preventDefault();
document.getElementById('mailModalBody').innerHTML = MailView.card(rows[open.dataset.mail]);
bootstrap.Modal.getOrCreateInstance(document.getElementById('mailModal')).show();
});
})();

View File

@@ -679,15 +679,9 @@ function openMailbox() {
list.textContent = 'Loading…';
new bootstrap.Modal(document.getElementById('mailModal')).show();
api.get('/api/characters/' + characterId + '/mail').then(function (mail) {
if (!mail.length) { list.textContent = 'No mail'; return; }
list.innerHTML = mail.map(function (m) {
return '<div class="border-bottom pb-2 mb-2">' +
'<div class="d-flex justify-content-between"><strong>' + esc(m.subject) + '</strong><span class="small text-body-secondary">' + fmt.unix(m.time_sent) + '</span></div>' +
'<div class="small text-body-secondary">From ' + esc(m.sender) + (m.read ? '' : ' ' + fmt.badge('Unread', 'info')) + '</div>' +
'<div style="white-space: pre-wrap;">' + esc(m.body) + '</div>' +
(m.attachment_lot > 0 ? '<div class="small mt-1">Attachment: ' + esc(m.attachment_count) + 'x ' + esc(m.attachment_name || ('LOT ' + m.attachment_lot)) + '</div>' : '') +
'</div>';
}).join('');
// Staff also get mail the player deleted (marked) and a link to everything this character sent and received
var more = {% if can.characters_mail %}'<p class="small mb-2"><a href="/mail#character=' + characterId + '">All mail sent and received on the Mail page</a></p>'{% else %}''{% endif %};
list.innerHTML = more + (mail.length ? mail.map(MailView.card).join('') : '<p class="text-body-secondary mb-0">No mail</p>');
});
}
@@ -765,4 +759,5 @@ function rescueCharacter() {
<script src="/js/character-tools.js"></script>
<script src="/js/character-missions.js"></script>
<script src="/js/related.js"></script>
<script src="/js/mail-view.js"></script>
{% endblock %}

View File

@@ -31,8 +31,8 @@
</div>
{% endif %}
{% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "send_mail"] %}
{% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.mail_send %}
{% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "mail", "send_mail"] %}
{% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.characters_mail or can.mail_send %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-moderation" aria-expanded="{% if current_page in moderationPages %}true{% else %}false{% endif %}" aria-controls="nav-moderation">
Moderation<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -43,6 +43,7 @@
{% if can.chat_view %}<a href="/chat_log" class="list-group-item list-group-item-action{% if current_page == "chat_log" %} active{% endif %}">Chat Log</a>{% endif %}
{% if can.chat_filter_manage %}<a href="/chat_filter" class="list-group-item list-group-item-action{% if current_page == "chat_filter" %} active{% endif %}">Chat Filter</a>{% endif %}
{% if can.moderate_pet_names %}<a href="/pet_names" class="list-group-item list-group-item-action{% if current_page == "pet_names" %} active{% endif %}">Pet Names</a>{% endif %}
{% if can.characters_mail %}<a href="/mail" class="list-group-item list-group-item-action{% if current_page == "mail" %} active{% endif %}">Mail</a>{% endif %}
{% if can.mail_send %}<a href="/send_mail" class="list-group-item list-group-item-action{% if current_page == "send_mail" %} active{% endif %}">Send Mail</a>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,53 @@
{% extends "base.jinja2" %}
{% block title %}Mail - DarkflameServer{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<div>
<h2 class="mb-1">Mail</h2>
<p class="text-body-secondary mb-0">Every in-game mail, newest first. Mail a player deleted stays here, marked; they no longer see it.</p>
</div>
{% if can.mail_send %}<a class="btn btn-sm btn-primary" href="/send_mail">Send mail</a>{% endif %}
</div>
<div class="card">
<div class="card-header">
<div class="row g-2 align-items-end">
<div class="col-sm-4"><label class="form-label small mb-1" for="mailState">Show</label>
<select class="form-select form-select-sm" id="mailState">
<option value="">All mail</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
<option value="attachment">Attachment waiting</option>
<option value="claimed">Attachment claimed</option>
<option value="deleted">Deleted by the player</option>
</select></div>
<div class="col-sm-4"><label class="form-label small mb-1" for="mailCharacter">Character (sent or received)</label>
<input class="form-control form-control-sm" id="mailCharacter" placeholder="Name or ID"></div>
<div class="col-sm-4"><label class="form-label small mb-1" for="mailAccount">Account ID</label>
<input class="form-control form-control-sm" id="mailAccount" inputmode="numeric" placeholder="Any"></div>
</div>
</div>
<div class="card-body">
<table id="mailTable" class="table table-sm table-hover align-middle w-100 table-stack">
<thead><tr><th>Sent</th><th>From</th><th>To</th><th>Subject</th><th>Attachment</th><th>State</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="modal fade" id="mailModal" tabindex="-1" aria-labelledby="mailModalTitle">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="mailModalTitle">Mail</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
<div class="modal-body" id="mailModalBody"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/mail-view.js"></script>
<script src="/js/mail.js"></script>
{% endblock %}

View File

@@ -892,6 +892,23 @@ accounts share one, and an address an account hasn't used for `log_login_address
log pruning. A shared address can also be a family, a school or a public network, so treat it as a hint. Turn
`log_login_addresses` off if you don't want addresses kept (and mention it in your privacy notice if you do).
### Mail
The **Mail** page (GM 3+, `characters_mail`, under Moderation) lists every in-game mail, newest first and live: when it
was sent, the sender (a character, the game, or staff from the dashboard) and receiver linked to their character
pages, the subject, the attachment (icon, name, count; waiting or claimed) and the state (unread, read, deleted).
Click a subject for the whole mail: body, accounts, attachment item ID, subkey and item data. Filter by state, by a character (sent or
received) or an account ID; the search box matches the stored subject, body and names. `/mail#character=<id>`,
`#account=<id>` and `#state=deleted` open it filtered.
Mail from the game stores locale keys (`%[MissionEmail_12_subjectText]`); the dashboard shows them as the client does,
from the client's `locale/locale.xml` (needs `client_location`), with the stored text under **Stored text**.
When a player deletes a mail in game, the row is kept and marked deleted with the time (`mail.deleted_at`). The game never
shows it again (mailbox, unread count, claiming); staff see it on the Mail page and in the character's **Mailbox**. The
character's owner sees only what is still in their mailbox. Deleting a character still removes its mail. API:
`POST /api/tables/mail`, `GET /api/characters/:id/mail`.
### Chat filter
The **Chat Filter** page (GM 5+, `chat_filter_manage`, under Moderation) adds words on top of the chat filter's files

View File

@@ -31,6 +31,7 @@ set(DWEBTESTS_SOURCES
"ServerOperationsTests.cpp"
"PublicPagesTests.cpp"
"ModerationToolsTests.cpp"
"LocaleTextTests.cpp"
"ModeratorHelperTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ClaudeClient.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ModeratorPrompt.cpp"

View File

@@ -0,0 +1,30 @@
#include <gtest/gtest.h>
#include <map>
#include "LocaleText.h"
namespace {
const std::string EMPTY;
const std::map<std::string, std::string> PHRASES{ { "MissionEmail_1_subjectText", "Welcome!" }, { "MAIL_SYSTEM_NOTIFICATION", "LEGO Universe" } };
std::string Expand(const std::string& text) {
return LocaleText::Expand(text, [](const std::string& key) -> const std::string& {
const auto it = PHRASES.find(key);
return it == PHRASES.end() ? EMPTY : it->second;
});
}
}
TEST(LocaleTextTest, ReplacesKeys) {
EXPECT_EQ(Expand("%[MissionEmail_1_subjectText]"), "Welcome!");
EXPECT_EQ(Expand("From %[MAIL_SYSTEM_NOTIFICATION]: %[MissionEmail_1_subjectText] ok"), "From LEGO Universe: Welcome! ok");
}
TEST(LocaleTextTest, LeavesOtherTextAlone) {
EXPECT_EQ(Expand("Items returned to you"), "Items returned to you");
EXPECT_EQ(Expand("%[Unknown_key] and 100%"), "%[Unknown_key] and 100%");
EXPECT_EQ(Expand("%[not closed"), "%[not closed");
EXPECT_EQ(Expand("[x] %[]"), "[x] %[]");
EXPECT_EQ(Expand(""), "");
}