ENH - complete Mail and Radio Sky UI

This commit is contained in:
DerEchteAlec
2026-08-16 22:51:36 +02:00
parent c5c9c33c2e
commit 0d351f9743
18 changed files with 2547 additions and 465 deletions
+3
View File
@@ -259,6 +259,9 @@ Config.Mail = {
MaxRecipients = 10,
PageSize = 50,
AuthAttemptsPerMinute = 5,
MailboxNameMaxLength = 50,
MaxMailboxes = 20,
MailboxRequestsPerMinute = 12,
DeleteBatchSize = 50,
DeleteRequestsPerMinute = 12,
}
+21 -3
View File
@@ -998,11 +998,12 @@ Locales["en"] = {
secondaryFrequency = "Secondary frequency", frequencyPlaceholder = "e.g. 120.5", optional = "Optional",
mhz = "MHz", volume = "Volume", connect = "Connect", disconnect = "Disconnect",
speaker = "Speaker", speakerDescription = "Play radio traffic through speaker mode",
providerFeatureUnavailable = "Not supported by this voice service", notSupported = "Not supported",
members = "Currently connected ({count})", noMembers = "No participants", history = "Recently connected",
noHistory = "No history", badge = "Service number", badgePlaceholder = "e.g. 231",
profileSaved = "Radio profile saved", otherSettings = "Other", autoRejoin = "Automatic rejoin",
displayName = "Radio display name", displayNamePlaceholder = "Leave empty to use your character name",
displayNameDescription = "This name is shown to other participants and in the built-in radio overlay.",
displayName = "Radio display name", displayNamePlaceholder = "Character name",
displayNameDescription = "Leave empty to use your character name. This name is shown to other participants and in the built-in radio overlay.",
displayNameNotAllowed = "Your current job or grade is not allowed to change the radio display name.",
autoRejoinDescription = "Reconnect after spawning or restarting the phone resource",
radioNotifications = "Radio notifications", notificationsDescription = "Show joins and leaves on your current channel",
@@ -1432,10 +1433,21 @@ Locales["en"] = {
localPart = "Email address", email = "Email", password = "Password", confirmPassword = "Confirm password",
accountEyebrow = "Your Sky Cloud account", emailPlaceholder = "name@ifruit.com", passwordPlaceholder = "Enter password",
passwordWarning = "Use an in-character password. Do not reuse a real-world password.",
mailboxes = "Mailboxes", inbox = "Inbox", sent = "Sent", drafts = "Drafts", trash = "Trash",
mailboxes = "Mailboxes", editMailboxes = "Edit", newMailbox = "New Mailbox",
mailboxName = "Name", mailboxNamePlaceholder = "Mailbox name", mailboxLocation = "Mailbox Location",
deleteMailbox = "Delete {mailbox}", deleteMailboxTitle = "Delete Mailbox?",
deleteMailboxBody = "Messages in {mailbox} will return to their original mailbox.",
moveToMailbox = "Move to Mailbox", movedToMailbox = "Moved to {mailbox}.",
inbox = "Inbox", sent = "Sent", drafts = "Drafts", trash = "Trash",
compose = "New Message", recipients = "To", recipientHint = "Separate up to 10 addresses with commas.",
recipientPlaceholder = "name@ifruit.com", subject = "Subject", subjectPlaceholder = "What's this about?",
body = "Message", messagePlaceholder = "Write a message...", search = "Search mail", messages = "messages",
toolbar = "Mail tools", filterTitle = "Filter", filteredBy = "Filtered by",
filterActiveLabel = "Filtered by {filter}",
filterStatus = "Status", filterAddressed = "Addressed", filterDirection = "Direction", filterOther = "Other",
filterUnreadLabel = "Unread", filterReadLabel = "Read", filterReceived = "Received",
filterSent = "Sent", filterToMe = "To Me", filterFromMe = "From Me",
filterToday = "Today", filterMultiple = "{count} Filters",
select = "Select", selectedCount = "{count} Selected", deleteSelected = "Delete Selected",
selectionLimit = "You can select up to {count} messages at once.",
noMessageContent = "No message content", noMail = "No Mail",
@@ -1457,6 +1469,12 @@ Locales["en"] = {
recipient_not_found = "One or more recipients do not exist.", invalid_draft = "This draft contains invalid content.",
not_authenticated = "Your mail session has ended. Sign in again.",
request_failed = "Mail is temporarily unavailable.", invalid_request = "The mail request was invalid.",
invalid_mailbox = "Enter a mailbox name with up to 50 characters.",
mailbox_exists = "A mailbox with that name already exists.",
mailbox_limit = "You have reached the mailbox limit.",
mailbox_not_found = "That mailbox is no longer available.",
message_not_found = "That message is no longer available.",
invalid_filter = "That mail filter is not available.",
default = "The mail request failed.",
},
},
+4
View File
@@ -43,6 +43,9 @@ local server_callbacks = {
"mail:login",
"mail:logout",
"mail:counts",
"mail:mailboxes",
"mail:create-mailbox",
"mail:delete-mailbox",
"mail:list",
"mail:get",
"mail:get-draft",
@@ -51,6 +54,7 @@ local server_callbacks = {
"mail:delete-many",
"mail:send",
"mail:set-read",
"mail:move",
"mail:trash",
"mail:restore",
"mail:delete-forever",
+26
View File
@@ -57,6 +57,30 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_mailboxes",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "name", type = "VARCHAR(50) NOT NULL" },
{ name = "sort_order", type = "SMALLINT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_mailbox_name", columns = "(`account_id`, `name`)" },
},
indexes = {
{ name = "idx_sky_phone_mailboxes", columns = "(`account_id`, `sort_order`, `id`)" },
},
foreignKeys = {
{
column = "account_id",
references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE",
},
},
tableOptions = "ENGINE=InnoDB",
},
{
name = "sky_phone_mail_entries",
columns = {
@@ -64,6 +88,7 @@ local schema = {
{ name = "message_id", type = "CHAR(36) NOT NULL" },
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "folder", type = "ENUM('inbox', 'sent') NOT NULL" },
{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "trashed_at", type = "DATETIME NULL" },
},
@@ -76,6 +101,7 @@ local schema = {
},
indexes = {
{ name = "idx_sky_phone_mailbox", columns = "(`account_id`, `folder`, `trashed_at`, `id`)" },
{ name = "idx_sky_phone_custom_mailbox", columns = "(`account_id`, `mailbox_id`, `trashed_at`, `id`)" },
},
foreignKeys = {
{
+298 -8
View File
@@ -67,6 +67,60 @@ local function validate_text(value, maximum)
return length and length <= maximum
end
local function normalize_mailbox_name(value)
local name = trim(value)
local length = text_length(name)
if not length or length < 1 or length > Config.Mail.MailboxNameMaxLength or name:find("%c") then
return nil
end
return name
end
local function normalize_numeric_id(value)
local id = tonumber(value)
if not id or id ~= id or id < 1 or id > MAX_SAFE_INTEGER or id ~= math.floor(id) then
return nil
end
return id
end
local function mailbox_id_from_folder(folder)
if type(folder) ~= "string" then
return nil
end
local value = folder:match("^mailbox:(%d+)$")
return value and normalize_numeric_id(value) or nil
end
local function normalize_list_filters(value)
if value == nil then
return {
address = "all",
direction = "all",
read = "all",
today = false,
}
end
if type(value) ~= "table"
or (value.address ~= "all" and value.address ~= "from-me" and value.address ~= "to-me")
or (value.direction ~= "all" and value.direction ~= "inbox" and value.direction ~= "sent")
or (value.read ~= "all" and value.read ~= "read" and value.read ~= "unread")
or type(value.today) ~= "boolean"
then
return nil
end
return {
address = value.address,
direction = value.direction,
read = value.read,
today = value.today,
}
end
local function normalize_recipients(values, strict)
if type(values) ~= "table" or #values > Config.Mail.MaxRecipients then
return nil
@@ -192,9 +246,9 @@ end
local function get_counts(account_id)
local rows = Bridge.Database.Query([[
SELECT
SUM(CASE WHEN `folder` = 'inbox' AND `trashed_at` IS NULL AND `read_at` IS NULL THEN 1 ELSE 0 END) AS unread,
SUM(CASE WHEN `folder` = 'inbox' AND `trashed_at` IS NULL THEN 1 ELSE 0 END) AS inbox,
SUM(CASE WHEN `folder` = 'sent' AND `trashed_at` IS NULL THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN `folder` = 'inbox' AND `mailbox_id` IS NULL AND `trashed_at` IS NULL AND `read_at` IS NULL THEN 1 ELSE 0 END) AS unread,
SUM(CASE WHEN `folder` = 'inbox' AND `mailbox_id` IS NULL AND `trashed_at` IS NULL THEN 1 ELSE 0 END) AS inbox,
SUM(CASE WHEN `folder` = 'sent' AND `mailbox_id` IS NULL AND `trashed_at` IS NULL THEN 1 ELSE 0 END) AS sent,
SUM(CASE WHEN `trashed_at` IS NOT NULL THEN 1 ELSE 0 END) AS trash
FROM `sky_phone_mail_entries`
WHERE `account_id` = ?
@@ -214,6 +268,38 @@ local function get_counts(account_id)
}
end
local function get_mailboxes(account_id)
local rows = Bridge.Database.Query([[
SELECT mailbox.`id`, mailbox.`name`, mailbox.`sort_order`, COUNT(entry.`id`) AS `count`
FROM `sky_phone_mailboxes` mailbox
LEFT JOIN `sky_phone_mail_entries` entry
ON entry.`mailbox_id` = mailbox.`id`
AND entry.`account_id` = mailbox.`account_id`
AND entry.`trashed_at` IS NULL
WHERE mailbox.`account_id` = ?
GROUP BY mailbox.`id`, mailbox.`name`, mailbox.`sort_order`
ORDER BY mailbox.`sort_order` ASC, mailbox.`id` ASC
]], { account_id })
for index = 1, #rows do
rows[index].id = tonumber(rows[index].id)
rows[index].sort_order = tonumber(rows[index].sort_order) or 0
rows[index].count = tonumber(rows[index].count) or 0
end
return rows
end
local function find_owned_mailbox(account_id, mailbox_id)
local rows = Bridge.Database.Query([[
SELECT `id`, `name`, `sort_order`
FROM `sky_phone_mailboxes`
WHERE `id` = ? AND `account_id` = ?
LIMIT 1
]], { mailbox_id, account_id })
return rows[1]
end
local function broadcast_mailbox_changed(account_id, counts)
notify_account(account_id, "sky_phone:mail:changed", {
counts = counts or get_counts(account_id),
@@ -229,6 +315,111 @@ Bridge.Callbacks.Register("sky_phone:mail:counts", function(source)
return { success = true, data = get_counts(session.id) }
end)
Bridge.Callbacks.Register("sky_phone:mail:mailboxes", function(source)
local session, error_response = require_session(source)
if not session then
return error_response
end
return { success = true, data = { mailboxes = get_mailboxes(session.id) } }
end)
Bridge.Callbacks.Register("sky_phone:mail:create-mailbox", function(source, data)
local session, error_response = require_session(source)
if not session then
return error_response
end
if not SkyPhone.AllowOperation(source, "mail_manage_mailboxes", Config.Mail.MailboxRequestsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
data = validate_payload(source, "create-mailbox", data)
if not data then
return { success = false, error = "invalid_request" }
end
local name = normalize_mailbox_name(data.name)
if not name then
return { success = false, error = "invalid_mailbox" }
end
local rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count`,
SUM(CASE WHEN LOWER(`name`) = LOWER(?) THEN 1 ELSE 0 END) AS `duplicate_count`,
COALESCE(MAX(`sort_order`), -1) AS `maximum_sort_order`
FROM `sky_phone_mailboxes`
WHERE `account_id` = ?
]], { name, session.id })
local summary = rows[1] or {}
if (tonumber(summary.duplicate_count) or 0) > 0 then
return { success = false, error = "mailbox_exists" }
end
if (tonumber(summary.count) or 0) >= Config.Mail.MaxMailboxes then
return { success = false, error = "mailbox_limit" }
end
local sort_order = math.min(65535, (tonumber(summary.maximum_sort_order) or -1) + 1)
Bridge.Database.Query([[
INSERT INTO `sky_phone_mailboxes` (`account_id`, `name`, `sort_order`)
VALUES (?, ?, ?)
]], { session.id, name, sort_order })
local created = Bridge.Database.Query([[
SELECT `id`, `name`, `sort_order`, 0 AS `count`
FROM `sky_phone_mailboxes`
WHERE `account_id` = ? AND LOWER(`name`) = LOWER(?)
LIMIT 1
]], { session.id, name })
if not created[1] then
error("[sky_phone] Created mail mailbox could not be loaded.")
end
created[1].id = tonumber(created[1].id)
created[1].sort_order = tonumber(created[1].sort_order) or 0
created[1].count = 0
broadcast_mailbox_changed(session.id)
return { success = true, data = created[1] }
end)
Bridge.Callbacks.Register("sky_phone:mail:delete-mailbox", function(source, data)
local session, error_response = require_session(source)
if not session then
return error_response
end
if not SkyPhone.AllowOperation(source, "mail_manage_mailboxes", Config.Mail.MailboxRequestsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
data = validate_payload(source, "delete-mailbox", data)
if not data then
return { success = false, error = "invalid_request" }
end
local mailbox_id = normalize_numeric_id(data.id)
if not mailbox_id or not find_owned_mailbox(session.id, mailbox_id) then
return { success = false, error = "mailbox_not_found" }
end
if not Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_mail_entries` SET `mailbox_id` = NULL
WHERE `account_id` = ? AND `mailbox_id` = ?
]],
params = { session.id, mailbox_id },
},
{
query = "DELETE FROM `sky_phone_mailboxes` WHERE `id` = ? AND `account_id` = ?",
params = { mailbox_id, session.id },
},
}) then
return { success = false, error = "request_failed" }
end
broadcast_mailbox_changed(session.id)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:mail:list", function(source, data)
local session, error_response = require_session(source)
if not session then
@@ -241,9 +432,15 @@ Bridge.Callbacks.Register("sky_phone:mail:list", function(source, data)
end
local folder = data.folder
if folder ~= "inbox" and folder ~= "sent" and folder ~= "drafts" and folder ~= "trash" then
local custom_mailbox_id = mailbox_id_from_folder(folder)
if folder ~= "inbox" and folder ~= "sent" and folder ~= "drafts" and folder ~= "trash"
and not custom_mailbox_id
then
return { success = false, error = "invalid_folder" }
end
if custom_mailbox_id and not find_owned_mailbox(session.id, custom_mailbox_id) then
return { success = false, error = "mailbox_not_found" }
end
local search = trim(data.search) or ""
if not validate_text(search, 120) then
@@ -251,9 +448,16 @@ Bridge.Callbacks.Register("sky_phone:mail:list", function(source, data)
end
local offset = math.max(0, math.min(100000, math.floor(tonumber(data.offset) or 0)))
local limit = Config.Mail.PageSize + 1
local filters = normalize_list_filters(data.filters)
if not filters then
return { success = false, error = "invalid_filter" }
end
local rows
if folder == "drafts" then
if filters.address ~= "all" or filters.direction ~= "all" or filters.read ~= "all" or filters.today then
return { success = false, error = "invalid_filter" }
end
local pattern = "%" .. search .. "%"
rows = Bridge.Database.Query([[
SELECT `id`, `recipients`, `subject`, LEFT(`body`, 180) AS `preview`, `updated_at` AS `created_at`
@@ -265,13 +469,35 @@ Bridge.Callbacks.Register("sky_phone:mail:list", function(source, data)
else
local conditions
local values = { session.id }
if folder == "trash" then
if custom_mailbox_id then
conditions = "e.`mailbox_id` = ? AND e.`trashed_at` IS NULL"
values[#values + 1] = custom_mailbox_id
elseif folder == "trash" then
conditions = "e.`trashed_at` IS NOT NULL"
else
conditions = "e.`folder` = ? AND e.`trashed_at` IS NULL"
conditions = "e.`folder` = ? AND e.`mailbox_id` IS NULL AND e.`trashed_at` IS NULL"
values[#values + 1] = folder
end
if filters.read == "unread" then
conditions = conditions .. " AND e.`folder` = 'inbox' AND e.`read_at` IS NULL"
elseif filters.read == "read" then
conditions = conditions .. " AND (e.`folder` = 'sent' OR e.`read_at` IS NOT NULL)"
end
if filters.direction ~= "all" then
conditions = conditions .. " AND e.`folder` = ?"
values[#values + 1] = filters.direction
end
if filters.address == "to-me" then
conditions = conditions .. " AND m.`recipients` LIKE ?"
values[#values + 1] = "%\"" .. session.email .. "\"%"
elseif filters.address == "from-me" then
conditions = conditions .. " AND m.`sender_account_id` = e.`account_id`"
end
if filters.today then
conditions = conditions .. " AND m.`created_at` >= CURRENT_DATE() AND m.`created_at` < CURRENT_DATE() + INTERVAL 1 DAY"
end
local pattern = "%" .. search .. "%"
values[#values + 1] = search
values[#values + 1] = pattern
@@ -566,6 +792,57 @@ Bridge.Callbacks.Register("sky_phone:mail:set-read", function(source, data)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:mail:move", function(source, data)
local session, error_response = require_session(source)
if not session then
return error_response
end
if not SkyPhone.AllowOperation(source, "mail_move", Config.Mail.MailboxRequestsPerMinute * 3, 60) then
return { success = false, error = "rate_limited" }
end
data = validate_payload(source, "move", data)
if not data then
return { success = false, error = "invalid_request" }
end
local entry_id = normalize_numeric_id(data.id)
local mailbox_id
if data.mailboxId ~= 0 then
mailbox_id = normalize_numeric_id(data.mailboxId)
end
if not entry_id then
return { success = false, error = "message_not_found" }
end
if data.mailboxId ~= 0 and (not mailbox_id or not find_owned_mailbox(session.id, mailbox_id)) then
return { success = false, error = "mailbox_not_found" }
end
local entries = Bridge.Database.Query([[
SELECT `id`
FROM `sky_phone_mail_entries`
WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL
LIMIT 1
]], { entry_id, session.id })
if not entries[1] then
return { success = false, error = "message_not_found" }
end
if mailbox_id then
Bridge.Database.Query([[
UPDATE `sky_phone_mail_entries` SET `mailbox_id` = ?
WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL
]], { mailbox_id, entry_id, session.id })
else
Bridge.Database.Query([[
UPDATE `sky_phone_mail_entries` SET `mailbox_id` = NULL
WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL
]], { entry_id, session.id })
end
broadcast_mailbox_changed(session.id)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:mail:trash", function(source, data)
local session, error_response = require_session(source)
if not session then
@@ -658,9 +935,13 @@ Bridge.Callbacks.Register("sky_phone:mail:delete-many", function(source, data)
end
local folder = data.folder
if type(folder) ~= "string" or not DELETE_FOLDERS[folder] then
local custom_mailbox_id = mailbox_id_from_folder(folder)
if type(folder) ~= "string" or (not DELETE_FOLDERS[folder] and not custom_mailbox_id) then
return { success = false, error = "invalid_folder" }
end
if custom_mailbox_id and not find_owned_mailbox(session.id, custom_mailbox_id) then
return { success = false, error = "mailbox_not_found" }
end
local ids = normalize_delete_ids(folder, data.ids)
if not ids then
@@ -680,7 +961,16 @@ Bridge.Callbacks.Register("sky_phone:mail:delete-many", function(source, data)
append_values(parameters, ids)
Bridge.Database.Query(([[
UPDATE `sky_phone_mail_entries` SET `trashed_at` = CURRENT_TIMESTAMP
WHERE `account_id` = ? AND `folder` = ? AND `trashed_at` IS NULL
WHERE `account_id` = ? AND `folder` = ? AND `mailbox_id` IS NULL
AND `trashed_at` IS NULL
AND `id` IN (%s)
]]):format(placeholders), parameters)
elseif custom_mailbox_id then
local parameters = { session.id, custom_mailbox_id }
append_values(parameters, ids)
Bridge.Database.Query(([[
UPDATE `sky_phone_mail_entries` SET `trashed_at` = CURRENT_TIMESTAMP
WHERE `account_id` = ? AND `mailbox_id` = ? AND `trashed_at` IS NULL
AND `id` IN (%s)
]]):format(placeholders), parameters)
else
+14
View File
@@ -23,16 +23,30 @@ CREATE TABLE IF NOT EXISTS `sky_phone_mail_messages` (
FOREIGN KEY (`sender_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_mailboxes` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` BIGINT UNSIGNED NOT NULL,
`name` VARCHAR(50) NOT NULL,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_mailbox_name` (`account_id`, `name`),
KEY `idx_sky_phone_mailboxes` (`account_id`, `sort_order`, `id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `sky_phone_mail_entries` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`message_id` CHAR(36) NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,
`folder` ENUM('inbox', 'sent') NOT NULL,
`mailbox_id` BIGINT UNSIGNED NULL,
`read_at` DATETIME NULL,
`trashed_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_mail_entry` (`message_id`, `account_id`, `folder`),
KEY `idx_sky_phone_mailbox` (`account_id`, `folder`, `trashed_at`, `id`),
KEY `idx_sky_phone_custom_mailbox` (`account_id`, `mailbox_id`, `trashed_at`, `id`),
FOREIGN KEY (`message_id`) REFERENCES `sky_phone_mail_messages` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;