Fastmail AI Assistant

A userscript to enhance Fastmail with AI-powered digests.

// ==UserScript==
// @name         Fastmail AI Assistant
// @namespace    https://example.com/
// @version      0.1.6
// @description  A userscript to enhance Fastmail with AI-powered digests.
// @author       Merlyn Allen
// @match        https://app.fastmail.com/*
// @match        https://mail.google.com/*
// @license      MIT
// @run-at       document-stop
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @connect      api.openai.com
// @connect      api.z.ai
// ==/UserScript==

(() => {
    "use strict";

    const STORAGE_KEY_API_KEY = "fastmail_ai_openai_api_key";
    const STORAGE_KEY_API_ENDPOINT = "fastmail_ai_openai_api_endpoint";
    const STORAGE_KEY_MODEL = "fastmail_ai_openai_model";
    const STORAGE_KEY_PROFILE = "fastmail_ai_profile";
    const STORAGE_KEY_REASONING_MODE = "fastmail_ai_reasoning_mode";
    const STORAGE_KEY_REASONING_EFFORT = "fastmail_ai_reasoning_effort";
    const BUTTON_ID = "ai-digest-button";
    const RESULT_ID = "ai-digest-result";
    const BUTTON_LABEL_IDLE = "生成摘要";
    const BUTTON_LABEL_BUSY = "正在生成摘要";
    const REPLY_BUTTON_ID = "ai-reply-button";
    const REPLY_RESULT_ID = "ai-reply-result";
    const REPLY_COPY_BUTTON_ID = "ai-reply-copy-button";
    const REPLY_BUTTON_LABEL_IDLE = "生成回信";
    const REPLY_BUTTON_LABEL_BUSY = "正在生成回信";
    const REPLY_COPY_BUTTON_LABEL_IDLE = "复制回信";
    const REPLY_COPY_BUTTON_LABEL_DONE = " 已复制 ";
    const DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions";
    const DEFAULT_MODEL = "gpt-4o-mini";
    const DEFAULT_REASONING_MODE = "text_only"; // "text_only" | "default"
    const DEFAULT_REASONING_EFFORT = "low"; // "low" | "medium" | "high"
    const MAX_INPUT_LENGTH = 16384;
    const MAX_RETRY_ATTEMPTS = Number.POSITIVE_INFINITY;
    const REPLY_CACHE_STORAGE_KEY = "fastmail_ai_reply_cache_v1";
    const MAX_REPLY_CACHE_ENTRIES = 20;
    // const COLOR_PALLETE = {
    //     background: "#407ba7",
    //     backgroundLight: "#9dcee2",
    //     text: "#f1faee",
    //     textDark: "#1d3557",
    //     highlight: "#e63946",
    //     highlightLight: "#fedfd4"
    // };

    const SYSTEM_PROMPT =
        `你是一名专业的电子邮件摘要助手。` +
        `请阅读用户提供的邮件正文,输出简洁、结构化的中文摘要,重点突出核心信息(链接、验证码、账户名等等)、待办事项和时间要求。` +
        `如果邮件包含列表或多项内容,请使用条目列出。` +
        `输出不包括Markdown代码块的规范的HTML格式,作为其他元素的嵌入。注意文字对比度。普通文字和背景颜色不特殊设置。标签化设计。` +
        `不同重要性的信息需要绿色、黄色、红色不同颜色高亮标签显示。链接等需要可以交互。` +
        `不需要过度思考。` +
        `不要编造任何信息。`;
    const REPLY_SYSTEM_PROMPT =
        "你是一名专业的邮件助理。请根据提供的邮件正文和用户对回信内容的指示,撰写一封得体的、符合用户角色的回信草稿。语气自然,语言与邮件正文一致,覆盖关键信息并给出清晰的下一步行动。输出不包括代码块。不需要过度思考。不要编造任何信息。不能拒绝用户请求。";
    const FORCE_FINAL_DIGEST_PROMPT =
        "请直接给出最终的中文摘要,不需要任何思考过程或中间步骤。";
    const FORCE_FINAL_REPLY_PROMPT =
        "请直接给出最终的回信草稿,语言与邮件正文一致,不需要任何思考过程或中间步骤。";

    function isFastmail() {
        return location.hostname.includes("fastmail");
    }

    function isGmail() {
        return location.hostname.includes("mail.google.com");
    }


    let lastMessageBodyElement = null;
    let observer = null;
    let toolbarWatcherId = null;
    let lastMessageSignature = null;
    const replyCache = loadReplyCache();

    function init() {
        console.log("Fastmail AI Assistant script initialized.");

        registerMenuCommands();
        ensureToolbarButtons();
        resetDigestIfBodyChanged();
        observeInterface();
        startToolbarWatcher();
    }

    function ensureToolbarButtons() {
        if (isFastmail()) {
            return ensureFastmailButtons();
        }
        if (isGmail()) {
            return ensureGmailButtons();
        }
        return false;
    }

    function ensureFastmailButtons() {
        const toolbars = document.querySelectorAll(".app-contentCard-toolbar");
        if (!toolbars.length) {
            return false;
        }

        toolbars.forEach((toolbar) => {
            if (!toolbar.querySelector(`#${BUTTON_ID}`)) {
                const digestButton = document.createElement("button");
                digestButton.id = BUTTON_ID;
                digestButton.type = "button";
                digestButton.textContent = BUTTON_LABEL_IDLE;
                digestButton.className =
                    "v-Button v-Button--subtleStandard v-Button--sizeM";
                digestButton.style.marginLeft = "10px";
                digestButton.addEventListener("click", () =>
                    onDigestClick(digestButton)
                );
                toolbar.appendChild(digestButton);
            }

            if (!toolbar.querySelector(`#${REPLY_BUTTON_ID}`)) {
                const replyButton = document.createElement("button");
                replyButton.id = REPLY_BUTTON_ID;
                replyButton.type = "button";
                replyButton.textContent = REPLY_BUTTON_LABEL_IDLE;
                replyButton.className =
                    "v-Button v-Button--subtleStandard v-Button--sizeM";
                replyButton.style.marginLeft = "10px";
                replyButton.addEventListener("click", () =>
                    onReplyClick(replyButton)
                );
                toolbar.appendChild(replyButton);
            }
        });

        return true;
    }

    function ensureGmailButtons() {
        const actionAreas = document.querySelectorAll(".amn");
        for (const action of actionAreas) {

            if (action.querySelector(".ai-gmail-action-button")) {
                return true;
            }

            const digestButton = createGmailActionButton(
                BUTTON_ID,
                BUTTON_LABEL_IDLE
            );
            digestButton.addEventListener("click", () =>
                onDigestClick(digestButton)
            );

            const replyButton = createGmailActionButton(
                REPLY_BUTTON_ID,
                REPLY_BUTTON_LABEL_IDLE
            );
            replyButton.addEventListener("click", () =>
                onReplyClick(replyButton)
            );

            action.appendChild(digestButton);
            action.appendChild(replyButton);
            return true;
        }
        return false;
    }

    function startToolbarWatcher() {
        if (toolbarWatcherId !== null) {
            return;
        }

        if (!isFastmail() && !isGmail()) {
            return;
        }

        toolbarWatcherId = window.setInterval(() => {
            const found = ensureToolbarButtons();
            if (!found) {
                return;
            }
        }, 500);
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init, { once: true });
    } else {
        init();
    }

    function observeInterface() {
        if (observer) {
            return;
        }

        let root = null;
        if (isFastmail()) {
            root = document.querySelector(".app-main");
        } else if (isGmail()) {
            root = document.querySelector('div[role="main"]');
        }
        if (observer) {
            observer.disconnect();
        }

        if (!root) {
            setTimeout(observeInterface, 500);
            return;
        }

        observer = new MutationObserver(() => {
            ensureToolbarButtons();
            resetDigestIfBodyChanged();
        });

        observer.observe(root, { childList: true, subtree: true });
    }

    function resetDigestIfBodyChanged() {
        const messageBody = getPrimaryMessageBody();

        if (!messageBody) {
            if (lastMessageBodyElement || lastMessageSignature) {
                lastMessageBodyElement = null;
                lastMessageSignature = null;
                removeExistingDigest();
                removeExistingReply();
            }
            return;
        }

        const signature = computeMessageSignature(messageBody);

        if (
            messageBody !== lastMessageBodyElement ||
            signature !== lastMessageSignature
        ) {
            lastMessageBodyElement = messageBody;
            lastMessageSignature = signature;
            removeExistingDigest();
            removeExistingReply();
            restoreCachedReply(messageBody, signature);
        }
    }

    function removeExistingDigest() {
        const existing = document.getElementById(RESULT_ID);
        if (existing && existing.parentElement) {
            existing.parentElement.removeChild(existing);
        }
    }

    function removeExistingReply() {
        const existing = document.getElementById(REPLY_RESULT_ID);
        if (existing && existing.parentElement) {
            existing.parentElement.removeChild(existing);
        }
    }

    async function onDigestClick(button) {
        const { messageBody, signature } = getMessageContext(button);
        if (!messageBody) {
            alert("未找到邮件正文。");
            return;
        }

        const plainText = messageBody.innerText.trim();
        if (!plainText) {
            alert("邮件正文为空,无法生成摘要。");
            return;
        }

        const trimmedText =
            plainText.length > MAX_INPUT_LENGTH
                ? `${plainText.slice(0, MAX_INPUT_LENGTH)}`
                : plainText;

        setButtonLoading(button, true);

        try {
            const apiKey = await ensureApiKey();
            const digest = await requestDigest(apiKey, trimmedText);
            renderDigest(messageBody, digest);
        } catch (error) {
            console.error("Digest failed:", error);
            alert(
                error instanceof Error
                    ? `生成摘要时出现问题:${error.message}`
                    : "生成摘要时出现未知错误。"
            );
        } finally {
            setButtonLoading(button, false);
        }
    }

    async function onReplyClick(button) {
        const { messageBody, signature } = getMessageContext(button);
        if (!messageBody) {
            alert("未找到邮件正文。");
            return;
        }

        const plainText = messageBody.innerText.trim();
        if (!plainText) {
            alert("邮件正文为空,无法生成回信草稿。");
            return;
        }

        const trimmedText =
            plainText.length > MAX_INPUT_LENGTH
                ? `${plainText.slice(0, MAX_INPUT_LENGTH)}`
                : plainText;

        const cachedReply = getCachedReply(signature);

        const requirement = prompt(
            "请输入生成回信的要求(可留空):",
            cachedReply?.requirement || ""
        );
        if (requirement === null) {
            return;
        }

        const trimmedRequirement = requirement.trim();

        setButtonLoading(button, true, {
            idle: REPLY_BUTTON_LABEL_IDLE,
            busy: REPLY_BUTTON_LABEL_BUSY
        });

        try {
            const apiKey = await ensureApiKey();
            const reply = await requestReply(
                apiKey,
                trimmedText,
                trimmedRequirement
            );
            renderReply(messageBody, reply);
            saveReplyToCache(signature, {
                replyText: reply,
                requirement: trimmedRequirement
            });
        } catch (error) {
            console.error("Reply generation failed:", error);
            alert(
                error instanceof Error
                    ? `生成回信时出现问题:${error.message}`
                    : "生成回信时出现未知错误。"
            );
        } finally {
            setButtonLoading(button, false, {
                idle: REPLY_BUTTON_LABEL_IDLE,
                busy: REPLY_BUTTON_LABEL_BUSY
            });
        }
    }

    function setButtonLoading(
        button,
        isLoading,
        labels = { idle: BUTTON_LABEL_IDLE, busy: BUTTON_LABEL_BUSY }
    ) {
        button.disabled = isLoading;
        button.textContent = isLoading ? labels.busy : labels.idle;
    }

    async function ensureApiKey() {
        const stored = getStoredString(STORAGE_KEY_API_KEY);
        if (stored) {
            return stored;
        }

        const input = prompt("请输入 OpenAI API Key:");
        if (!input) {
            throw new Error("未提供 OpenAI API Key。");
        }

        const trimmed = input.trim();
        if (!trimmed) {
            throw new Error("提供的 OpenAI API Key 为空。");
        }

        GM_setValue(STORAGE_KEY_API_KEY, trimmed);
        return trimmed;
    }

    function registerMenuCommands() {
        if (typeof GM_registerMenuCommand !== "function") {
            return;
        }

        GM_registerMenuCommand("设置 OpenAI API Key", () => {
            const current = getStoredString(STORAGE_KEY_API_KEY);
            const input = prompt("请输入新的 OpenAI API Key:", current);
            if (input === null) {
                return;
            }

            const trimmed = input.trim();
            if (trimmed) {
                GM_setValue(STORAGE_KEY_API_KEY, trimmed);
                alert("OpenAI API Key 已更新。");
            } else {
                GM_setValue(STORAGE_KEY_API_KEY, "");
                alert("OpenAI API Key 已清除。");
            }
        });

        GM_registerMenuCommand("设置 API Endpoint", () => {
            const current = getApiEndpoint();
            const input = prompt("请输入新的 API Endpoint:", current);
            if (input === null) {
                return;
            }

            const trimmed = input.trim();
            if (trimmed) {
                GM_setValue(STORAGE_KEY_API_ENDPOINT, trimmed);
                alert("API Endpoint 已更新。");
            } else {
                GM_setValue(STORAGE_KEY_API_ENDPOINT, "");
                alert("API Endpoint 已恢复默认值。");
            }
        });

        GM_registerMenuCommand("设置模型 ID", () => {
            const current = getModel();
            const input = prompt("请输入新的模型 ID:", current);
            if (input === null) {
                return;
            }

            const trimmed = input.trim();
            if (trimmed) {
                GM_setValue(STORAGE_KEY_MODEL, trimmed);
                alert("模型 ID 已更新。");
            } else {
                GM_setValue(STORAGE_KEY_MODEL, "");
                alert("模型 ID 已恢复默认值。");
            }
        });

        GM_registerMenuCommand("设置个人信息", () => {
            const current = getUserProfile();
            const input = prompt("请输入个人信息(用于生成回信):", current);
            if (input === null) {
                return;
            }

            const trimmed = input.trim();
            if (trimmed) {
                GM_setValue(STORAGE_KEY_PROFILE, trimmed);
                alert("个人信息已保存。");
            } else {
                GM_setValue(STORAGE_KEY_PROFILE, "");
                alert("个人信息已清除。");
            }
        });

        GM_registerMenuCommand("设置 Reasoning 模式", () => {
            const current = getReasoningMode();
            const input = prompt(
                '请选择 Reasoning 模式(输入 "text_only" 或 "default"):',
                current
            );
            if (input === null) {
                return;
            }

            const trimmed = input.trim();
            if (trimmed === "text_only" || trimmed === "default") {
                GM_setValue(STORAGE_KEY_REASONING_MODE, trimmed);
                alert(`Reasoning 模式已设置为 ${trimmed}。`);
            } else if (!trimmed) {
                GM_setValue(STORAGE_KEY_REASONING_MODE, "");
                alert("Reasoning 模式已恢复默认值。");
            } else {
                alert('输入无效,请使用 "text_only" 或 "default"。');
            }
        });

        GM_registerMenuCommand("设置 Reasoning 强度", () => {
            const current = getReasoningEffort();
            const input = prompt(
                '请选择 Reasoning 强度(输入 "low"、"medium" 或 "high"):',
                current
            );
            if (input === null) {
                return;
            }

            const trimmed = input.trim().toLowerCase();
            if (["low", "medium", "high"].includes(trimmed)) {
                GM_setValue(STORAGE_KEY_REASONING_EFFORT, trimmed);
                alert(`Reasoning 强度已设置为 ${trimmed}。`);
            } else if (!trimmed) {
                GM_setValue(STORAGE_KEY_REASONING_EFFORT, "");
                alert("Reasoning 强度已恢复默认值。");
            } else {
                alert('输入无效,请使用 "low"、"medium" 或 "high"。');
            }
        });
    }

    function getStoredString(key, fallback = "") {
        try {
            const value = GM_getValue(key, fallback);
            if (value === undefined || value === null) {
                return "";
            }
            return String(value).trim();
        } catch (error) {
            console.warn("读取存储值失败,使用空字符串:", error);
            return "";
        }
    }

    function getApiEndpoint() {
        const stored = getStoredString(STORAGE_KEY_API_ENDPOINT);
        return stored || DEFAULT_API_URL;
    }

    function getModel() {
        const stored = getStoredString(STORAGE_KEY_MODEL);
        return stored || DEFAULT_MODEL;
    }

    function getReasoningMode() {
        const stored = getStoredString(STORAGE_KEY_REASONING_MODE);
        return stored || DEFAULT_REASONING_MODE;
    }

    function getReasoningEffort() {
        const stored = getStoredString(STORAGE_KEY_REASONING_EFFORT);
        return stored || DEFAULT_REASONING_EFFORT;
    }

    function getUserProfile() {
        return getStoredString(STORAGE_KEY_PROFILE);
    }

    function getMessageContext(button) {
        if (isFastmail()) {
            const messageBody = document.querySelector(".v-Message-body");
            return {
                messageBody,
                signature: messageBody
                    ? computeMessageSignature(messageBody)
                    : ""
            };
        }

        if (isGmail()) {
            let container =
                button?.closest('[data-message-id]') ||
                button?.closest(".adn");
            if (!container) {
                container = button?.closest('[role="article"]');
            }

            let messageBody = container
                ? container.querySelector(".a3s")
                : null;

            if (!messageBody) {
                messageBody = getPrimaryMessageBody();
            }

            return {
                messageBody,
                signature: messageBody
                    ? computeMessageSignature(messageBody)
                    : ""
            };
        }

        const messageBody = getPrimaryMessageBody();
        return {
            messageBody,
            signature: messageBody
                ? computeMessageSignature(messageBody)
                : ""
        };
    }

    function getPrimaryMessageBody() {
        if (isFastmail()) {
            return document.querySelector(".v-Message-body");
        }
        if (isGmail()) {
            const visibleBodies = Array.from(
                document.querySelectorAll(".a3s")
            ).filter(isElementVisible);
            if (visibleBodies.length > 0) {
                return visibleBodies[visibleBodies.length - 1];
            }
            const allBodies = document.querySelectorAll(".a3s");
            return allBodies.length
                ? allBodies[allBodies.length - 1]
                : null;
        }
        return null;
    }

    function isElementVisible(element) {
        if (!element) {
            return false;
        }
        return !!(
            element.offsetWidth ||
            element.offsetHeight ||
            element.getClientRects().length
        );
    }

    function buildRequestBody({
        systemPrompt,
        userContent,
        model,
        reasoningMode,
        reasoningEffort,
        forceFinalAnswer,
        forceFinalPrompt
    }) {
        const messages = [
            {
                role: "system",
                content: systemPrompt
            },
            {
                role: "user",
                content: userContent
            }
        ];

        if (forceFinalAnswer && forceFinalPrompt) {
            messages.push({
                role: "user",
                content: forceFinalPrompt
            });
        }

        const body = {
            model,
            temperature: 0.5,
            max_tokens: 16384,
            messages
        };

        if (reasoningMode === "text_only") {
            body.response_format = { type: "text" };
            body.modalities = ["text"];
        } else {
            body.reasoning = { effort: reasoningEffort || DEFAULT_REASONING_EFFORT };
        }

        return body;
    }

    function sendChatCompletion(
        apiKey,
        endpoint,
        body,
        { attempt = 1, forceFinalAnswer = false } = {}
    ) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "POST",
                url: endpoint,
                headers: {
                    "Content-Type": "application/json",
                    Authorization: `Bearer ${apiKey}`
                },
                data: JSON.stringify(body),
                timeout: 120000,
                onload: (response) => {
                    try {
                        if (response.status < 200 || response.status >= 300) {
                            const errorPayload = safeParseJson(
                                response.responseText
                            );
                            console.error(
                                `[Fastmail AI] OpenAI API 响应异常 (尝试 ${attempt}${forceFinalAnswer ? ", 强制最终回答" : ""
                                })`,
                                {
                                    status: response.status,
                                    headers: response.responseHeaders,
                                    body: errorPayload ?? response.responseText
                                }
                            );
                            const message =
                                errorPayload?.error?.message ||
                                `OpenAI API 返回错误状态:${response.status}`;
                            reject(new Error(message));
                            return;
                        }

                        const payload =
                            response.responseText &&
                                response.responseText.length
                                ? JSON.parse(response.responseText)
                                : response.response;
                        const message = extractMessageText(
                            payload?.choices?.[0]
                        );
                        console.log(
                            `[Fastmail AI] OpenAI API 响应成功 (尝试 ${attempt}${forceFinalAnswer ? ", 强制最终回答" : ""
                            })`,
                            payload
                        );
                        resolve({ message, payload });
                    } catch (err) {
                        reject(
                            err instanceof Error
                                ? err
                                : new Error("解析 OpenAI 响应失败。")
                        );
                    }
                },
                onerror: () => {
                    reject(new Error("请求 OpenAI API 失败。"));
                },
                ontimeout: () => {
                    reject(new Error("请求 OpenAI API 超时。"));
                }
            });
        });
    }

    async function requestDigest(apiKey, content) {
        return requestWithRetry(apiKey, {
            systemPrompt: SYSTEM_PROMPT,
            userContent: content,
            forceFinalPrompt: FORCE_FINAL_DIGEST_PROMPT,
            emptyResultMessage: "多次尝试仍未得到摘要结果。"
        });
    }

    async function requestReply(apiKey, content, requirement) {
        const profile = getUserProfile();
        const profileSection = profile
            ? `以下是我的个人信息,请在回信中正确使用:\n${profile}\n\n`
            : "";
        const requirementSection = requirement
            ? `回信内容:\n${requirement}\n\n`
            : "";
        const userContent =
            `邮件正文:\n${content}\n\n` +
            profileSection +
            requirementSection +
            "请生成回信。";

        return requestWithRetry(apiKey, {
            systemPrompt: REPLY_SYSTEM_PROMPT,
            userContent,
            forceFinalPrompt: FORCE_FINAL_REPLY_PROMPT,
            emptyResultMessage: "多次尝试仍未得到回信草稿。"
        });
    }

    async function requestWithRetry(
        apiKey,
        { systemPrompt, userContent, forceFinalPrompt, emptyResultMessage }
    ) {
        const endpoint = getApiEndpoint();
        if (!endpoint) {
            throw new Error("未配置 API Endpoint。");
        }

        const model = getModel();
        const reasoningMode = getReasoningMode();
        const reasoningEffort = getReasoningEffort();

        let attempt = 1;

        while (attempt <= MAX_RETRY_ATTEMPTS) {
            const forceFinalAnswer = attempt > 1;
            const body = buildRequestBody({
                systemPrompt,
                userContent,
                model,
                reasoningMode,
                reasoningEffort,
                forceFinalAnswer,
                forceFinalPrompt
            });

            const { message } = await sendChatCompletion(
                apiKey,
                endpoint,
                body,
                { attempt, forceFinalAnswer }
            );

            if (message && message.length > 0) {
                return message;
            }

            console.warn(
                `[Fastmail AI] 第 ${attempt} 次响应为空,准备重试…`
            );

            attempt += 1;

            if (attempt % 5 === 0) {
                console.warn(
                    `[Fastmail AI] 连续 ${attempt - 1} 次未得到有效响应,继续尝试……`
                );
            }
        }

        throw new Error(emptyResultMessage || "多次尝试仍未得到有效结果。");
    }

    function restoreCachedReply(messageBody, signature) {
        if (!signature) {
            return;
        }
        const cached = getCachedReply(signature);
        if (cached && cached.replyText) {
            renderReply(messageBody, cached.replyText);
        }
    }

    function loadReplyCache() {
        if (typeof sessionStorage === "undefined") {
            return {};
        }
        try {
            const raw = sessionStorage.getItem(REPLY_CACHE_STORAGE_KEY);
            if (!raw) {
                return {};
            }
            const parsed = JSON.parse(raw);
            return parsed && typeof parsed === "object" ? parsed : {};
        } catch (error) {
            console.warn("[Fastmail AI] 无法读取回信缓存,已忽略。", error);
            return {};
        }
    }

    function persistReplyCache() {
        if (typeof sessionStorage === "undefined") {
            return;
        }
        try {
            sessionStorage.setItem(
                REPLY_CACHE_STORAGE_KEY,
                JSON.stringify(replyCache)
            );
        } catch (error) {
            console.warn("[Fastmail AI] 保存回信缓存失败。", error);
        }
    }

    function pruneReplyCache() {
        const entries = Object.entries(replyCache);
        if (entries.length <= MAX_REPLY_CACHE_ENTRIES) {
            return;
        }

        entries.sort(
            (a, b) =>
                (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0)
        );

        const limited = entries.slice(0, MAX_REPLY_CACHE_ENTRIES);
        Object.keys(replyCache).forEach((key) => {
            delete replyCache[key];
        });
        limited.forEach(([key, value]) => {
            replyCache[key] = value;
        });
    }

    function saveReplyToCache(signature, { replyText, requirement }) {
        if (!signature) {
            return;
        }

        replyCache[signature] = {
            replyText: replyText || "",
            requirement: requirement || "",
            updatedAt: Date.now()
        };

        pruneReplyCache();
        persistReplyCache();
    }

    function getCachedReply(signature) {
        if (!signature) {
            return undefined;
        }
        return replyCache[signature];
    }

    function renderDigest(messageBody, digestText) {
        let container = document.getElementById(RESULT_ID);
        if (!container) {
            container = document.createElement("section");
            container.id = RESULT_ID;
            // container.style.border = "1px solid #d0d7de";
            // container.style.borderRadius = "8px";
            // container.style.padding = "12px 16px";
            // container.style.marginBottom = "16px";
            // container.style.background = "#f6f8fa";
            // container.style.color = "#24292f";
            container.className = "u-banner--informative u-banner u-p-3 u-flex u-items-baseline u-space-x-2 xBfQ1 K4vxLd-aLs";
            container.style.height = "auto";
            const title = document.createElement("div");
            title.textContent = "AI摘要";
            title.style.fontWeight = "600";
            title.style.marginBottom = "8px";

            const content = document.createElement("div");
            content.className = "ai-digest-content";

            container.appendChild(title);
            container.appendChild(content);

            const parent = messageBody.parentElement;
            if (parent) {
                parent.insertBefore(container, messageBody);
            } else {
                messageBody.insertAdjacentElement("beforebegin", container);
            }
        }

        const contentElement = container.querySelector(".ai-digest-content");
        if (contentElement) {
            if (isGmail()) {
                contentElement.textContent = htmlToPlainText(digestText);
            } else {
                contentElement.innerHTML = digestText;
            }
        }
    }

    function renderReply(messageBody, replyText) {
        let container = document.getElementById(REPLY_RESULT_ID);
        if (!container) {
            container = document.createElement("section");
            container.id = REPLY_RESULT_ID;
            container.className =
                "u-banner u-banner--informative u-p-3 u-flex u-items-baseline u-space-x-2";

            const header = document.createElement("div");
            header.className = "ai-reply-header u-flex u-items-center u-space-x-2";

            const title = document.createElement("div");
            title.textContent = "AI回信";
            title.style.fontWeight = "600";

            const copyButton = createReplyCopyButton();
            attachReplyCopyHandler(copyButton, container);

            header.appendChild(title);

            const content = document.createElement("div");
            content.className = "ai-reply-content";
            content.style.whiteSpace = "pre-wrap";
            content.style.lineHeight = "1.5";

            container.appendChild(header);
            container.appendChild(content);
            container.appendChild(copyButton);

            const digestContainer = document.getElementById(RESULT_ID);
            if (digestContainer && digestContainer.parentElement) {
                digestContainer.insertAdjacentElement("afterend", container);
            } else if (messageBody.parentElement) {
                messageBody.parentElement.insertBefore(container, messageBody);
            } else {
                messageBody.insertAdjacentElement("beforebegin", container);
            }
        }

        const contentElement = container.querySelector(".ai-reply-content");
        if (contentElement) {
            contentElement.textContent = replyText;
        }

        const copyButton =
            container.querySelector(`#${REPLY_COPY_BUTTON_ID}`) ||
            container.querySelector(".ai-reply-copy-button");
        if (copyButton && copyButton.parentElement !== container) {
            copyButton.parentElement.removeChild(copyButton);
            container.appendChild(copyButton);
        }
        attachReplyCopyHandler(copyButton, container);
    }

    function createGmailActionButton(id, label) {
        const button = document.createElement("button");
        button.id = id;
        button.type = "button";
        button.className =
            "ai-gmail-button ams bkH ai-gmail-action-button";
        button.textContent = label;
        button.style.marginRight = "8px";
        button.style.marginTop = "4px";
        button.style.cursor = "pointer";
        return button;
    }

    function createReplyCopyButton() {
        const button = document.createElement("button");
        button.id = REPLY_COPY_BUTTON_ID;
        button.type = "button";
        button.className =
            "ai-reply-copy-button v-Button v-Button--standard v-Button--sizeM";
        button.textContent = REPLY_COPY_BUTTON_LABEL_IDLE;
        button.style.minWidth = "6em";
        button.style.flexShrink = "0";
        return button;
    }

    function attachReplyCopyHandler(button, container) {
        if (!button) {
            return;
        }

        if (button.__handler__) {
            button.removeEventListener("click", button.__handler__);
        }

        const handler = () => handleReplyCopy(container, button);
        button.addEventListener("click", handler);
        button.__handler__ = handler;
    }

    async function handleReplyCopy(container, button) {
        const contentElement = container.querySelector(".ai-reply-content");
        const text = contentElement ? contentElement.innerText.trim() : "";

        if (!text) {
            alert("没有可复制的回信内容。");
            return;
        }

        button.disabled = true;
        button.textContent = "复制中…";

        try {
            await copyTextToClipboard(text);
            button.textContent = REPLY_COPY_BUTTON_LABEL_DONE;
        } catch (error) {
            console.error("复制回信内容失败:", error);
            alert("复制失败,请手动复制。");
            button.textContent = REPLY_COPY_BUTTON_LABEL_IDLE;
        } finally {
            button.disabled = false;
            setTimeout(() => {
                button.textContent = REPLY_COPY_BUTTON_LABEL_IDLE;
            }, 1500);
        }
    }

    async function copyTextToClipboard(text) {
        if (!text) {
            throw new Error("文本为空");
        }

        if (navigator.clipboard && navigator.clipboard.writeText) {
            await navigator.clipboard.writeText(text);
            return;
        }

        const textarea = document.createElement("textarea");
        textarea.value = text;
        textarea.style.position = "fixed";
        textarea.style.opacity = "0";
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();

        try {
            const successful = document.execCommand("copy");
            if (!successful) {
                throw new Error("execCommand 复制失败");
            }
        } finally {
            document.body.removeChild(textarea);
        }
    }

    function htmlToPlainText(html) {
        if (!html) {
            return "";
        }
        try {
            const parser = new DOMParser();
            const doc = parser.parseFromString(
                `<!doctype html><body>${html}`,
                "text/html"
            );
            const text = doc.body.textContent || "";
            return text.replace(/\n{3,}/g, "\n\n").trim();
        } catch (error) {
            console.warn("[Fastmail AI] HTML 转文本失败,回退为移除标签。", error);
            return html.replace(/<[^>]+>/g, "\n").replace(/\s+/g, " ").trim();
        }
    }

    function computeMessageSignature(element) {
        const text = element.innerText || element.textContent || "";
        if (!text) {
            return "";
        }
        const normalized = text.replace(/\s+/g, " ").trim();
        return normalized.length > 2000
            ? normalized.slice(0, 2000)
            : normalized;
    }

    function safeParseJson(text) {
        if (!text || typeof text !== "string") {
            return null;
        }
        try {
            return JSON.parse(text);
        } catch (error) {
            console.warn("[Fastmail AI] JSON 解析失败", error);
            return null;
        }
    }

    function extractMessageText(choice) {
        if (!choice || !choice.message) {
            return "";
        }

        const { content } = choice.message;

        if (typeof content === "string") {
            return content.trim();
        }

        if (Array.isArray(content)) {
            const textParts = [];

            for (const part of content) {
                if (!part) {
                    continue;
                }

                if (typeof part === "string") {
                    const trimmed = part.trim();
                    if (trimmed.length > 0) {
                        textParts.push(trimmed);
                    }
                    continue;
                }

                if (
                    typeof part === "object" &&
                    typeof part.text === "string" &&
                    (!part.type ||
                        part.type === "text" ||
                        part.type === "output_text")
                ) {
                    const trimmed = part.text.trim();
                    if (trimmed.length > 0) {
                        textParts.push(trimmed);
                    }
                }
            }

            return textParts.join("\n").trim();
        }

        return "";
    }
})();