From 180585f9c6198829c7e7852bfe012b2e4242c7d9 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 00:41:44 -0500 Subject: [PATCH] feat(mail): mail history query for the dashboard IMail::GetMailHistory and CountMailHistory return mail rows as stored, deleted mail included, newest first, with the sender's and receiver's accounts. Filters: character (sent or received), account, text in the subject, body or a name (LIKE wildcards matched literally), state (unread, read, attachment waiting, claimed, deleted) and whether to include deleted mail. MySQL and SQLite share the SQL (MailSql.h). Check: parity test ParitySeeded.Mail (GetMailHistory). Co-Authored-By: Claude Opus 5.5 --- dDatabase/GameDatabase/ITables/IMail.h | 46 ++++++++++++++++ dDatabase/GameDatabase/ITables/MailSql.h | 42 +++++++++++++++ dDatabase/GameDatabase/MySQL/MySQLDatabase.h | 2 + dDatabase/GameDatabase/MySQL/Tables/Mail.cpp | 25 +++++++++ .../GameDatabase/SQLite/SQLiteDatabase.h | 2 + dDatabase/GameDatabase/SQLite/Tables/Mail.cpp | 24 +++++++++ .../GameDatabase/TestSQL/TestSQLDatabase.h | 2 + tests/dDatabaseTests/DatabaseParityTests.cpp | 53 +++++++++++++++++++ 8 files changed, 196 insertions(+) create mode 100644 dDatabase/GameDatabase/ITables/MailSql.h diff --git a/dDatabase/GameDatabase/ITables/IMail.h b/dDatabase/GameDatabase/ITables/IMail.h index 396ad82e3..6403518d0 100644 --- a/dDatabase/GameDatabase/ITables/IMail.h +++ b/dDatabase/GameDatabase/ITables/IMail.h @@ -3,7 +3,9 @@ #include #include +#include #include +#include #include "dCommonVars.h" #include "NiQuaternion.h" @@ -12,6 +14,50 @@ class IMail { public: + // A mail row as stored, for the dashboard (deleted mail included) + struct MailRecord { + uint64_t id{}; + LWOOBJID senderId{}; // 0: from the game or the dashboard + std::string senderName; + uint32_t senderAccountId{}; // 0 when the sender isn't a character (or it was deleted) + LWOOBJID receiverId{}; + std::string receiverName; + uint32_t receiverAccountId{}; + int64_t timeSent{}; + std::string subject; + std::string body; + LWOOBJID attachmentId{}; + LOT attachmentLot{}; // 0 once the attachment was claimed (attachmentCount stays) + LWOOBJID attachmentSubkey{}; + int32_t attachmentCount{}; + std::string attachmentConfig; + bool read{}; + int64_t deletedAt{}; // when the player deleted it; 0: not deleted + }; + + enum class eMailState : uint8_t { + ANY, + UNREAD, // not deleted + READ, // not deleted + ATTACHMENT, // an attachment waits to be claimed + CLAIMED, // the attachment was claimed + DELETED // deleted by the player + }; + + struct MailQuery { + LWOOBJID characterId{}; // sent or received by this character (0: anyone) + uint32_t accountId{}; // sent or received by a character of this account (0: anyone) + std::string search; // text in the subject, body or a name + eMailState state{ eMailState::ANY }; + bool includeDeleted{ true }; + uint32_t offset{}; + uint32_t limit{ 100 }; + }; + + // Mail rows matching the query, newest first + virtual std::vector GetMailHistory(const MailQuery& query) = 0; + virtual uint64_t CountMailHistory(const MailQuery& query) = 0; + // Insert a new mail into the database. virtual void InsertNewMail(const MailInfo& mail) = 0; diff --git a/dDatabase/GameDatabase/ITables/MailSql.h b/dDatabase/GameDatabase/ITables/MailSql.h new file mode 100644 index 000000000..6acc5b2bb --- /dev/null +++ b/dDatabase/GameDatabase/ITables/MailSql.h @@ -0,0 +1,42 @@ +#ifndef MAILSQL_H +#define MAILSQL_H + +#include + +#include "IMail.h" + +// The dashboard's mail history query, shared by the MySQL and SQLite backends (they differ only in how LIKE patterns are +// built). Every filter binds its parameters whether it is on or off, so one argument list fits every query: +// characterId x3, accountId x3, search, then the search pattern x4. +namespace MailSql { + inline std::string Select() { + return "SELECT m.id, m.sender_id, m.sender_name, m.receiver_id, m.receiver_name, m.time_sent, m.subject, m.body, m.attachment_id, " + "m.attachment_lot, m.attachment_subkey, m.attachment_count, m.attachment_config, m.was_read, m.deleted_at, " + "COALESCE(s.account_id, 0) AS sender_account, COALESCE(r.account_id, 0) AS receiver_account " + "FROM mail AS m LEFT JOIN charinfo AS s ON m.sender_id <> 0 AND s.id = m.sender_id LEFT JOIN charinfo AS r ON r.id = m.receiver_id"; + } + + // `like` is the pattern for "contains ?", e.g. CONCAT('%', ?, '%') or '%' || ? || '%' + inline std::string Where(const IMail::MailQuery& q, const std::string& like) { + using eMailState = IMail::eMailState; + std::string where = " WHERE 1 = 1"; + where += q.characterId == 0 ? " AND (? = 0 AND ? = 0 AND ? = 0)" : " AND (? <> 0 AND (m.sender_id = ? OR m.receiver_id = ?))"; + where += q.accountId == 0 ? " AND (? = 0 AND ? = 0 AND ? = 0)" + : " AND (? <> 0 AND (m.receiver_id IN (SELECT id FROM charinfo WHERE account_id = ?) OR m.sender_id IN (SELECT id FROM charinfo WHERE account_id = ?)))"; + const auto has = [&like](const char* column) { return std::string(column) + " LIKE " + like + " ESCAPE '!'"; }; + where += q.search.empty() ? " AND (? = '' AND ? = '' AND ? = '' AND ? = '' AND ? = '')" + : " AND (? <> '' AND (" + has("m.subject") + " OR " + has("m.body") + " OR " + has("m.sender_name") + " OR " + has("m.receiver_name") + "))"; + switch (q.state) { + case eMailState::UNREAD: where += " AND m.was_read = 0 AND m.deleted_at = 0"; break; + case eMailState::READ: where += " AND m.was_read <> 0 AND m.deleted_at = 0"; break; + case eMailState::ATTACHMENT: where += " AND m.attachment_lot > 0 AND m.attachment_count > 0"; break; + case eMailState::CLAIMED: where += " AND m.attachment_lot = 0 AND m.attachment_count > 0"; break; + case eMailState::DELETED: where += " AND m.deleted_at <> 0"; break; + case eMailState::ANY: break; + } + if (!q.includeDeleted) where += " AND m.deleted_at = 0"; + return where; + } +} + +#endif //!MAILSQL_H diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index 94c02df3e..7d848fb7f 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -363,6 +363,8 @@ public: void MarkMailRead(const uint64_t mailId) override; void DeleteMail(const uint64_t mailId) override; void ClaimMailItem(const uint64_t mailId) override; + std::vector GetMailHistory(const MailQuery& query) override; + uint64_t CountMailHistory(const MailQuery& query) override; void InsertSlashCommandUsage(const LWOOBJID characterId, const std::string_view command) override; void UpdateAccountUnmuteTime(const uint32_t accountId, const uint64_t timeToUnmute) override; void UpdateAccountBan(const uint32_t accountId, const bool banned) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp b/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp index a2745a2f3..5222c5b92 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/Mail.cpp @@ -1,4 +1,6 @@ #include "MySQLDatabase.h" +#include "GeneralUtils.h" +#include "MailSql.h" void MySQLDatabase::InsertNewMail(const MailInfo& mail) { @@ -90,3 +92,26 @@ void MySQLDatabase::DeleteMail(const uint64_t mailId) { // Kept for staff (the dashboard shows it as deleted); every read for the game skips it ExecuteUpdate("UPDATE mail SET deleted_at=? WHERE id=? AND deleted_at=0 LIMIT 1;", static_cast(time(NULL)), mailId); } + +std::vector MySQLDatabase::GetMailHistory(const MailQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto res = ExecuteSelect(MailSql::Select() + MailSql::Where(q, "CONCAT('%', ?, '%')") + " ORDER BY m.id DESC LIMIT ? OFFSET ?;", + q.characterId, q.characterId, q.characterId, q.accountId, q.accountId, q.accountId, q.search, pattern, pattern, pattern, pattern, q.limit, q.offset); + std::vector rows; + while (res->next()) { + rows.push_back({ res->getUInt64("id"), res->getInt64("sender_id"), std::string(res->getString("sender_name").c_str()), res->getUInt("sender_account"), + res->getInt64("receiver_id"), std::string(res->getString("receiver_name").c_str()), res->getUInt("receiver_account"), res->getInt64("time_sent"), + std::string(res->getString("subject").c_str()), std::string(res->getString("body").c_str()), res->getInt64("attachment_id"), + res->getInt("attachment_lot"), res->getInt64("attachment_subkey"), res->getInt("attachment_count"), + res->isNull("attachment_config") ? "" : std::string(res->getString("attachment_config").c_str()), res->getBoolean("was_read"), + res->getInt64("deleted_at") }); + } + return rows; +} + +uint64_t MySQLDatabase::CountMailHistory(const MailQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto res = ExecuteSelect("SELECT COUNT(*) AS count FROM mail AS m" + MailSql::Where(q, "CONCAT('%', ?, '%')") + ";", + q.characterId, q.characterId, q.characterId, q.accountId, q.accountId, q.accountId, q.search, pattern, pattern, pattern, pattern); + return res->next() ? res->getUInt64("count") : 0; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 650d33717..344fa1da9 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -347,6 +347,8 @@ public: void MarkMailRead(const uint64_t mailId) override; void DeleteMail(const uint64_t mailId) override; void ClaimMailItem(const uint64_t mailId) override; + std::vector GetMailHistory(const MailQuery& query) override; + uint64_t CountMailHistory(const MailQuery& query) override; void InsertSlashCommandUsage(const LWOOBJID characterId, const std::string_view command) override; void UpdateAccountUnmuteTime(const uint32_t accountId, const uint64_t timeToUnmute) override; void UpdateAccountBan(const uint32_t accountId, const bool banned) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp b/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp index a8e24ef8e..6de461a4e 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/Mail.cpp @@ -1,4 +1,6 @@ #include "SQLiteDatabase.h" +#include "GeneralUtils.h" +#include "MailSql.h" void SQLiteDatabase::InsertNewMail(const MailInfo& mail) { ExecuteInsert( @@ -89,3 +91,25 @@ void SQLiteDatabase::DeleteMail(const uint64_t mailId) { // Kept for staff (the dashboard shows it as deleted); every read for the game skips it ExecuteUpdate("UPDATE mail SET deleted_at=? WHERE id=? AND deleted_at=0;", static_cast(time(NULL)), mailId); } + +std::vector SQLiteDatabase::GetMailHistory(const MailQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto [_, res] = ExecuteSelect(MailSql::Select() + MailSql::Where(q, "'%' || ? || '%'") + " ORDER BY m.id DESC LIMIT ? OFFSET ?;", + q.characterId, q.characterId, q.characterId, q.accountId, q.accountId, q.accountId, q.search, pattern, pattern, pattern, pattern, q.limit, q.offset); + std::vector rows; + for (; !res.eof(); res.nextRow()) { + rows.push_back({ static_cast(res.getInt64Field("id")), res.getInt64Field("sender_id"), res.getStringField("sender_name"), + static_cast(res.getIntField("sender_account")), res.getInt64Field("receiver_id"), res.getStringField("receiver_name"), + static_cast(res.getIntField("receiver_account")), res.getInt64Field("time_sent"), res.getStringField("subject"), res.getStringField("body"), + res.getInt64Field("attachment_id"), res.getIntField("attachment_lot"), res.getInt64Field("attachment_subkey"), res.getIntField("attachment_count"), + res.fieldIsNull("attachment_config") ? "" : res.getStringField("attachment_config"), res.getIntField("was_read") != 0, res.getInt64Field("deleted_at") }); + } + return rows; +} + +uint64_t SQLiteDatabase::CountMailHistory(const MailQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto [_, res] = ExecuteSelect("SELECT COUNT(*) AS count FROM mail AS m" + MailSql::Where(q, "'%' || ? || '%'") + ";", + q.characterId, q.characterId, q.characterId, q.accountId, q.accountId, q.accountId, q.search, pattern, pattern, pattern, pattern); + return res.eof() ? 0 : static_cast(res.getInt64Field("count")); +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index a963c84ed..c7816ab68 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -75,6 +75,8 @@ class TestSQLDatabase : public GameDatabase { void MarkMailRead(const uint64_t mailId) override; void DeleteMail(const uint64_t mailId) override; void ClaimMailItem(const uint64_t mailId) override; + std::vector GetMailHistory(const MailQuery& query) override { return {}; } + uint64_t CountMailHistory(const MailQuery& query) override { return 0; } void InsertSlashCommandUsage(const LWOOBJID characterId, const std::string_view command) override; void UpdateAccountUnmuteTime(const uint32_t accountId, const uint64_t timeToUnmute) override; void UpdateAccountBan(const uint32_t accountId, const bool banned) override; diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index c9ab3168c..754764f8c 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -71,6 +71,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IDashboardAdmin::Webhook, id, name, url, form NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IDashboardAdmin::Totp, encryptedSecret, enabledAt, lastStep); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IDashboardStats::Snapshot, accounts, accountsMaxId, characters, pendingNames, properties, pendingProperties, playKeys, bugReports, unresolvedBugReports, petNames, pendingPetNames, activityLogMaxId, chatLogMaxId, commandLogMaxId, auditLogMaxId, mailMaxId, openEconomyFlags, economyFlagsMaxId); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IEconomyLedger::MailAttachment, mailId, receiverId, itemId, lot, count); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IMail::MailRecord, id, senderId, senderName, senderAccountId, receiverId, receiverName, receiverAccountId, timeSent, subject, body, attachmentId, attachmentLot, attachmentSubkey, attachmentCount, attachmentConfig, read, deletedAt); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IModeration::AppliedStrikeStep, step, count, time); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IModeration::PlayerReport, id, createdAt, kind, reporterId, reporterAccountId, objectId, objectLot, targetCharacterId, targetAccountId, propertyId, zoneId, instanceId, cloneId, body, status, handledBy, handledAt, resolution); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IModeration::ChatFilterWord, word, allowed, addedBy, addedAt); @@ -932,6 +933,58 @@ TEST_F(ParitySeeded, Mail) { EXPECT_EQ(rows, json::array()); return out; }); + // The dashboard still sees deleted mail, and can filter it + Both("GetMailHistory", [](GameDatabase& db) { + MailInfo game; + game.senderUsername = "%[MAIL_SYSTEM_NOTIFICATION]"; game.recipient = "Alice"; game.subject = "%[MissionEmail_1_subjectText]"; game.body = "100% done_"; + game.receiverId = CHAR_ALICE; game.itemLOT = 1727; game.itemCount = 2; + db.InsertNewMail(game); // id 3 + db.ClaimMailItem(3); + db.MarkMailRead(3); + using eMailState = IMail::eMailState; + const auto ids = [&db](IMail::MailQuery q) { + json out = json::array(); + for (const auto& row : db.GetMailHistory(q)) out.push_back(row.id); + out.push_back(db.CountMailHistory(q)); + return out; + }; + // Times are when the rows were written, which may differ by a second between the backends + json rows = db.GetMailHistory({}); + for (auto& row : rows) { row["timeSent"] = row["timeSent"] != 0; row["deletedAt"] = row["deletedAt"] != 0; } + json out = json::array(); + out.push_back(rows); + out.push_back(ids({ .characterId = CHAR_BOB })); + out.push_back(ids({ .characterId = CHAR_ALICE })); + out.push_back(ids({ .accountId = 2 })); + out.push_back(ids({ .accountId = 3 })); + out.push_back(ids({ .search = "gift" })); + out.push_back(ids({ .search = "100%" })); + out.push_back(ids({ .search = "1_0" })); // LIKE wildcards are matched as themselves + out.push_back(ids({ .state = eMailState::DELETED })); + out.push_back(ids({ .state = eMailState::CLAIMED })); + out.push_back(ids({ .state = eMailState::READ })); + out.push_back(ids({ .state = eMailState::UNREAD })); + out.push_back(ids({ .includeDeleted = false })); + out.push_back(ids({ .offset = 1, .limit = 1 })); + EXPECT_EQ(rows.size(), 3u); + EXPECT_EQ(rows[2]["senderAccountId"], 1); + EXPECT_EQ(rows[2]["receiverAccountId"], 2); + EXPECT_EQ(rows[0]["senderAccountId"], 0); + EXPECT_EQ(out[1], json({ 2, 1, 2 })); + EXPECT_EQ(out[2], json({ 3, 2, 1, 3 })); + EXPECT_EQ(out[3], json({ 2, 1, 2 })); + EXPECT_EQ(out[4], json({ 0 })); + EXPECT_EQ(out[5], json({ 1, 1 })); + EXPECT_EQ(out[6], json({ 3, 1 })); + EXPECT_EQ(out[7], json({ 0 })); + EXPECT_EQ(out[8], json({ 2, 1, 2 })); + EXPECT_EQ(out[9], json({ 3, 1 })); + EXPECT_EQ(out[10], json({ 3, 1 })); + EXPECT_EQ(out[11], json({ 0 })); + EXPECT_EQ(out[12], json({ 3, 1 })); + EXPECT_EQ(out[13], json({ 2, 3 })); + return out; + }); } TEST_F(ParitySeeded, Friends) {