Merge pull request #733 from micz/translations_issue247

Automatic Translations
This commit is contained in:
Mic 2026-04-01 23:29:59 +02:00 committed by GitHub
commit 01ef569d4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1904 additions and 340 deletions

View file

@ -6,7 +6,8 @@
<h2>Version 4.1.0 - ??/??/2026</h2>
<ul>
<li>Antispam information are now permanently saved for each message [<a href="https://github.com/micz/ThunderAI/issues/675">#675</a>].</li>
<li><i>[All APIs]</i> A summaru has been added above the mail content [<a href="https://github.com/micz/ThunderAI/issues/580">#580</a>].</li>
<li><i>[All APIs]</i> A summary has been added above the mail content [<a href="https://github.com/micz/ThunderAI/issues/580">#580</a>].</li>
<li><i>[All APIs]</i> Inline auto translation for emails added [<a href="https://github.com/micz/ThunderAI/issues/247">#247</a>].</li>
<li>...</li>
</ul>
<h2>Version 4.0.3 - 20/03/2026</h2>

View file

@ -119,6 +119,7 @@ _The language status represents the percentage of translated strings in the late
- [Icojam](https://www.iconarchive.com/artist/icojam.html) for the spam filter context menu icon
- [Roundicons](https://www.flaticon.com/authors/roundicons) for the summarize context menu icon
- [HideMau](https://www.flaticon.com/authors/hidemaru) for the ai summarize icon
- [Hilmy Abiyyu A.](https://www.flaticon.com/authors/hilmy-abiyyu-a) for the ai translate and context menu icons
<br>

View file

@ -629,7 +629,7 @@
"prefs_OptionText_add_tags_auto_only_inbox_Info": {
"message": "Pokud je zaškrtnuto, AI bude přidávat štítky pouze e-mailům přijatým ve složce Doručená pošta."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Výchozí jazyk podle nastavení ThunderAI."
},
"StorageSpace": {

View file

@ -753,7 +753,7 @@
"placeholder_thunderai_def_sign": {
"message": "Standardsignatur wie in den ThunderAI-Optionen definiert."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Standardsprache wie in den ThunderAI-Optionen definiert."
},
"prefs_OptionText_spamfilter": {

View file

@ -692,7 +692,7 @@
"placeholder_thunderai_def_sign": {
"message": "Προεπιλεγμένη υπογραφή όπως ορίζεται στις επιλογές ThunderAI."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Προεπιλεγμένη γλώσσα όπως ορίζεται στις επιλογές ThunderAI."
},
"placeholder_mail_attachments_info": {

View file

@ -332,8 +332,12 @@
"description": ""
},
"prefs_storage_title": {
"message": "Additional Info Storage",
"description": "Title for additional info storage management section in options"
"message": "Storage",
"description": "Title for storage management section in options"
},
"prefs_storage_info": {
"message": "The storage is used to save information about spam score, summaries and translations of each message.",
"description": "Info text for storage management section in options"
},
"prefs_storage_size": {
"message": "Storage size",
@ -813,7 +817,7 @@
"description": ""
},
"prompt_translate_this_full_text": {
"message": "Translate the following email in",
"message": "Translate the email below into {%thunderai_translate_lang%}.\n\nRules:\n- Translate both the subject and the body.\n- Return the result as a JSON object with three fields: \"subject\", \"body\" and \"status\".\n- If the translation has been done the status is equal to 1.\n- If the email is written in one of these languages \"{%thunderai_translate_exclude_lang%}\" or in the {%thunderai_translate_lang%} language, return an empty string for the body and the subject and set the status to -1.\n- Do not add explanations, notes, or any text outside the JSON.\n\nMail subject: {%mail_subject%}\n\nMail body: {%mail_html_body%}\n\nGenerate a response in JSON format only. The output should be only a JSON object. Here is an example of the JSON format to be used:\n{\n\"subject\": \"subject translation\",\n\"body\": \"body translation\",\n\"status\": \"status result\"\n}",
"description": ""
},
"prompt_this_full_text": {
@ -1308,10 +1312,18 @@
"message": "Default signature as defined in ThunderAI options.",
"description": ""
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Default language as defined in ThunderAI options.",
"description": ""
},
"placeholder_thunderai_translate_lang": {
"message": "The language to be used in the mail translations.",
"description": ""
},
"placeholder_thunderai_translate_exclude_lang": {
"message": "The languages code to do not translate when found.",
"description": ""
},
"placeholder_mail_attachments_info": {
"message": "Information about the attachments in the email",
"description": ""
@ -1480,6 +1492,10 @@
"message": "Summarize",
"description": ""
},
"context_menu_mzta-translate": {
"message": "Translate",
"description": ""
},
"noActiveCalendar": {
"message": "No editable calendar found!",
"description": ""
@ -1957,18 +1973,6 @@
"message": "Auto-summarize messages",
"description": ""
},
"prefs_OptionText_summarize_auto_disabled": {
"message": "Disabled",
"description": ""
},
"prefs_OptionText_summarize_auto_manual": {
"message": "Show summary button",
"description": ""
},
"prefs_OptionText_summarize_auto_automatic": {
"message": "When the email is opened",
"description": ""
},
"prefs_OptionText_summarize_auto_Info": {
"message": "Choose whether to automatically generate summaries when viewing messages. Requires an API-based connection (not ChatGPT Web).",
"description": ""
@ -1977,14 +1981,6 @@
"message": "Display summary in",
"description": ""
},
"prefs_OptionText_summarize_display_mode_inline": {
"message": "Message pane (inline)",
"description": ""
},
"prefs_OptionText_summarize_display_mode_webchat": {
"message": "Chat window",
"description": ""
},
"prefs_OptionText_summarize_display_mode_Info": {
"message": "Choose where the summary result is displayed. Inline mode shows a summary banner directly in the message pane. Chat window mode opens the AI chat window.",
"description": ""
@ -2049,14 +2045,158 @@
"message": "Delete summary",
"description": ""
},
"prefs_OptionText_translate": {
"message": "Translate email",
"description": ""
},
"prefs_OptionText_translate_use_specific_integration_Info": {
"message": "If checked, the Model and API specified below will be used for translating email(s), regardless the one chosen in the ThunderAI options page.",
"description": ""
},
"prefs_OptionText_translate_Info": {
"message": "If checked, adds a translate button in the message body.",
"description": ""
},
"prefs_OptionText_btnManageTranslateInfo": {
"message": "Manage translate settings",
"description": ""
},
"Translate_PageTitle": {
"message": "Manage Translation Settings",
"description": ""
},
"Translate_info_default": {
"message": "In this page you can modify the default prompt used to translate emails.",
"description": ""
},
"Translate_prompt_text_title": {
"message": "Current prompt text",
"description": ""
},
"Translate_prompt_prefs_title": {
"message": "Translation Options",
"description": ""
},
"prefs_OptionText_translate_auto": {
"message": "Auto-translate messages",
"description": ""
},
"prefs_OptionText_action_auto_disabled": {
"message": "Disabled",
"description": ""
},
"prefs_OptionText_action_auto_manual": {
"message": "Manual button only",
"description": ""
},
"prefs_OptionText_action_auto_automatic": {
"message": "When the email is opened",
"description": ""
},
"prefs_OptionText_translate_auto_Info": {
"message": "Choose when to translate messages: disabled, only when clicking the button, or automatically when opening a message.",
"description": ""
},
"prefs_OptionText_display_mode_inline": {
"message": "Message pane (inline)",
"description": ""
},
"prefs_OptionText_display_mode_webchat": {
"message": "Chat window",
"description": ""
},
"prefs_OptionText_translate_max_display_length": {
"message": "Maximum length of displayed translation",
"description": ""
},
"prefs_OptionText_translate_max_display_length_Info": {
"message": "Maximum number of characters shown in the inline translation. 0 = no limit. When set, longer text is truncated with a \"See more\" toggle.",
"description": ""
},
"translate_see_more": {
"message": "See more",
"description": ""
},
"translate_see_less": {
"message": "See less",
"description": ""
},
"prefs_OptionText_translate_lang": {
"message": "Translation target language",
"description": ""
},
"prefs_OptionText_translate_lang_Info": {
"message": "Language to translate emails into. If empty, uses the default language setting.",
"description": ""
},
"prefs_OptionText_translate_exclude_lang": {
"message": "Exclude languages",
"description": ""
},
"prefs_OptionText_translate_exclude_lang_Info": {
"message": "Comma-separated list of language codes (e.g., en, fr, it) to skip for automatic translation. If the email is in one of these languages, it won't be translated automatically or the manual button won't be shown.",
"description": ""
},
"prefs_OptionText_Translate_main_prompt": {
"message": "The prompt describing the translation task:",
"description": ""
},
"translate_generating": {
"message": "Translating...",
"description": ""
},
"translate_click_to_generate": {
"message": "Click here to translate this email",
"description": ""
},
"get_ai_translation": {
"message": "AI Translation",
"description": ""
},
"translate_chatgpt_web_not_supported": {
"message": "Auto-translation requires an API-based connection. Please configure an API connection in ThunderAI settings.",
"description": ""
},
"translate_refresh": {
"message": "Refresh translation",
"description": ""
},
"translate_delete": {
"message": "Delete translation",
"description": ""
},
"translate_banner_title": {
"message": "AI Translation",
"description": ""
},
"translate_error": {
"message": "Translation failed.",
"description": ""
},
"translate_no_language_configured": {
"message": "Translation language is not configured. Please set a language in the Translation settings or set a default language in the General settings.",
"description": ""
},
"translate_skipped": {
"message": "Translation skipped: Language excluded or identical to target.",
"description": "Shown in the translation banner when the email language matches the excluded or target language"
},
"antispam_by": {
"message": "Antispam by",
"description": ""
},
"spam_badge_tooltip": {
"message": "Spam score — Click to see the explanation",
"description": "Tooltip shown on the spam score badge in the toolbar"
},
"summary_by": {
"message": "Summary by",
"description": ""
},
"translate_by": {
"message": "Translation by",
"description": ""
},
"prefs_THStats_1": {
"message": "Do you want beautiful statistics about your emails?",
"description": ""
@ -2088,5 +2228,9 @@
"prefs_OptionText_action_auto_batch": {
"message": "When the email is received",
"description": ""
},
"placeholder_string": {
"message": "Placeholder",
"description": ""
}
}

View file

@ -875,7 +875,7 @@
"placeholder_thunderai_def_sign": {
"message": "Firma predeterminada según lo definido en las opciones de ThunderAI."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Idioma predeterminado según lo definido en las opciones de ThunderAI."
},
"placeholder_mail_attachments_info": {

View file

@ -810,7 +810,7 @@
"SpamFilter_prompt_text_title": {
"message": "Texte du prompt actuelle"
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Langue par défaut telle que définie dans les options de ThunderAI."
},
"prefs_OptionText_btnManageSpamFilterInfo": {

View file

@ -759,7 +759,7 @@
"placeholder_thunderai_def_sign": {
"message": "Zadani potpis kako je određeno u mogućnostima ThunderAI."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Zadani jezik kako je određeno u mogućnostima ThunderAI."
},
"prefs_OptionText_spamfilter": {

View file

@ -804,7 +804,7 @@
"SpamFilter_info_default": {
"message": "In questa pagina puoi modificare il prompt predefinito utilizzato per rilevare le email di spam."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Lingua predefinita come impostata nelle opzioni di ThunderAI."
},
"spamfilter_no_reports": {

View file

@ -878,7 +878,7 @@
"placeholder_thunderai_def_sign": {
"message": "ThunderAIオプションで定義されたデフォルトの署名。"
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "ThunderAIオプションで定義されたデフォルト言語。"
},
"placeholder_mail_attachments_info": {

View file

@ -756,7 +756,7 @@
"Spam_Value": {
"message": "Wartość spamu"
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Domyślny język zgodnie z opcjami ThunderAI."
},
"prompt_spamfilter": {

View file

@ -822,7 +822,7 @@
"prefs_OptionText_add_tags_auto_only_inbox_Info": {
"message": "Se marcado, a IA adicionará tags apenas aos e-mails recebidos na pasta da caixa de entrada."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Idioma padrão conforme definido nas opções do ThunderAI."
},
"placeholder_thunderai_def_sign": {

View file

@ -848,7 +848,7 @@
"placeholder_thunderai_def_sign": {
"message": "Подпись по умолчанию, определенная в опциях ThunderAI."
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Язык по умолчанию, определенный в опциях ThunderAI."
},
"empty": {

View file

@ -734,7 +734,7 @@
"prefs_OptionText_get_calendar_event": {
"message": "Lägg till en ny kalenderhändelse från markerad text"
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "Standardspråk som definierat i ThunderAI-alternativ."
},
"placeholder_mail_attachments_info": {

View file

@ -611,7 +611,7 @@
"prefs_OptionText_placeholders_use_default_value": {
"message": "占位符:使用默认值"
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "ThunderAI 选项中定义的默认语言。"
},
"prefs_OptionText_openai_comp_info_remote": {

View file

@ -1002,7 +1002,7 @@
"prefs_OptionText_chatgpt_web_custom_gpt": {
"message": "ChatGPT 網頁自訂 GPT"
},
"thunderai_def_lang": {
"placeholder_thunderai_def_lang": {
"message": "ThunderAI 選項中定義的預設語言。"
},
"prefs_SurveyLinkText2": {

View file

@ -459,7 +459,7 @@ class MessagesArea extends HTMLElement {
}
// Save as Summary button (only shown for summary webchat sessions)
if(promptData.prompt_info?.headerMessageId) {
if(promptData.prompt_info?.headerMessageId && promptData.prompt_info?.summaryTabId) {
const saveSummaryButton = document.createElement('button');
saveSummaryButton.textContent = browser.i18n.getMessage("webchat_save_as_summary");
saveSummaryButton.classList.add('action_btn');

View file

@ -38,7 +38,7 @@ js/mzta-prompts.js (builds final prompt string)
Result returned to background
js/mzta-compose-script.js (inserts text into Thunderbird compose window)
js/mzta-compose-script.js (inserts text into Thunderbird compose window and display window)
```
### Data Flow: Inline Summary on Message Display
@ -80,6 +80,34 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs)
mzta-compose-script.js (render summary banner in message body)
```
### Data Flow: Inline Translation on Message Display
The `translate_auto` preference controls when translation is triggered.
Translation always renders inline (webchat mode has been removed).
The target language is determined by `translate_lang` (fallback on `default_chatgpt_lang`).
```
User opens/selects a message in Thunderbird
mzta-compose-script.js (sends "initTranslation" to background)
mzta-background.js (checks translate + translate_auto prefs)
┌──────────────────────────────────────────────────────────┐
│ translate_auto = 0 → do nothing │
│ translate_auto = 1 → show "click to translate" button │
│ translate_auto = 2 → generate immediately │
└──────────────────────────────────────────────────────────┘
taTranslationStore (check cache / set processing)
↓ (cache miss)
mzta-special-commands (via Web Worker, NOT chatgpt_web)
taTranslationStore (save result via taStorage)
mzta-compose-script.js (render translation banner in message body)
```
### Data Flow: Background Summary on Email Receive (summarize_auto = 3)
When `summarize_auto = 3`, a summary is generated silently when a new email arrives. The flow mirrors `add_tags_auto`:
@ -92,7 +120,7 @@ browser.messages.onNewMailReceived
newEmailListener (checks _process_incoming, which includes summarize_auto === 3)
processEmails({ summarizeOnReceive: true })
↓ (single loop — shared with addTagsAuto / spamFilter)
↓ (single loop — shared with addTagsAuto / spamFilter / translateOnReceive)
_generateSummaryForMessage(headerMessageId, null, { messageData })
← tabId is null → no UI messages sent, silent pre-cache
@ -101,6 +129,27 @@ taSummaryStore.saveSummary()
[later] user opens the message → initSummary → cache hit → showSummary instantly
```
### Data Flow: Background Translation on Email Receive (translate_auto = 3)
When `translate_auto = 3`, a translation is generated silently when a new email arrives. Mirrors the summarize on-receive flow:
```
New email arrives
browser.messages.onNewMailReceived
newEmailListener (checks _process_incoming, which includes translate_auto === 3)
processEmails({ translateOnReceive: true })
↓ (single loop — shared with addTagsAuto / spamFilter / summarizeOnReceive)
_generateTranslationForMessage(headerMessageId, null, { messageData })
← tabId is null → no UI messages sent, silent pre-cache
taTranslationStore.saveTranslation()
[later] user opens the message → initTranslation → cache hit → showTranslation instantly
```
## Key Modules
| File | Role |
@ -110,8 +159,8 @@ taSummaryStore.saveSummary()
| `js/mzta-prompts.js` | Prompt definitions (built-in) and custom prompt loading |
| `js/mzta-placeholders.js` | Placeholder definitions and resolution logic |
| `js/mzta-utils.js` | General utilities (email parsing, storage helpers, etc.) |
| `js/mzta-utils-prompt.js` | Prompt-specific utilities (text truncation, lang injection, `buildSummaryPrompt()` for unified summary prompt assembly) |
| `js/mzta-compose-script.js` | Content script for compose and message display: injects AI response into compose window, renders summary/spam banners in message display |
| `js/mzta-utils-prompt.js` | Prompt-specific utilities (text truncation, lang injection, `buildSummaryPrompt()` for unified summary prompt assembly, `buildTranslationPrompt()` for translation prompt assembly) |
| `js/mzta-compose-script.js` | Content script for compose and message display: injects AI response into compose window, renders unified toolbar (spam badge, summary/translation trigger buttons) and content panels (spam explanation, summary, translation) in message display via `#mzta-container` |
| `js/mzta-chatgpt.js` | ChatGPT Web integration (opens browser window, reads DOM) |
| `js/mzta-special-commands.js` | Handles special prompt actions (add_tags, calendar, task) |
| `js/mzta-spamreport.js` | Spam filter logic |
@ -120,6 +169,7 @@ taSummaryStore.saveSummary()
| `js/mzta-store.js` | Storage abstraction helpers |
| `js/mzta-storage.js` | Unified per-message storage layer (`taStorage` class) for summary, spam, and translation data |
| `js/mzta-summarystore.js` | Summary-specific storage wrapper (`taSummaryStore` class) with caching, truncation, and processing-state tracking |
| `js/mzta-translationstore.js` | Translation-specific storage wrapper (`taTranslationStore` class) with caching, truncation, and processing-state tracking |
| `js/mzta-working-status.js` | Visual status indicator during AI processing |
| `js/mzta-addatags-exclusion-list.js` | Tag exclusion list management |
| `js/mzta-placeholders-autocomplete.js` | Autocomplete for placeholders in prompt editor |
@ -162,6 +212,7 @@ Each subdirectory is a self-contained settings/UI page for a specific feature:
| `get-task/` | Task creation settings |
| `spamfilter/` | Spam filter settings |
| `summarize/` | Email summarization settings |
| `translate/` | Email translation settings |
| `onboarding/` | First-run welcome page |
| `_lib/` | Shared libraries used by pages |
@ -174,3 +225,5 @@ All preferences are stored via `browser.storage.local`. The keys and default val
Per-message data (summaries, spam reports, translations) is stored via `js/mzta-storage.js` (`taStorage` class). Each record is keyed by `msg:<headerMessageId>` in `messenger.storage.local` and follows schema version 1. Records contain optional fields: `summary`, `spam`, `translation`, plus metadata (`v`, `ts`). The `taStorage` class provides typed read/write/delete methods per field, automatic record cleanup when all fields are removed, and age-based cleanup.
`js/mzta-summarystore.js` (`taSummaryStore` class) wraps `taStorage` for summary-specific operations: load/save/remove summaries, track in-flight generation state via `browser.storage.session`, enforce a 100-entry cache limit with oldest-first truncation, and store error states.
`js/mzta-translationstore.js` (`taTranslationStore` class) wraps `taStorage` for translation-specific operations: load/save/remove translations, track in-flight generation state via `browser.storage.session`, enforce a 100-entry cache limit with oldest-first truncation, and store error states. Each translation record stores `translated_text`, `lang`, and optional error information.

View file

@ -55,6 +55,7 @@ Some prompts trigger additional Thunderbird actions beyond just sending text to
| `summarize` | Summarize email content |
| `get_calendar_event` | Extract and create a calendar event |
| `get_task` | Extract and create a task |
| `translate` | Translate email content into a target language |
These special prompts can have their own dedicated API integration settings (configured in the Options page). The list of these special prompts is in `options/mzta-options-default.js` as `special_prompts_with_integration`.
@ -85,6 +86,28 @@ The summarize feature uses two distinct prompt pathways:
- Accepts an array of `{ message, fullMessage }` entries
- Returns `{ promptText, promptInfo }` where `promptInfo` is the `prompt_summarize` prompt object
### Translate: Inline-Only Prompt System
The translate feature uses a single special prompt (`prompt_translate_this`) for translating emails. Translation always renders inline (no webchat mode).
**Inline Translation on Message Display** (controlled by `translate_auto` pref):
- Uses a single special prompt: `prompt_translate_this`
- The prompt uses placeholders (`{%mail_subject%}`, `{%mail_html_body%}`, `{%thunderai_translate_lang%}`, `{%thunderai_translate_exclude_lang%}`) resolved via the standard placeholder system
- The AI response is a JSON object: `{ "subject": "...", "body": "...", "status": "1"|"-1" }`
- `status = "1"`: translation completed, subject and body are displayed
- `status = "-1"`: translation skipped (excluded/target language), a "skipped" message is shown
- Target language is determined by `translate_lang` pref, falling back to `default_chatgpt_lang`
- Does **not** support `chatgpt_web` connection type (shows error if configured)
- Result is rendered as a styled banner (green/teal theme) in the message body via `mzta-compose-script.js`
- Banner includes refresh (↻) and delete (×) buttons
- Cached per-message via `taTranslationStore` / `taStorage` (max 100 entries)
- The prompt was originally a regular prompt (`defaultPrompts`) and was moved to `specialPrompts` with `is_special: "1"` and `type: "1"` (reading email only)
**Prompt Building** — `taPromptUtils.buildTranslationPrompt(fullMessage)`:
- Retrieves the `prompt_translate_this` special prompt text
- Resolves placeholders via `placeholdersUtils.getPlaceholdersValues()` + `replacePlaceholders()`
- Returns `{ promptText, promptInfo }`
## Prompt Types Reference
```

View file

@ -35,7 +35,7 @@ use_specific_integration (default: false)
### Special Prompt Integration Overrides
The 5 special prompts (`add_tags`, `spamfilter`, `summarize`, `get_calendar_event`, `get_task`) each get their own `use_specific_integration` and `connection_type` keys:
The 6 special prompts (`add_tags`, `spamfilter`, `summarize`, `get_calendar_event`, `get_task`, `translate`) each get their own `use_specific_integration` and `connection_type` keys:
```
{prefix}_use_specific_integration (default: false)
@ -98,6 +98,10 @@ These are generated programmatically at the bottom of `mzta-options-default.js`
| `summarize_auto` | `1` | Auto-summarize mode: `0` = disabled, `1` = manual (show "click to generate" button), `2` = automatic (generate on message open), `3` = generate on email receive (background pre-cache via `onNewMailReceived`, no UI during generation) |
| `summarize_display_mode` | `'inline'` | Where to display summaries: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `summarize_auto = 2` and `summarize_auto = 3` always use inline regardless of this setting. |
| `summarize_max_display_length` | `0` | Maximum characters shown in inline summary before truncation. `0` = no limit (show full text). When set, text is truncated at a word boundary and a "See more"/"See less" toggle link is shown. |
| `translate` | `true` | Enable email translation |
| `translate_auto` | `0` | Auto-translate mode: `0` = disabled, `1` = manual (show button), `2` = automatic (translate on message open), `3` = generate on email receive (background pre-cache via `onNewMailReceived`, no UI during generation) |
| `translate_max_display_length` | `0` | Maximum characters shown in inline translation before truncation. `0` = no limit (show full text). When set, text is truncated at a word boundary and a "See more"/"See less" toggle link is shown. |
| `translate_lang` | `''` | Target language for translation. Falls back to `default_chatgpt_lang` if empty. |
### Summarize Settings Page (`pages/summarize/`)
@ -120,6 +124,19 @@ The summarize settings page provides:
- Each has Save/Reset buttons and placeholder autocomplete
- Default text comes from i18n strings (`prompt_summarize_full_text`, etc.)
### Translate Settings Page (`pages/translate/`)
The translate settings page provides:
1. **Specific integration checkbox** — enables per-feature API override (like other special prompts)
2. **Auto-translate dropdown** (`translate_auto`) — three modes:
- `0` (Disabled) — no inline translations
- `1` (Manual) — shows a "Get AI Translation" button in message display
- `2` (Automatic) — generates translation immediately when message is opened
3. **Max display length** (`translate_max_display_length`) — number input, limits inline translation text to N characters. `0` = no limit. When truncated, a "See more"/"See less" toggle link is appended.
4. **Target language** (`translate_lang`) — text input for the destination language. If empty, falls back to `default_chatgpt_lang`.
5. **One editable prompt** — the translation instruction prompt (`prompt_translate_this`) with Save/Reset buttons and placeholder autocomplete. Default text comes from i18n string `prompt_translate_this_full_text`.
## Adding a New Preference
1. Add the key and default value to `prefs_default` in `options/mzta-options-default.js`

BIN
images/ai_translate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 B

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
images/menu_translate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -21,8 +21,7 @@
// to avoid contaminating placeholder values sent to AI providers.
// Add new selectors here when new UI elements are injected into the email DOM.
const MZTA_INJECTED_SELECTORS = [
'#mzta-spam-check-progress',
'#mzta-spam-report-banner',
'#mzta-container',
'.mzta_dialog',
];
@ -45,6 +44,154 @@ function getCleanBodyHtml() {
return clone;
}
// ── Theme colors ────────────────────────────────────────────────────
function _getThemeColors(spamValue, spamThreshold) {
const isDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
const colors = {
isDark,
toolbar: { bg: isDark ? '#1e1e1e' : '#f5f5f5', text: isDark ? '#ddd' : '#333', border: isDark ? '#444' : '#ddd' },
spamLoading: { bg: isDark ? '#003366' : '#e6f2ff', text: isDark ? '#cce5ff' : '#004085', border: isDark ? '#004085' : '#b8daff' },
summary: { bg: isDark ? '#2a2a2a' : '#f0f0f0', text: isDark ? '#e0e0e0' : '#333', border: isDark ? '#444' : '#ddd' },
summaryErr: { bg: isDark ? '#3a1a1a' : '#f7e6e6', text: isDark ? '#ffcccc' : '#660000', border: '#660000' },
translation: { bg: isDark ? '#1a2e2a' : '#e8f5e9', text: isDark ? '#c8e6c9' : '#1b5e20', border: isDark ? '#2e5740' : '#a5d6a7' },
translErr: { bg: isDark ? '#3a1a1a' : '#f7e6e6', text: isDark ? '#ffcccc' : '#660000', border: '#660000' },
linkColor: isDark ? '#6db3f2' : '#1a5fa8',
};
// Spam colors depend on the score
if (spamValue !== undefined) {
const threshold = spamThreshold || 50;
if (spamValue == -999) {
colors.spam = { bg: isDark ? '#332701' : '#fff3cd', text: isDark ? '#ffeb80' : '#856404', border: isDark ? '#664d03' : '#ffeeba' };
} else if (spamValue >= threshold) {
colors.spam = { bg: isDark ? '#5a1a1a' : '#ffe6e6', text: isDark ? '#ffcccc' : '#cc0000', border: '#cc0000' };
} else {
colors.spam = { bg: isDark ? '#1a401a' : '#e6ffe6', text: isDark ? '#ccffcc' : '#006600', border: '#006600' };
}
}
return colors;
}
// ── Container / Toolbar / Panels management ─────────────────────────
function _ensureContainer() {
let container = document.getElementById('mzta-container');
if (!container) {
const colors = _getThemeColors();
container = document.createElement('div');
container.id = 'mzta-container';
container.style.cssText = 'font-family: system-ui, -apple-system, sans-serif;';
document.body.insertBefore(container, document.body.firstChild);
const toolbar = document.createElement('div');
toolbar.id = 'mzta-toolbar';
toolbar.style.cssText = `display: none; align-items: center; gap: 8px; padding: 6px 0.5rem; background-color: ${colors.toolbar.bg}; border-bottom: 1px solid ${colors.toolbar.border}; font-size: 13px; color: ${colors.toolbar.text};`;
container.appendChild(toolbar);
const panels = document.createElement('div');
panels.id = 'mzta-panels';
panels.style.cssText = 'display: flex; flex-direction: column; gap: 4px; padding: 4px 0;';
container.appendChild(panels);
}
return {
toolbar: document.getElementById('mzta-toolbar'),
panels: document.getElementById('mzta-panels'),
};
}
function _updateToolbarVisibility() {
const toolbar = document.getElementById('mzta-toolbar');
if (!toolbar) return;
const hasItems = toolbar.querySelector('#mzta-toolbar-spam, #mzta-toolbar-summary, #mzta-toolbar-translation');
toolbar.style.display = hasItems ? 'flex' : 'none';
// Push the first button (summary or translation) to the right
const summary = document.getElementById('mzta-toolbar-summary');
const translation = document.getElementById('mzta-toolbar-translation');
const firstBtn = summary || translation;
if (summary) summary.style.marginLeft = (firstBtn === summary) ? 'auto' : '';
if (translation) translation.style.marginLeft = (firstBtn === translation) ? 'auto' : '';
}
const _TOOLBAR_SLOT_ORDER = ['mzta-toolbar-spam', 'mzta-toolbar-summary', 'mzta-toolbar-translation'];
function _addToolbarItem(id, element) {
const { toolbar } = _ensureContainer();
const existing = document.getElementById(id);
if (existing) existing.remove();
element.id = id;
const myIndex = _TOOLBAR_SLOT_ORDER.indexOf(id);
let insertBefore = null;
for (let i = myIndex + 1; i < _TOOLBAR_SLOT_ORDER.length; i++) {
const later = document.getElementById(_TOOLBAR_SLOT_ORDER[i]);
if (later) { insertBefore = later; break; }
}
if (insertBefore) toolbar.insertBefore(element, insertBefore);
else toolbar.appendChild(element);
_updateToolbarVisibility();
}
function _removeToolbarItem(id) {
const el = document.getElementById(id);
if (el) el.remove();
_updateToolbarVisibility();
}
const _PANEL_ORDER = [
'mzta-spam-check-progress', 'mzta-spam-report-banner',
'mzta-translation-generating', 'mzta-translation-banner',
'mzta-summary-generating', 'mzta-summary-banner'
];
function _addPanel(id, element) {
const { panels } = _ensureContainer();
const existing = document.getElementById(id);
if (existing) existing.remove();
element.id = id;
const myIndex = _PANEL_ORDER.indexOf(id);
let insertBefore = null;
for (let i = myIndex + 1; i < _PANEL_ORDER.length; i++) {
const later = document.getElementById(_PANEL_ORDER[i]);
if (later) { insertBefore = later; break; }
}
if (insertBefore) panels.insertBefore(element, insertBefore);
else panels.appendChild(element);
_updatePanelMargins();
}
function _removePanel(id) {
const el = document.getElementById(id);
if (el) el.remove();
_updatePanelMargins();
}
function _updatePanelMargins() {
const panels = document.getElementById('mzta-panels');
if (!panels) return;
let lastPanel = null;
for (const child of panels.children) {
child.style.marginBottom = '';
lastPanel = child;
}
if (lastPanel) lastPanel.style.marginBottom = '1rem';
}
function _isHtml(text) {
return /<[a-z][^>]*>/i.test(text);
}
function _renderSafeHtml(container, html) {
container.textContent = '';
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, img').forEach(el => el.remove());
while (doc.body.firstChild) {
container.appendChild(doc.body.firstChild);
}
container.querySelectorAll('p').forEach(p => { p.style.marginBlockStart = '0'; });
}
function createThreeDotsMenu(isDark, menuItems, panelColors) {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'position: relative; display: inline-flex; align-items: center;';
@ -725,96 +872,68 @@ switch (message.command) {
break;
case "showSpamCheckInProgress":
const oldBanner = document.getElementById('mzta-spam-report-banner');
if(oldBanner) oldBanner.remove();
case "showSpamCheckInProgress": {
_removePanel('mzta-spam-report-banner');
_removeToolbarItem('mzta-toolbar-spam');
if (document.getElementById('mzta-spam-check-progress')) return Promise.resolve(true);
if(document.getElementById('mzta-spam-check-progress')) return Promise.resolve(true);
const containerProgress = document.createElement('div');
containerProgress.id = 'mzta-spam-check-progress';
const isDarkProgress = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColorProgress = isDarkProgress ? '#003366' : '#e6f2ff';
let textColorProgress = isDarkProgress ? '#cce5ff' : '#004085';
let borderColorProgress = isDarkProgress ? '#004085' : '#b8daff';
containerProgress.style.cssText = `background-color: ${bgColorProgress}; color: ${textColorProgress}; border-bottom: 1px solid ${borderColorProgress}; padding: 8px 12px; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; display: flex; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`;
const textProgress = document.createElement('strong');
textProgress.textContent = browser.i18n.getMessage("spam_check_in_progress");
const loadingImg = document.createElement('img');
loadingImg.src = browser.runtime.getURL("/images/loading.gif");
loadingImg.style.cssText = "height: 16px; width: 16px;";
const brandingProgress = document.createElement('span');
brandingProgress.textContent = browser.i18n.getMessage("antispam_by") + " ThunderAI";
brandingProgress.style.cssText = 'margin-left: auto; font-style: italic; font-size: 11px; opacity: 0.7;';
containerProgress.appendChild(loadingImg);
containerProgress.appendChild(textProgress);
containerProgress.appendChild(brandingProgress);
document.body.insertBefore(containerProgress, document.body.firstChild);
// Reposition summary trigger button if it exists as fixed
const existingFixedTriggerProgress = document.getElementById('mzta-summary-trigger');
if (existingFixedTriggerProgress && !document.getElementById('mzta-summary-trigger-wrapper')) {
existingFixedTriggerProgress.style.position = '';
existingFixedTriggerProgress.style.top = '';
existingFixedTriggerProgress.style.right = '';
existingFixedTriggerProgress.style.zIndex = '';
existingFixedTriggerProgress.style.marginLeft = 'auto';
existingFixedTriggerProgress.style.marginTop = '4px';
const reposTriggerWrapperProgress = document.createElement('div');
reposTriggerWrapperProgress.id = 'mzta-summary-trigger-wrapper';
reposTriggerWrapperProgress.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;';
reposTriggerWrapperProgress.appendChild(existingFixedTriggerProgress);
document.body.insertBefore(reposTriggerWrapperProgress, containerProgress.nextSibling);
}
const colors = _getThemeColors();
// Loading badge in toolbar
const badge = document.createElement('div');
badge.style.cssText = `background-color: ${colors.spamLoading.bg}; color: ${colors.spamLoading.text}; border: 1px solid ${colors.spamLoading.border}; border-radius: 4px; padding: 2px 8px; font-size: 12px; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;`;
const badgeLoading = document.createElement('img');
badgeLoading.src = browser.runtime.getURL("/images/loading.gif");
badgeLoading.style.cssText = "height: 14px; width: 14px;";
badge.appendChild(badgeLoading);
const badgeText = document.createElement('span');
badgeText.textContent = browser.i18n.getMessage("spam_check_in_progress");
badge.appendChild(badgeText);
_addToolbarItem('mzta-toolbar-spam', badge);
return Promise.resolve(true);
}
case "showSpamReport":
const progressBanner = document.getElementById('mzta-spam-check-progress');
if(progressBanner) progressBanner.remove();
case "showSpamReport": {
_removePanel('mzta-spam-check-progress');
_removeToolbarItem('mzta-toolbar-spam');
const data = message.data;
if(document.getElementById('mzta-spam-report-banner')) return Promise.resolve(true);
if (document.getElementById('mzta-spam-report-banner')) return Promise.resolve(true);
const container = document.createElement('div');
container.id = 'mzta-spam-report-banner';
const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColor = '#f8f9fa';
let textColor = '#333';
let borderColor = '#ccc';
const colors = _getThemeColors(data.spamValue, data.SpamThreshold);
const sc = colors.spam;
// Spam badge in toolbar (clickable to toggle explanation panel)
const badge = document.createElement('div');
badge.title = browser.i18n.getMessage("spam_badge_tooltip");
badge.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; border: 1px solid ${sc.border}; border-radius: 4px; padding: 2px 8px; font-size: 12px; font-weight: bold; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; transition: opacity 0.2s;`;
if (data.spamValue == -999) {
bgColor = isDark ? '#332701' : '#fff3cd';
textColor = isDark ? '#ffeb80' : '#856404';
borderColor = isDark ? '#664d03' : '#ffeeba';
} else if (data.spamValue >= (data.SpamThreshold || 50)) {
bgColor = isDark ? '#5a1a1a' : '#ffe6e6';
textColor = isDark ? '#ffcccc' : '#cc0000';
borderColor = '#cc0000';
badge.textContent = browser.i18n.getMessage("apiwebchat_error");
} else {
bgColor = isDark ? '#1a401a' : '#e6ffe6';
textColor = isDark ? '#ccffcc' : '#006600';
borderColor = '#006600';
badge.textContent = ((data.spamValue >= (data.SpamThreshold || 50)) ? "\u26A0\uFE0F " + browser.i18n.getMessage("Spam") : "\uD83D\uDEE1\uFE0F " + browser.i18n.getMessage("Valid")) + " [" + data.spamValue + "/100]";
}
const chevron = document.createElement('span');
chevron.textContent = ' \u25BC';
chevron.style.cssText = 'font-size: 10px; transition: transform 0.2s; transform: rotate(-90deg);';
badge.appendChild(chevron);
container.style.cssText = `background-color: ${bgColor}; color: ${textColor}; border-bottom: 1px solid ${borderColor}; border-radius:4px; padding: 8px 0.5rem; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; display: flex; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`;
let spamExpanded = false;
badge.onclick = () => {
const panel = document.getElementById('mzta-spam-report-banner');
if (panel) {
spamExpanded = !spamExpanded;
panel.style.display = spamExpanded ? 'flex' : 'none';
chevron.style.transform = spamExpanded ? '' : 'rotate(-90deg)';
}
};
badge.onmouseover = () => { badge.style.opacity = '0.8'; };
badge.onmouseout = () => { badge.style.opacity = '1'; };
const scoreText = document.createElement('strong');
if (data.spamValue == -999) {
scoreText.textContent = browser.i18n.getMessage("apiwebchat_error");
} else {
scoreText.textContent = ((data.spamValue >= (data.SpamThreshold || 50)) ? "⚠️ " + browser.i18n.getMessage("Spam") : "🛡️ " + browser.i18n.getMessage("Valid")) + " [" + data.spamValue + "/100]";
}
_addToolbarItem('mzta-toolbar-spam', badge);
// Explanation panel (collapsed by default)
const panel = document.createElement('div');
panel.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; border: 1px solid ${sc.border}; border-radius: 4px; padding: 8px 0.5rem; font-size: 13px; display: none; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`;
const reasonText = document.createElement('span');
if (data.spamValue == -999) {
@ -823,115 +942,76 @@ switch (message.command) {
reasonText.textContent = browser.i18n.getMessage("Explanation") + ": " + data.explanation;
}
const branding = document.createElement('span');
branding.textContent = browser.i18n.getMessage("antispam_by") + " ThunderAI";
branding.style.cssText = 'margin-left: auto; font-style: italic; font-size: 10px; opacity: 0.5;';
const spamMenu = createThreeDotsMenu(isDark, [
const spamMenu = createThreeDotsMenu(colors.isDark, [
{
icon: '',
icon: '\u21BB',
label: browser.i18n.getMessage("spamfilter_refresh") || 'Refresh spam report',
hoverColor: isDark ? '#4d9de0' : '#1a5fa8',
hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8',
disableAfterClick: true,
onClick: () => {
browser.runtime.sendMessage({ command: "refreshSpamReport", headerMessageId: data.headerMessageId });
}
},
{
icon: '×',
icon: '\u00D7',
label: browser.i18n.getMessage("spamfilter_delete") || 'Delete spam report',
hoverColor: '#cc0000',
onClick: () => {
container.remove();
_removePanel('mzta-spam-report-banner');
_removeToolbarItem('mzta-toolbar-spam');
browser.runtime.sendMessage({ command: "removeSpamReport", headerMessageId: data.headerMessageId });
}
}
], { bg: bgColor, border: borderColor, text: textColor });
], { bg: sc.bg, border: sc.border, text: sc.text });
const spamRightGroup = document.createElement('span');
spamRightGroup.style.cssText = 'margin-left: auto; margin-right:1px; display: flex; align-items: center; gap: 5px;';
const rightGroup = document.createElement('span');
rightGroup.style.cssText = 'margin-left: auto; display: flex; align-items: center; gap: 5px;';
const branding = document.createElement('span');
branding.textContent = browser.i18n.getMessage("antispam_by") + " ThunderAI";
branding.style.cssText = 'font-style: italic; font-size: 10px; opacity: 0.5;';
spamRightGroup.appendChild(branding);
spamRightGroup.appendChild(spamMenu);
rightGroup.appendChild(branding);
rightGroup.appendChild(spamMenu);
container.appendChild(scoreText);
container.appendChild(reasonText);
container.appendChild(spamRightGroup);
document.body.insertBefore(container, document.body.firstChild);
// Reposition summary trigger button if it exists as fixed
const existingFixedTrigger = document.getElementById('mzta-summary-trigger');
if (existingFixedTrigger && !document.getElementById('mzta-summary-trigger-wrapper')) {
existingFixedTrigger.style.position = '';
existingFixedTrigger.style.top = '';
existingFixedTrigger.style.right = '';
existingFixedTrigger.style.zIndex = '';
existingFixedTrigger.style.marginLeft = 'auto';
existingFixedTrigger.style.marginTop = '4px';
const reposTriggerWrapper = document.createElement('div');
reposTriggerWrapper.id = 'mzta-summary-trigger-wrapper';
reposTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;';
reposTriggerWrapper.appendChild(existingFixedTrigger);
document.body.insertBefore(reposTriggerWrapper, container.nextSibling);
}
panel.appendChild(reasonText);
panel.appendChild(rightGroup);
_addPanel('mzta-spam-report-banner', panel);
return Promise.resolve(true);
}
case "showSummary":
const generatingBanner = document.getElementById('mzta-summary-generating');
if(generatingBanner) generatingBanner.remove();
const existingTriggerWrapper = document.getElementById('mzta-summary-trigger-wrapper');
if(existingTriggerWrapper) existingTriggerWrapper.remove();
const existingTriggerBtn = document.getElementById('mzta-summary-trigger');
if(existingTriggerBtn) existingTriggerBtn.remove();
const summaryBanner = document.getElementById('mzta-summary-banner');
if(summaryBanner) summaryBanner.remove();
case "showSummary": {
_removePanel('mzta-summary-generating');
_removePanel('mzta-summary-banner');
_removeToolbarItem('mzta-toolbar-summary');
const summaryData = message.data;
const colors = _getThemeColors();
const sc = summaryData.error ? colors.summaryErr : colors.summary;
const summaryContainer = document.createElement('div');
summaryContainer.id = 'mzta-summary-banner';
const isDarkSummary = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColorSummary = isDarkSummary ? '#2a2a2a' : '#f0f0f0';
let textColorSummary = isDarkSummary ? '#e0e0e0' : '#333';
let borderColorSummary = isDarkSummary ? '#444' : '#ddd';
if (summaryData.error) {
bgColorSummary = isDarkSummary ? '#3a1a1a' : '#f7e6e6';
textColorSummary = isDarkSummary ? '#ffcccc' : '#660000';
borderColorSummary = '#660000';
}
summaryContainer.className = 'thunderai-summary-pane';
summaryContainer.style.cssText = `background-color: ${bgColorSummary}; color: ${textColorSummary}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorSummary}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`;
summaryContainer.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${sc.border}; font-size: 14px;`;
const summaryMenu = createThreeDotsMenu(isDarkSummary, [
const summaryMenu = createThreeDotsMenu(colors.isDark, [
{
icon: '',
icon: '\u21BB',
label: browser.i18n.getMessage("summarize_refresh") || 'Refresh summary',
hoverColor: isDarkSummary ? '#4d9de0' : '#1a5fa8',
hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8',
disableAfterClick: true,
onClick: () => {
browser.runtime.sendMessage({
command: "refreshSummary",
headerMessageId: summaryData.headerMessageId
});
browser.runtime.sendMessage({ command: "refreshSummary", headerMessageId: summaryData.headerMessageId });
}
},
{
icon: '×',
icon: '\u00D7',
label: browser.i18n.getMessage("summarize_delete") || 'Delete summary',
hoverColor: '#cc0000',
onClick: () => {
summaryContainer.remove();
_removePanel('mzta-summary-banner');
browser.runtime.sendMessage({ command: "removeSummary", headerMessageId: summaryData.headerMessageId });
}
}
], { bg: bgColorSummary, border: borderColorSummary, text: textColorSummary });
], { bg: sc.bg, border: sc.border, text: sc.text });
const summaryBranding = document.createElement('span');
summaryBranding.textContent = browser.i18n.getMessage("summary_by") + " ThunderAI";
@ -944,18 +1024,16 @@ switch (message.command) {
const summaryIcon = document.createElement('img');
summaryIcon.src = browser.runtime.getURL("/images/ai_summary.png");
summaryIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0; margin-top: 2px;${isDarkSummary ? ' filter: invert(1);' : ''}`;
summaryIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0; margin-top: 2px;${colors.isDark ? ' filter: invert(1);' : ''}`;
const summaryTextWrapper = document.createElement('div');
summaryTextWrapper.style.cssText = 'flex: 1; min-width: 0;';
summaryTextWrapper.appendChild(summaryRightGroup);
const summaryText = document.createElement('div');
summaryText.className = 'thunderai-summary-content';
const hasHtml = !!summaryData.summary_html;
// Helper to set summary content using DOMParser (innerHTML is blocked in Thunderbird content scripts)
function setSummaryHtml(element, html) {
element.textContent = '';
const parser = new DOMParser();
@ -973,57 +1051,45 @@ switch (message.command) {
} else {
summaryText.textContent = summaryData.summary;
}
summaryText.style.cssText = `font-size: 14px; line-height: 1.4;`;
summaryText.style.cssText = 'font-size: 14px; line-height: 1.4;';
summaryTextWrapper.appendChild(summaryText);
const maxLen = summaryData.maxDisplayLength || 0;
const fullText = summaryData.summary;
if (!summaryData.error && maxLen > 0 && fullText && fullText.length > maxLen) {
// Set up animated expand/collapse via max-height transition
summaryText.style.overflow = 'hidden';
summaryText.style.transition = 'max-height 0.2s ease';
if (!hasHtml) {
// Plain text: truncate by character position
let cutPos = fullText.lastIndexOf(' ', maxLen);
if (cutPos <= 0) cutPos = maxLen;
const truncated = fullText.substring(0, cutPos) + '\u2026';
summaryText.textContent = truncated;
// Measure truncated height after layout
requestAnimationFrame(() => {
const collapsedHeight = summaryText.scrollHeight;
summaryText.style.maxHeight = collapsedHeight + 'px';
summaryText.style.maxHeight = summaryText.scrollHeight + 'px';
});
const toggleLink = document.createElement('a');
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
toggleLink.href = '#';
toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' +
(isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;';
toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`;
let expanded = false;
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
if (!expanded) {
// Expand: set full text, measure, animate to full height
summaryText.textContent = fullText;
const fullHeight = summaryText.scrollHeight;
summaryText.style.maxHeight = fullHeight + 'px';
summaryText.style.maxHeight = summaryText.scrollHeight + 'px';
toggleLink.textContent = browser.i18n.getMessage("summarize_see_less") || "See less";
} else {
// Collapse: measure current truncated height, then animate down
summaryText.textContent = truncated;
// Force layout to get the target height before animating
const collapsedHeight = summaryText.scrollHeight;
summaryText.textContent = fullText;
// Set explicit current height so transition has a starting point
summaryText.style.maxHeight = summaryText.scrollHeight + 'px';
requestAnimationFrame(() => {
summaryText.style.maxHeight = collapsedHeight + 'px';
});
// Swap text after transition ends
summaryText.addEventListener('transitionend', function handler() {
summaryText.removeEventListener('transitionend', handler);
summaryText.textContent = truncated;
@ -1032,18 +1098,15 @@ switch (message.command) {
}
expanded = !expanded;
});
summaryTextWrapper.appendChild(toggleLink);
} else {
// HTML content: use max-height to collapse, preserve full HTML
const collapsedMaxHeight = '4.2em'; // ~3 lines collapsed
const collapsedMaxHeight = '4.2em';
summaryText.style.maxHeight = collapsedMaxHeight;
const toggleLink = document.createElement('a');
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
toggleLink.href = '#';
toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' +
(isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;';
toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`;
let expanded = false;
toggleLink.addEventListener('click', (e) => {
@ -1058,7 +1121,6 @@ switch (message.command) {
expanded = !expanded;
});
// Only show toggle if content is actually taller than collapsed height
requestAnimationFrame(() => {
if (summaryText.scrollHeight > summaryText.clientHeight) {
summaryTextWrapper.appendChild(toggleLink);
@ -1076,110 +1138,299 @@ switch (message.command) {
summaryBody.appendChild(summaryTextWrapper);
summaryContainer.appendChild(summaryBody);
const spamBanner = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress');
document.body.insertBefore(summaryContainer, spamBanner ? spamBanner.nextSibling : document.body.firstChild);
_addPanel('mzta-summary-banner', summaryContainer);
return Promise.resolve(true);
}
case "showSummaryGenerating":
const existingGenerating = document.getElementById('mzta-summary-generating');
if(existingGenerating) return Promise.resolve(true);
case "showSummaryGenerating": {
if (document.getElementById('mzta-summary-generating')) return Promise.resolve(true);
const existingSummary = document.getElementById('mzta-summary-banner');
if(existingSummary) existingSummary.remove();
_removePanel('mzta-summary-banner');
_removeToolbarItem('mzta-toolbar-summary');
const existingTriggerWrap = document.getElementById('mzta-summary-trigger-wrapper');
if(existingTriggerWrap) existingTriggerWrap.remove();
const existingTrigger = document.getElementById('mzta-summary-trigger');
if(existingTrigger) existingTrigger.remove();
const colors = _getThemeColors();
const genContainer = document.createElement('div');
genContainer.className = 'thunderai-summary-pane';
genContainer.style.cssText = `background-color: ${colors.summary.bg}; color: ${colors.summary.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${colors.summary.border}; font-size: 14px; display: flex; align-items: center; gap: 10px;`;
const isDarkGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const genIcon = document.createElement('img');
genIcon.src = browser.runtime.getURL("/images/ai_summary.png");
genIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`;
let bgColorGen = isDarkGen ? '#2a2a2a' : '#f0f0f0';
let textColorGen = isDarkGen ? '#e0e0e0' : '#333';
let borderColorGen = isDarkGen ? '#444' : '#ddd';
let titleColorGen = isDarkGen ? '#ff6b6b' : '#d70022';
const genLoading = document.createElement('img');
genLoading.src = browser.runtime.getURL("/images/loading.gif");
genLoading.style.cssText = "height: 16px; width: 16px;";
const generatingContainer = document.createElement('div');
generatingContainer.id = 'mzta-summary-generating';
generatingContainer.className = 'thunderai-summary-pane';
generatingContainer.style.cssText = `background-color: ${bgColorGen}; color: ${textColorGen}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; display: flex; align-items: center; gap: 10px;`;
const genTitle = document.createElement('span');
genTitle.className = 'thunderai-summary-title';
genTitle.textContent = browser.i18n.getMessage("summarize_generating");
genTitle.style.cssText = 'font-size: 14px;';
const generatingIcon = document.createElement('img');
generatingIcon.src = browser.runtime.getURL("/images/ai_summary.png");
generatingIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkGen ? ' filter: invert(1);' : ''}`;
genContainer.appendChild(genIcon);
genContainer.appendChild(genLoading);
genContainer.appendChild(genTitle);
const generatingLoadingImg = document.createElement('img');
generatingLoadingImg.src = browser.runtime.getURL("/images/loading.gif");
generatingLoadingImg.style.cssText = "height: 16px; width: 16px;";
const generatingTitle = document.createElement('span');
generatingTitle.className = 'thunderai-summary-title';
generatingTitle.textContent = browser.i18n.getMessage("summarize_generating");
generatingTitle.style.cssText = `font-size: 14px;`;
generatingContainer.appendChild(generatingIcon);
generatingContainer.appendChild(generatingLoadingImg);
generatingContainer.appendChild(generatingTitle);
const spamBannerGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress');
document.body.insertBefore(generatingContainer, spamBannerGen ? spamBannerGen.nextSibling : document.body.firstChild);
_addPanel('mzta-summary-generating', genContainer);
return Promise.resolve(true);
}
case "showSummaryButton":
const existingButton = document.getElementById('mzta-summary-trigger');
if(existingButton) return Promise.resolve(true);
case "showSummaryButton": {
if (document.getElementById('mzta-toolbar-summary')) return Promise.resolve(true);
const isDarkBtn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColorBtn = isDarkBtn ? '#2a2a2a' : '#f0f0f0';
let textColorBtn = isDarkBtn ? '#e0e0e0' : '#333';
let borderColorBtn = isDarkBtn ? '#444' : '#ddd';
const spamBannerTrigger = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress');
const colors = _getThemeColors();
const triggerBtn = document.createElement('div');
triggerBtn.id = 'mzta-summary-trigger';
triggerBtn.title = browser.i18n.getMessage("summarize_click_to_generate");
const triggerBtnBase = `background-color: ${bgColorBtn}; border: 1px solid ${borderColorBtn}; border-radius: 4px; padding: 6px 10px; cursor: pointer; font-family: system-ui, -apple-system, sans-serif; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${textColorBtn}; display: inline-flex; align-items: center; gap: 6px; width: fit-content;`;
if (spamBannerTrigger) {
triggerBtn.style.cssText = triggerBtnBase + ' margin-left: auto; margin-top: 4px;';
} else {
triggerBtn.style.cssText = triggerBtnBase + ' position: fixed; top: 8px; right: 8px; z-index: 9998;';
}
triggerBtn.style.cssText = `background-color: ${colors.summary.bg}; border: 1px solid ${colors.summary.border}; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${colors.summary.text}; display: inline-flex; align-items: center; gap: 6px;`;
const triggerIcon = document.createElement('img');
triggerIcon.src = browser.runtime.getURL("/images/ai_summary.png");
triggerIcon.style.cssText = `height: 14px; width: 14px;${isDarkBtn ? ' filter: invert(1);' : ''}`;
triggerIcon.style.cssText = `height: 14px; width: 14px;${colors.isDark ? ' filter: invert(1);' : ''}`;
triggerBtn.appendChild(triggerIcon);
const triggerLabel = document.createElement('span');
triggerLabel.textContent = browser.i18n.getMessage("get_ai_summary");
triggerBtn.appendChild(triggerLabel);
triggerBtn.onmouseover = () => { triggerBtn.style.opacity = '1'; };
triggerBtn.onmouseout = () => { triggerBtn.style.opacity = '0.7'; };
triggerBtn.onclick = async () => {
triggerBtn.onclick = null;
triggerBtn.style.cursor = 'default';
triggerBtn.style.opacity = '0.7';
triggerBtn.onmouseover = null;
triggerBtn.onmouseout = null;
const wrapper = document.getElementById('mzta-summary-trigger-wrapper');
if (wrapper) wrapper.remove(); else triggerBtn.remove();
triggerBtn.onclick = () => {
_removeToolbarItem('mzta-toolbar-summary');
browser.runtime.sendMessage({
command: message.webchat ? "triggerSummaryWebchat" : "triggerSummaryGeneration",
headerMessageId: message.headerMessageId
});
};
if (spamBannerTrigger) {
const triggerWrapper = document.createElement('div');
triggerWrapper.id = 'mzta-summary-trigger-wrapper';
triggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;';
triggerWrapper.appendChild(triggerBtn);
document.body.insertBefore(triggerWrapper, spamBannerTrigger.nextSibling);
} else {
document.body.appendChild(triggerBtn);
}
_addToolbarItem('mzta-toolbar-summary', triggerBtn);
return Promise.resolve(true);
}
case "showTranslation": {
_removePanel('mzta-translation-generating');
_removePanel('mzta-translation-banner');
_removeToolbarItem('mzta-toolbar-translation');
const translationData = message.data;
const colors = _getThemeColors();
const tc = translationData.error ? colors.translErr : colors.translation;
const translationContainer = document.createElement('div');
translationContainer.className = 'thunderai-translation-pane';
translationContainer.style.cssText = `background-color: ${tc.bg}; color: ${tc.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${tc.border}; font-size: 14px;`;
const translationHeader = document.createElement('div');
translationHeader.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 6px;';
const translationIcon = document.createElement('img');
translationIcon.src = browser.runtime.getURL("/images/ai_translate.png");
translationIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`;
const translationTitleSpan = document.createElement('span');
translationTitleSpan.style.cssText = `font-weight: bold; font-size: 14px; color: ${tc.text}; flex-grow: 1;`;
translationTitleSpan.textContent = browser.i18n.getMessage("translate_banner_title") || "AI Translation";
const translationMenu = createThreeDotsMenu(colors.isDark, [
{
icon: '\u21BB',
label: browser.i18n.getMessage("translate_refresh") || 'Refresh translation',
hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8',
disableAfterClick: true,
onClick: () => {
browser.runtime.sendMessage({ command: "refreshTranslation", headerMessageId: translationData.headerMessageId });
}
},
{
icon: '\u00D7',
label: browser.i18n.getMessage("translate_delete") || 'Delete translation',
hoverColor: '#cc0000',
onClick: () => {
_removePanel('mzta-translation-banner');
browser.runtime.sendMessage({ command: "removeTranslation", headerMessageId: translationData.headerMessageId });
}
}
], { bg: tc.bg, border: tc.border, text: tc.text });
const translationBranding = document.createElement('span');
translationBranding.textContent = browser.i18n.getMessage("translate_by") + " ThunderAI";
translationBranding.style.cssText = 'font-style: italic; font-size: 10px; opacity: 0.5; white-space: nowrap;';
translationHeader.appendChild(translationIcon);
translationHeader.appendChild(translationTitleSpan);
translationHeader.appendChild(translationBranding);
translationHeader.appendChild(translationMenu);
translationContainer.appendChild(translationHeader);
const translationTextWrapper = document.createElement('div');
translationTextWrapper.style.cssText = 'flex-grow: 1;';
const translationText = document.createElement('div');
translationText.style.cssText = 'white-space: pre-wrap; line-height: 1.5;';
if (translationData.error) {
translationText.textContent = translationData.message || browser.i18n.getMessage("translate_error");
} else if (translationData.translation_status === '-1') {
translationText.textContent = browser.i18n.getMessage("translate_skipped");
} else {
if (translationData.translated_subject) {
// const subjectEl = document.createElement('div');
// subjectEl.style.cssText = 'font-weight: bold; margin-bottom: 4px;';
if (translationData.lang) {
translationTitleSpan.textContent = '[' + translationData.lang + '] ';
}
translationTitleSpan.textContent += translationData.translated_subject;
// translationTextWrapper.appendChild(subjectEl);
}
const bodyText = translationData.translated_text || '';
const bodyIsHtml = _isHtml(bodyText);
if (bodyIsHtml) {
translationText.style.whiteSpace = '';
_renderSafeHtml(translationText, bodyText);
} else {
translationText.textContent = bodyText;
}
}
translationTextWrapper.appendChild(translationText);
const maxLenTranslation = translationData.maxDisplayLength || 0;
const fullTranslationText = translationData.translated_text || '';
const fullTranslationIsHtml = _isHtml(fullTranslationText);
if (!translationData.error && translationData.translation_status !== '-1' && maxLenTranslation > 0 && fullTranslationText.length > maxLenTranslation) {
translationText.style.overflow = 'hidden';
translationText.style.transition = 'max-height 0.2s ease';
const toggleLink = document.createElement('a');
toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more";
toggleLink.href = '#';
toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`;
if (fullTranslationIsHtml) {
const collapsedMaxHeight = '4.2em';
translationText.style.maxHeight = collapsedMaxHeight;
let expanded = false;
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
if (!expanded) {
translationText.style.maxHeight = translationText.scrollHeight + 'px';
toggleLink.textContent = browser.i18n.getMessage("translate_see_less") || "See less";
} else {
translationText.style.maxHeight = collapsedMaxHeight;
toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more";
}
expanded = !expanded;
});
requestAnimationFrame(() => {
if (translationText.scrollHeight > translationText.clientHeight) {
translationTextWrapper.appendChild(toggleLink);
} else {
translationText.style.maxHeight = '';
translationText.style.overflow = '';
}
});
} else {
let cutPos = fullTranslationText.lastIndexOf(' ', maxLenTranslation);
if (cutPos <= 0) cutPos = maxLenTranslation;
const truncatedTranslation = fullTranslationText.substring(0, cutPos) + '\u2026';
translationText.textContent = truncatedTranslation;
requestAnimationFrame(() => {
translationText.style.maxHeight = translationText.scrollHeight + 'px';
});
let expanded = false;
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
if (!expanded) {
translationText.textContent = fullTranslationText;
translationText.style.maxHeight = translationText.scrollHeight + 'px';
toggleLink.textContent = browser.i18n.getMessage("translate_see_less") || "See less";
} else {
translationText.textContent = truncatedTranslation;
const collapsedHeight = translationText.scrollHeight;
translationText.textContent = fullTranslationText;
translationText.style.maxHeight = translationText.scrollHeight + 'px';
requestAnimationFrame(() => {
translationText.style.maxHeight = collapsedHeight + 'px';
});
translationText.addEventListener('transitionend', function handler() {
translationText.removeEventListener('transitionend', handler);
translationText.textContent = truncatedTranslation;
});
toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more";
}
expanded = !expanded;
});
translationTextWrapper.appendChild(toggleLink);
}
}
translationContainer.appendChild(translationTextWrapper);
_addPanel('mzta-translation-banner', translationContainer);
return Promise.resolve(true);
}
case "showTranslationGenerating": {
if (document.getElementById('mzta-translation-generating')) return Promise.resolve(true);
_removePanel('mzta-translation-banner');
_removeToolbarItem('mzta-toolbar-translation');
const colors = _getThemeColors();
const genContainer = document.createElement('div');
genContainer.className = 'thunderai-translation-pane';
genContainer.style.cssText = `background-color: ${colors.translation.bg}; color: ${colors.translation.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${colors.translation.border}; font-size: 14px; display: flex; align-items: center; gap: 10px;`;
const genIcon = document.createElement('img');
genIcon.src = browser.runtime.getURL("/images/ai_translate.png");
genIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`;
const genLoading = document.createElement('img');
genLoading.src = browser.runtime.getURL("/images/loading.gif");
genLoading.style.cssText = "height: 16px; width: 16px;";
const genTitle = document.createElement('span');
genTitle.textContent = browser.i18n.getMessage("translate_generating") || "Translating...";
genTitle.style.cssText = 'font-size: 14px;';
genContainer.appendChild(genIcon);
genContainer.appendChild(genLoading);
genContainer.appendChild(genTitle);
_addPanel('mzta-translation-generating', genContainer);
return Promise.resolve(true);
}
case "showTranslationButton": {
if (document.getElementById('mzta-toolbar-translation')) return Promise.resolve(true);
const colors = _getThemeColors();
const triggerBtn = document.createElement('div');
triggerBtn.title = browser.i18n.getMessage("translate_click_to_generate") || "Click to translate this email";
triggerBtn.style.cssText = `background-color: ${colors.translation.bg}; border: 1px solid ${colors.translation.border}; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${colors.translation.text}; display: inline-flex; align-items: center; gap: 6px;`;
const triggerIcon = document.createElement('img');
triggerIcon.src = browser.runtime.getURL("/images/ai_translate.png");
triggerIcon.style.cssText = `height: 14px; width: 14px;${colors.isDark ? ' filter: invert(1);' : ''}`;
triggerBtn.appendChild(triggerIcon);
const triggerLabel = document.createElement('span');
triggerLabel.textContent = browser.i18n.getMessage("get_ai_translation") || "Get AI Translation";
triggerBtn.appendChild(triggerLabel);
triggerBtn.onmouseover = () => { triggerBtn.style.opacity = '1'; };
triggerBtn.onmouseout = () => { triggerBtn.style.opacity = '0.7'; };
triggerBtn.onclick = () => {
_removeToolbarItem('mzta-toolbar-translation');
browser.runtime.sendMessage({
command: "triggerTranslationGeneration",
headerMessageId: message.headerMessageId
});
};
_addToolbarItem('mzta-toolbar-translation', triggerBtn);
return Promise.resolve(true);
}
default:
// do nothing
@ -1190,3 +1441,4 @@ switch (message.command) {
browser.runtime.sendMessage({ command: "checkSpamReport" });
browser.runtime.sendMessage({ command: "initSummary" });
browser.runtime.sendMessage({ command: "initTranslation" });

View file

@ -202,8 +202,8 @@ export class mzta_Menus {
switch(curr_prompt.id){
case 'prompt_translate_this':
let prefs2 = await browser.storage.sync.get({default_chatgpt_lang: getLanguageDisplayName(browser.i18n.getUILanguage())});
let chatgpt_lang2 = prefs2.default_chatgpt_lang;
let prefs2 = await browser.storage.sync.get({default_chatgpt_lang: getLanguageDisplayName(browser.i18n.getUILanguage()), translate_lang: ''});
let chatgpt_lang2 = prefs2.translate_lang || prefs2.default_chatgpt_lang;
if(chatgpt_lang2 === ''){
chatgpt_lang2 = getLanguageDisplayName(browser.i18n.getUILanguage());
}

View file

@ -253,6 +253,24 @@ const defaultPlaceholders = [
is_dynamic: "0",
enabled: 1,
},
{
id: 'thunderai_translate_lang',
name: "__MSG_placeholder_thunderai_translate_lang__",
default_value: "",
type: 0,
is_default: "1",
is_dynamic: "0",
enabled: 1,
},
{
id: 'thunderai_translate_exclude_lang',
name: "__MSG_placeholder_thunderai_translate_exclude_lang__",
default_value: "",
type: 0,
is_default: "1",
is_dynamic: "0",
enabled: 1,
},
{
id: 'empty',
name: "__MSG_placeholder_empty__",
@ -599,6 +617,14 @@ export const placeholdersUtils = {
let prefs_def_lang = await browser.storage.sync.get({ default_chatgpt_lang: prefs_default.default_chatgpt_lang });
finalSubs['thunderai_def_lang'] = placeholdersUtils.failSafePlaceholders(prefs_def_lang.default_chatgpt_lang);
break;
case 'thunderai_translate_lang':
let prefs_translate_lang = await browser.storage.sync.get({ translate_lang: prefs_default.translate_lang });
finalSubs['thunderai_translate_lang'] = placeholdersUtils.failSafePlaceholders(prefs_translate_lang.translate_lang);
break;
case 'thunderai_translate_exclude_lang':
let prefs_translate_exclude_lang = await browser.storage.sync.get({ translate_exclude_lang: prefs_default.translate_exclude_lang });
finalSubs['thunderai_translate_exclude_lang'] = placeholdersUtils.failSafePlaceholders(prefs_translate_exclude_lang.translate_exclude_lang);
break;
case 'mail_attachments_info':
let attachments_info_string = "";
let attachments_info = await browser.messages.listAttachments(curr_message.id);

View file

@ -235,24 +235,6 @@ const defaultPrompts = [
is_default: "1",
is_special: "0",
},
{
id: 'prompt_translate_this',
name: "__MSG_prompt_translate_this__",
text: "prompt_translate_this_full_text",
type: "0",
action: "0",
need_selected: "0",
need_signature: "0",
need_custom_text: "0",
define_response_lang: "0",
use_diff_viewer: "0",
chatgpt_web_model: '',
chatgpt_web_project: '',
chatgpt_web_custom_gpt: '',
api_type: '',
is_default: "1",
is_special: "0",
},
{
id: 'prompt_this',
name: "__MSG_prompt_this__",
@ -396,6 +378,22 @@ const specialPrompts = [
api_model: '',
is_default: "1",
is_special: "1",
},
{
id: 'prompt_translate_this',
name: "__MSG_prompt_translate_this__",
text: "prompt_translate_this_full_text",
type: "1",
action: "0",
need_selected: "0",
need_signature: "0",
need_custom_text: "0",
define_response_lang: "0",
use_diff_viewer: "0",
api_type: '',
api_model: '',
is_default: "1",
is_special: "1",
}
];

View file

@ -274,11 +274,24 @@ export class taStorage {
/**
* Write the translation field for a given Message-ID.
* @param {string} messageId - The Message-ID header string.
* @param {string} translated_text - The translated text.
* @param {string} lang - Target language code.
* @param {Object} data - Translation data object.
* @param {string} [data.translated_text=''] - The translated body text.
* @param {string} [data.translated_subject=''] - The translated subject.
* @param {string} [data.translation_status=''] - Status: "1" = ok, "-1" = skipped.
* @param {string} [data.lang=''] - Target language code.
* @param {boolean} [data.error=false] - Whether an error occurred.
* @param {string} [data.message=''] - Error message.
* @param {boolean} [force=true] - If true, overwrite existing translation data.
*/
async writeTranslation(messageId, translated_text, lang, force = true) {
async writeTranslation(messageId, data, force = true) {
const {
translated_text = '',
translated_subject = '',
translation_status = '',
lang = '',
error = false,
message = '',
} = data || {};
this.taLog.log('[writeTranslation] messageId: ' + messageId + ', lang: ' + lang + ', force: ' + force);
try {
let key = this._buildKey(messageId);
@ -288,7 +301,15 @@ export class taStorage {
return;
}
let now = Date.now();
record[taStorage.FIELD_TRANSLATION] = { translated_text: translated_text, lang: lang, ts: now };
record[taStorage.FIELD_TRANSLATION] = {
translated_text,
translated_subject,
translation_status,
lang,
error,
message,
ts: now
};
record.ts = now;
await messenger.storage.local.set({ [key]: record });
} catch (e) {
@ -296,6 +317,64 @@ export class taStorage {
}
}
/**
* Get all records that have a translation field.
* @returns {Promise<Object>} A map of messageId translation data.
*/
async getAllTranslationRecords() {
this.taLog.log('[getAllTranslationRecords] loading all translation records');
try {
let all = await messenger.storage.local.get(null);
let result = {};
for (let [key, record] of Object.entries(all)) {
if (!key.startsWith(taStorage.STORAGE_KEY_PREFIX)) continue;
if (!this.hasField(record, taStorage.FIELD_TRANSLATION)) continue;
let messageId = key.slice(taStorage.STORAGE_KEY_PREFIX.length);
let translation = record[taStorage.FIELD_TRANSLATION];
result[messageId] = {
headerMessageId: messageId,
translated_text: translation.translated_text,
lang: translation.lang || '',
error: translation.error || false,
message: translation.message || '',
translation_date: new Date(translation.ts),
};
}
return result;
} catch (e) {
this.taLog.error('getAllTranslationRecords error: ' + e);
return {};
}
}
/**
* Delete only the translation field from a record.
* Deletes the entire record if no other data fields remain.
* @param {string} messageId - The Message-ID header string.
*/
async deleteTranslationField(messageId) {
this.taLog.log('[deleteTranslationField] messageId: ' + messageId);
try {
let key = this._buildKey(messageId);
let record = await this.getRecord(messageId);
if (!record || !(taStorage.FIELD_TRANSLATION in record)) {
this.taLog.log('[deleteTranslationField] no translation field found for messageId: ' + messageId);
return;
}
delete record[taStorage.FIELD_TRANSLATION];
const remainingFields = Object.keys(record).filter(k => k !== 'v' && k !== 'ts');
if (remainingFields.length === 0) {
this.taLog.log('[deleteTranslationField] no remaining fields, deleting entire record');
await messenger.storage.local.remove(key);
} else {
this.taLog.log('[deleteTranslationField] remaining fields: ' + remainingFields.join(', '));
await messenger.storage.local.set({ [key]: record });
}
} catch (e) {
this.taLog.error('deleteTranslationField error: ' + e);
}
}
/**
* Delete the entire record for a given Message-ID.
* @param {string} messageId - The Message-ID header string.

152
js/mzta-translationstore.js Normal file
View file

@ -0,0 +1,152 @@
/*
* ThunderAI [https://micz.it/thunderbird-addon-thunderai/]
* Copyright (C) 2024 - 2026 Mic (m@micz.it)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { taStorage } from './mzta-storage.js';
import { taLogger } from './mzta-logger.js';
export class taTranslationStore {
_processing_prefix = 'mzta-translation-processing-';
_max_translations = 100;
_storage = null;
taLog = null;
constructor(do_debug = false) {
this._storage = new taStorage(do_debug);
this.taLog = new taLogger('mzta-translationstore', do_debug);
}
async setProcessing(data_id) {
this.taLog.log("[setProcessing] data_id: " + data_id);
const key = this._processing_prefix + data_id;
await browser.storage.session.set({ [key]: true });
}
async isProcessing(data_id) {
this.taLog.log("[isProcessing] data_id: " + data_id);
const key = this._processing_prefix + data_id;
let output = await browser.storage.session.get(key);
let result = output[key] || false;
this.taLog.log("[isProcessing] result: " + result);
return result;
}
async saveTranslation(data, data_id) {
this.taLog.log("[saveTranslation] data_id: " + data_id);
try {
await this._storage.writeTranslation(data_id, data, true);
await browser.storage.session.remove(this._processing_prefix + data_id);
} catch (e) {
this.taLog.error("[saveTranslation] error: " + e);
throw e;
}
}
async saveError(data_id, error_message) {
this.taLog.log("[saveError] data_id: " + data_id + ", error_message: " + error_message);
let data = {
translated_text: '',
lang: '',
error: true,
message: error_message,
headerMessageId: data_id
};
await this.saveTranslation(data, data_id);
return data;
}
async loadTranslation(data_id) {
this.taLog.log("[loadTranslation] data_id: " + data_id);
let record = await this._storage.getRecord(data_id);
if (!record || !this._storage.hasField(record, taStorage.FIELD_TRANSLATION)) {
this.taLog.log("[loadTranslation] no record found for data_id: " + data_id);
return null;
}
let translation = record[taStorage.FIELD_TRANSLATION];
return {
headerMessageId: data_id,
translated_text: translation.translated_text || '',
translated_subject: translation.translated_subject || '',
translation_status: translation.translation_status || '',
lang: translation.lang || '',
error: translation.error || false,
message: translation.message || '',
translation_date: new Date(translation.ts),
};
}
async removeTranslation(data_id) {
this.taLog.log("[removeTranslation] data_id: " + data_id);
await this._storage.deleteTranslationField(data_id);
await browser.storage.session.remove(this._processing_prefix + data_id);
}
async getAllTranslations() {
this.taLog.log("[getAllTranslations] loading all translations");
return await this._storage.getAllTranslationRecords();
}
async clearTranslations() {
this.taLog.log("[clearTranslations] clearing all translation data");
let allTranslations = await this._storage.getAllTranslationRecords();
let translationKeys = Object.keys(allTranslations);
this.taLog.log("[clearTranslations] deleting " + translationKeys.length + " translation records");
for (let messageId of translationKeys) {
await this._storage.deleteTranslationField(messageId);
}
let allSession = await browser.storage.session.get(null);
let keysToDelete = Object.keys(allSession).filter(k => k.startsWith(this._processing_prefix));
this.taLog.log("[clearTranslations] deleting " + keysToDelete.length + " session keys");
for (let key of keysToDelete) {
await browser.storage.session.remove(key);
}
}
async truncTranslations() {
this.taLog.log("[truncTranslations] checking translation count");
let data = await this._storage.getAllTranslationRecords();
let sortedData = this.sortTranslationsByDate(data);
let keys = Object.keys(sortedData);
this.taLog.log("[truncTranslations] total translations: " + keys.length + ", max: " + this._max_translations);
if (keys.length > this._max_translations) {
let toDelete = keys.length - this._max_translations;
this.taLog.log("[truncTranslations] truncating " + toDelete + " oldest translations");
for (let i = this._max_translations; i < keys.length; i++) {
await this._storage.deleteTranslationField(keys[i]);
}
}
}
sortTranslationsByDate(data) {
if (!data) return {};
const translationKeys = Object.keys(data);
translationKeys.sort((a, b) => {
const dateA = new Date(data[a].translation_date);
const dateB = new Date(data[b].translation_date);
return dateB - dateA;
});
let sortedTranslations = {};
translationKeys.forEach((key) => {
sortedTranslations[key] = data[key];
});
return sortedTranslations;
}
}

View file

@ -173,6 +173,33 @@ export const taPromptUtils = {
return { promptText, promptInfo: prompt };
},
async buildTranslationPrompt(fullMessage) {
const specialPrompts = await getSpecialPrompts();
const prompt = specialPrompts.find(p => p.id === 'prompt_translate_this');
let promptText = prompt.text;
if (promptText === 'prompt_translate_this_full_text') {
promptText = browser.i18n.getMessage('prompt_translate_this_full_text');
}
const bodyHtml = getMailBody(fullMessage);
const mailSubject = fullMessage.headers?.subject?.[0] || '';
const finalSubs = await placeholdersUtils.getPlaceholdersValues({
prompt_text: promptText,
msg_text: { html: bodyHtml.html, text: bodyHtml.text },
mail_subject: mailSubject,
});
const fullPrompt = placeholdersUtils.replacePlaceholders({
text: promptText,
replacements: finalSubs,
use_default_value: false,
});
return { promptText: fullPrompt, promptInfo: prompt };
},
/**
* Extracts tags from the response text.
* @param {string} response_text - The response text from which to extract tags.

View file

@ -27,10 +27,12 @@ export const getMenuContextDisplay = () => 'message_display_action_menu';
export const contextMenuID_AddTags = 'mzta-add-tags';
export const contextMenuID_Spamfilter = 'mzta-spamfilter';
export const contextMenuID_Summarize = 'mzta-summarize';
export const contextMenuID_Translate = 'mzta-translate';
export const contextMenuIconsPath = {
[contextMenuID_AddTags]: 'moz-extension:images/autotags.png',
[contextMenuID_Spamfilter]: 'moz-extension:images/spamfilter.png',
[contextMenuID_Summarize]: 'moz-extension:images/summarize.png',
[contextMenuID_AddTags]: 'moz-extension:images/menu_autotags.png',
[contextMenuID_Spamfilter]: 'moz-extension:images/menu_spamfilter.png',
[contextMenuID_Summarize]: 'moz-extension:images/menu_summarize.png',
[contextMenuID_Translate]: 'moz-extension:images/menu_translate.png',
};
export function getLanguageDisplayName(languageCode) {
@ -517,6 +519,8 @@ function getTagsKeyFromLabel(tag_names, all_tags_list) {
}
function sanitizeString(input) {
// Replaces accented characters with their non-accented version
input = input.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
// Define the regex to match valid characters
const validChar = /^[^ ()/{%*<>"]+$/;
// Filter out invalid characters from the string

View file

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "ThunderAI",
"description": "__MSG_extensionDescription__",
"version": "4.1.0",
"version": "4.1.0pre1",
"author": "Mic (m@micz.it)",
"homepage_url": "https://micz.it/thunderbird-addon-thunderai/",
"browser_specific_settings": {

View file

@ -45,6 +45,7 @@ import {
contextMenuID_AddTags,
contextMenuID_Spamfilter,
contextMenuID_Summarize,
contextMenuID_Translate,
contextMenuIconsPath,
sanitizeChatGPTModelData,
sanitizeChatGPTWebCustomData,
@ -62,6 +63,7 @@ import {
} from './js/mzta-prompts.js';
import { taSpamReport } from './js/mzta-spamreport.js';
import { taSummaryStore } from './js/mzta-summarystore.js';
import { taTranslationStore } from './js/mzta-translationstore.js';
import { taWorkingStatus } from './js/mzta-working-status.js';
import {
addTags_getExclusionList,
@ -95,6 +97,7 @@ let taLog = new taLogger("mzta-background",prefs_init.do_debug);
taWorkingStatus.taLog = taLog;
let spamReport = new taSpamReport(prefs_init.do_debug);
let summaryStore = new taSummaryStore(prefs_init.do_debug);
let translationStore = new taTranslationStore(prefs_init.do_debug);
let special_prompts_ids = getActiveSpecialPromptsIDs({
addtags: prefs_init.add_tags,
@ -328,6 +331,75 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => {
// case 'chatgpt_open':
// openChatGPT(message.prompt,message.action,message.tabId);
// return true;
case 'initTranslation':
async function _initTranslation() {
try {
let tabId = sender.tab.id;
let prefs = await browser.storage.sync.get({ translate: prefs_default.translate, translate_auto: prefs_default.translate_auto, translate_max_display_length: prefs_default.translate_max_display_length });
if (!prefs.translate) return;
let message = await browser.messageDisplay.getDisplayedMessage(tabId);
if (!message) return;
// Always show cached translation if available, regardless of translate_auto
let cachedTranslation = await translationStore.loadTranslation(message.headerMessageId);
if (cachedTranslation && !cachedTranslation.error) {
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } });
return;
}
if (await translationStore.isProcessing(message.headerMessageId)) {
browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" });
return;
}
// If translate_auto is disabled, don't show button or auto-generate
if (prefs.translate_auto === 0) return;
// Auto mode (translate_auto === 2) always generates inline
if (prefs.translate_auto === 2) {
_generateTranslationForMessage(message.headerMessageId, tabId);
return;
}
// Manual button mode (translate_auto === 1)
browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId });
} catch (e) {
taLog.error("Error in initTranslation: " + e);
}
}
_initTranslation();
break;
case 'triggerTranslationGeneration':
async function _triggerTranslationGeneration(message) {
let tabId = sender.tab.id;
let prefs_tl = await browser.storage.sync.get({
translate_lang: prefs_default.translate_lang,
default_chatgpt_lang: prefs_default.default_chatgpt_lang
});
const lang_tl = prefs_tl.translate_lang || prefs_tl.default_chatgpt_lang || '';
if (!lang_tl) {
let tabs = await browser.tabs.query({ active: true, currentWindow: true });
browser.tabs.sendMessage(tabId, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage('translate_no_language_configured') });
browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId });
return;
}
await _generateTranslationForMessage(message.headerMessageId, tabId);
}
_triggerTranslationGeneration(message);
break;
case 'refreshTranslation':
async function _refreshTranslation(message) {
let tabId = sender.tab.id;
await translationStore.removeTranslation(message.headerMessageId);
await _generateTranslationForMessage(message.headerMessageId, tabId);
}
_refreshTranslation(message);
break;
case 'removeTranslation':
translationStore.removeTranslation(message.headerMessageId);
break;
case 'chatgpt_close':
async function _closeChatGptWindow(window_id) {
let prefs_close = await browser.storage.sync.get({chatgpt_win_save_position: prefs_default.chatgpt_win_save_position});
@ -591,6 +663,106 @@ async function _generateSummaryForMessage(headerMessageId, tabId = null, options
}
}
// tabId is optional — if null, runs silently (background pre-cache, no UI update)
// options.messageData: { fullMessage } — pass pre-fetched data to avoid re-querying
async function _generateTranslationForMessage(headerMessageId, tabId = null, options = {}) {
try {
let prefs = await browser.storage.sync.get({
connection_type: prefs_default.connection_type,
do_debug: prefs_default.do_debug,
default_chatgpt_lang: prefs_default.default_chatgpt_lang,
translate_lang: prefs_default.translate_lang,
translate_max_display_length: prefs_default.translate_max_display_length,
...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type'])
});
let cachedTranslation = await translationStore.loadTranslation(headerMessageId);
if (cachedTranslation && !cachedTranslation.error) {
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } });
return;
}
const lang = prefs.translate_lang || prefs.default_chatgpt_lang || '';
if (!lang) {
taLog.warn("Translation skipped: no language configured (translate_lang and default_chatgpt_lang are both empty).");
return;
}
if (await translationStore.isProcessing(headerMessageId)) {
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" });
return;
}
await translationStore.setProcessing(headerMessageId);
taWorkingStatus.startWorking();
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" });
let fullMessage;
if (options.messageData) {
fullMessage = options.messageData.fullMessage;
} else {
const messageResult = await browser.messages.query({ headerMessageId: headerMessageId });
if (!messageResult || messageResult.messages.length === 0) {
await translationStore.saveError(headerMessageId, "Message not found");
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: "Message not found" } });
taWorkingStatus.stopWorking();
return;
}
fullMessage = await browser.messages.getFull(messageResult.messages[0].id);
}
const connectionType = getConnectionType(prefs, {}, 'translate');
if (connectionType === 'chatgpt_web') {
const errorMsg = browser.i18n.getMessage('translate_chatgpt_web_not_supported');
await translationStore.saveError(headerMessageId, errorMsg);
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: errorMsg } });
taWorkingStatus.stopWorking();
return;
}
const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage);
const cmd = new mzta_specialCommand({
prompt: promptText,
llm: connectionType,
do_debug: prefs.do_debug,
config: {}
});
await cmd.initWorker();
const aiResponse = await cmd.sendPrompt();
let translatedBody = '';
let translatedSubject = '';
let translationStatus = '';
try {
const parsed = JSON.parse(aiResponse);
translatedBody = parsed.body || '';
translatedSubject = parsed.subject || '';
translationStatus = String(parsed.status || '');
} catch (e) {
translatedBody = aiResponse;
}
const translationData = {
translated_text: translatedBody,
translated_subject: translatedSubject,
translation_status: translationStatus,
lang: lang,
headerMessageId: headerMessageId
};
await translationStore.saveTranslation(translationData, headerMessageId);
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData, maxDisplayLength: prefs.translate_max_display_length } });
taWorkingStatus.stopWorking();
} catch (error) {
console.error("[ThunderAI] Error generating translation:", error);
await translationStore.saveError(headerMessageId, error.message || String(error));
if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: error.message || "Failed to generate translation" } });
taWorkingStatus.stopWorking();
}
}
// options.messageData: { message, fullMessage, body_text, msg_text } — pass pre-fetched data to avoid re-querying
// options.prefs: pass pre-fetched prefs to avoid re-querying
// options.autoMove: if true, move spam messages to junk folder (default: false)
@ -1171,13 +1343,15 @@ async function reload_pref_init(){
spamfilter: prefs_default.spamfilter,
summarize: prefs_default.summarize,
summarize_auto: prefs_default.summarize_auto,
translate: prefs_default.translate,
translate_auto: prefs_default.translate_auto,
spamfilter_threshold: prefs_default.spamfilter_threshold,
spamfilter_show_msg_panel: prefs_default.spamfilter_show_msg_panel,
dynamic_menu_force_enter: prefs_default.dynamic_menu_force_enter,
chatgpt_win_save_position: prefs_default.chatgpt_win_save_position,
...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type'])
});
_process_incoming = prefs_init.add_tags_auto || prefs_init.spamfilter || (prefs_init.summarize && prefs_init.summarize_auto === 3);
_process_incoming = prefs_init.add_tags_auto || prefs_init.spamfilter || (prefs_init.summarize && prefs_init.summarize_auto === 3) || (prefs_init.translate && prefs_init.translate_auto === 3);
_sparks_presence = await checkSparksPresence();
}
@ -1348,6 +1522,13 @@ function addContextMenuItems() {
removeContextMenu(contextMenuID_Summarize);
}
// Add Context menu: Translate
if(prefs_init.translate && checkAPIIntegration(prefs_init.connection_type, prefs_init.translate_use_specific_integration, prefs_init.translate_connection_type)){
itemsToAdd.push(contextMenuID_Translate);
} else {
removeContextMenu(contextMenuID_Translate);
}
itemsToAdd.sort((a, b) => {
let titleA = browser.i18n.getMessage("context_menu_" + a);
let titleB = browser.i18n.getMessage("context_menu_" + b);
@ -1366,6 +1547,7 @@ browser.menus.onClicked.addListener( (info, tab) => {
let _add_tags = false
let _spamfilter = false
let _summarize = false;
let _translate = false;
if(info.menuItemId === contextMenuID_AddTags){
_add_tags = true;
}
@ -1375,12 +1557,16 @@ browser.menus.onClicked.addListener( (info, tab) => {
if(info.menuItemId === contextMenuID_Summarize) {
_summarize = true;
}
if(_add_tags || _spamfilter || _summarize){
if(info.menuItemId === contextMenuID_Translate) {
_translate = true;
}
if(_add_tags || _spamfilter || _summarize || _translate){
processEmails({
messages: getMessages(info.selectedMessages),
addTagsAuto: _add_tags,
spamFilter: _spamfilter,
summarize: _summarize
summarize: _summarize,
translate: _translate
});
}
});
@ -1405,7 +1591,8 @@ const newEmailListener = (folder, messagesList) => {
messages: messages,
addTagsAuto: add_tags_auto_enabled,
spamFilter: prefs_init.spamfilter,
summarizeOnReceive: prefs_init.summarize && prefs_init.summarize_auto === 3
summarizeOnReceive: prefs_init.summarize && prefs_init.summarize_auto === 3,
translateOnReceive: prefs_init.translate && prefs_init.translate_auto === 3
});
if(prefs_init.spamfilter){
@ -1439,15 +1626,17 @@ async function processEmails(args) {
addTagsAuto = false,
spamFilter = false,
summarize = false,
summarizeOnReceive = false
summarizeOnReceive = false,
translateOnReceive = false,
translate = false
} = args;
taWorkingStatus.startWorking();
// One loop handles addTagsAuto, spamFilter, and summarizeOnReceive (on email receive).
// One loop handles addTagsAuto, spamFilter, summarizeOnReceive, and translateOnReceive (on email receive).
// The separate summarize block below handles the context menu flow.
if (addTagsAuto || spamFilter || summarizeOnReceive) {
if (addTagsAuto || spamFilter || summarizeOnReceive || translateOnReceive || translate) {
let prefs_aats = await browser.storage.sync.get({
add_tags_maxnum: prefs_default.add_tags_maxnum,
connection_type: prefs_default.connection_type,
@ -1552,6 +1741,21 @@ async function processEmails(args) {
messageData: { message, fullMessage: curr_fullMessage }
});
}
if (translateOnReceive || translate) {
if (!curr_fullMessage) {
curr_fullMessage = await browser.messages.getFull(message.id);
}
let translateTabId = null;
if (translate) {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
translateTabId = tabs[0].id;
}
taLog.log("[ThunderAI] Generating translation for: " + message.headerMessageId);
await _generateTranslationForMessage(message.headerMessageId, translateTabId, {
messageData: { fullMessage: curr_fullMessage }
});
}
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const special_prompts_with_integration = ['add_tags', 'spamfilter', 'summarize', 'get_calendar_event', 'get_task'];
const special_prompts_with_integration = ['add_tags', 'spamfilter', 'summarize', 'get_calendar_event', 'get_task', 'translate'];
export const integration_options_config = {
chatgpt: {
@ -141,6 +141,11 @@ export const prefs_default = {
summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic on message open, 3: generate on email receive
summarize_display_mode: 'inline', // 'inline' or 'webchat'
summarize_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline
translate: true,
translate_auto: 0, // 0: disabled, 1: manual button, 2: automatic on message open, 3: generate on email receive
translate_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline
translate_lang: '', // target language, fallback on default_chatgpt_lang
translate_exclude_lang: '', // languages to do not translate
spamfilter_show_msg_panel: true,
...generated_prefs
}

View file

@ -203,6 +203,17 @@
</label>
</td>
</tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate__</span>
<br><button id="btnManageTranslateInfo" class="btn_small">__MSG_prefs_OptionText_btnManageTranslateInfo__</button></td>
<td>
<label>
<input type="checkbox" id="translate" name="translate" class="option-input" />
__MSG_prefs_OptionText_translate_Info__
<br><span class="warn_API_needed" id="translate_warn_API_needed">__MSG_warn_API_needed__</span>
</label>
</td>
</tr>
<tr class="get_calendar_event_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_get_calendar_event__</span>
<br><button id="btnManageCalendarEventInfo" class="btn_small">__MSG_prefs_OptionText_btnManageCalendarEventInfo__</button></td>
@ -242,10 +253,11 @@
</td>
</tr>
<tr>
<td><span class="opt_title">__MSG_prefs_storage_title__</span></td>
<td><span class="opt_title">ThunderAI __MSG_prefs_storage_title__</span></td>
<td>
__MSG_prefs_storage_size__: <span id="cache_storage_size"></span>
&nbsp;<button id="btnClearCache" class="btn_small">__MSG_prefs_storage_clear_button__</button>
<br>__MSG_prefs_storage_info__
</td>
</tr>
</table>

View file

@ -195,6 +195,27 @@ function disable_Summarize(prefs_opt){
}
}
function disable_Translate(prefs_opt){
let translate = document.getElementById('translate');
let conntype_select = document.getElementById("connection_type");
const tempPrefs = {
connection_type: conntype_select.value,
...prefs_opt
};
let translate_disabled = (getConnectionType(tempPrefs, null, 'translate') === "chatgpt_web");
let translate_checked_original = translate.checked;
translate.checked = translate_disabled ? false : translate.checked;
if(!translate.checked){
let translate_info_btn = document.getElementById('btnManageTranslateInfo');
translate_info_btn.disabled = 'disabled';
}
let translate_warn_API_needed = document.getElementById('translate_warn_API_needed');
translate_warn_API_needed.style.display = (translate_disabled) ? 'inline-block' : 'none';
if(translate_checked_original != translate.checked){
browser.storage.sync.set({translate: translate.checked});
}
}
async function disable_GetCalendarEvent(){
let get_calendar_event = document.getElementById('get_calendar_event');
let get_task = document.getElementById('get_task');
@ -300,6 +321,13 @@ document.addEventListener('DOMContentLoaded', async () => {
});
summarize_info_btn.disabled = summarize_el.checked ? '' : 'disabled';
let translate_el = document.getElementById('translate');
let translate_info_btn = document.getElementById('btnManageTranslateInfo');
translate_el.addEventListener('click', (event) => {
translate_info_btn.disabled = event.target.checked ? '' : 'disabled';
});
translate_info_btn.disabled = translate_el.checked ? '' : 'disabled';
let get_calendar_event_el = document.getElementById('get_calendar_event');
let get_calendar_event_info_btn = document.getElementById('btnManageCalendarEventInfo');
get_calendar_event_el.addEventListener('click', (event) => {
@ -334,6 +362,10 @@ document.addEventListener('DOMContentLoaded', async () => {
openTab('/pages/summarize/mzta-summarize.html');
});
document.getElementById('btnManageTranslateInfo').addEventListener('click', () => {
openTab('/pages/translate/mzta-translate.html');
});
document.getElementById('btnManageCalendarEventInfo').addEventListener('click', () => {
openTab('/pages/get-calendar-event/mzta-get-calendar-event.html');
});
@ -360,6 +392,7 @@ document.addEventListener('DOMContentLoaded', async () => {
conntype_select.addEventListener("change", () => disable_AddTags(prefs_opt));
conntype_select.addEventListener("change", () => disable_SpamFilter(prefs_opt));
conntype_select.addEventListener("change", () => disable_Summarize(prefs_opt));
conntype_select.addEventListener("change", () => disable_Translate(prefs_opt));
conntype_select.addEventListener("change", disable_GetCalendarEvent);
showConnectionOptions(conntype_select);
@ -367,6 +400,7 @@ document.addEventListener('DOMContentLoaded', async () => {
disable_AddTags(prefs_opt);
disable_SpamFilter(prefs_opt);
disable_Summarize(prefs_opt);
disable_Translate(prefs_opt);
disable_GetCalendarEvent();
document.getElementById('reset_max_prompt_length').addEventListener('click', resetMaxPromptLength);

View file

@ -10,7 +10,8 @@
<h2>Version 4.1.0 - ??/??/2026</h2>
<ul>
<li>Antispam information are now permanently saved for each message [<a href="https://github.com/micz/ThunderAI/issues/675">#675</a>].</li>
<li><i>[All APIs]</i> A summaru has been added above the mail content [<a href="https://github.com/micz/ThunderAI/issues/580">#580</a>].</li>
<li><i>[All APIs]</i> A summary has been added above the mail content [<a href="https://github.com/micz/ThunderAI/issues/580">#580</a>].</li>
<li><i>[All APIs]</i> Inline auto translation for emails added [<a href="https://github.com/micz/ThunderAI/issues/247">#247</a>].</li>
<li>...</li>
</ul>
<h2>Version 4.0.3 - 20/03/2026</h2>

View file

@ -31,9 +31,9 @@
<td>
<label>
<select id="summarize_auto" name="summarize_auto" class="option-input">
<option value="0">__MSG_prefs_OptionText_summarize_auto_disabled__</option>
<option value="1">__MSG_prefs_OptionText_summarize_auto_manual__</option>
<option value="2">__MSG_prefs_OptionText_summarize_auto_automatic__</option>
<option value="0">__MSG_prefs_OptionText_action_auto_disabled__</option>
<option value="1">__MSG_prefs_OptionText_action_auto_manual__</option>
<option value="2">__MSG_prefs_OptionText_action_auto_automatic__</option>
<option value="3">__MSG_prefs_OptionText_action_auto_batch__</option>
</select>
<br>__MSG_prefs_OptionText_summarize_auto_Info__
@ -45,8 +45,8 @@
<td>
<label>
<select id="summarize_display_mode" name="summarize_display_mode" class="option-input">
<option value="inline">__MSG_prefs_OptionText_summarize_display_mode_inline__</option>
<option value="webchat">__MSG_prefs_OptionText_summarize_display_mode_webchat__</option>
<option value="inline">__MSG_prefs_OptionText_display_mode_inline__</option>
<option value="webchat">__MSG_prefs_OptionText_display_mode_webchat__</option>
</select>
<br>__MSG_prefs_OptionText_summarize_display_mode_Info__
</label>

View file

@ -189,6 +189,8 @@ function updateDisplayModeConstraint() {
display_mode_el.value = 'inline';
display_mode_el.disabled = true;
browser.storage.sync.set({ summarize_display_mode: 'inline' });
} else if (autoVal === '0') {
display_mode_el.disabled = true;
} else {
display_mode_el.disabled = false;
}

View file

@ -0,0 +1,180 @@
#translate_prompt_container {
width: 90%;
margin: 20px auto;
}
#translate_prompt_text {
width: 100%;
}
.infoline {
font-size: 0.8em;
font-style: italic;
}
#account_selector_container{
display: none;
}
#account_selector_checkboxes{
padding: 10px;
border: 1px solid gray;
width: 24em;
margin-bottom: 10px;
margin-top: 10px;
}
.specific_integration{
background-color: #dfeaff;
}
table {
border-collapse: separate;
border-spacing: 0;
}
.group td {
border-top: none;
border-bottom: none;
}
.specific_integration_sub td:first-child {
border-left: 10px solid #dfeaff;
}
.specific_integration_sub td:last-child {
border-right: 10px solid #dfeaff;
}
#connection_ui_end td{
height: 6px;
background-color: #dfeaff;
}
#connection_ui_end{
display: none;
}
.btn_div {
width: 100%;
display: flex;
justify-content: space-between;
}
table#miczPrefs {
padding: 10px;
border-spacing: 0px;
width: 90%;
margin: 0px auto 40px auto;
border: 1px solid #ccc;
}
.translate_table_title{
width: 90%;
margin: auto;
}
.section_title {
font-weight: bold;
}
table#miczPrefs td {
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
vertical-align: top;
padding: 2px;
}
table#miczPrefs tr:first-child td {
border-top: none;
}
table#miczPrefs tr:last-child td {
border-bottom: none;
}
.autocomplete-container {
position: relative;
}
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: white;
border: 1px solid #ccc;
z-index: 1000;
max-height: 200px;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
font-size: small;
}
.autocomplete-list li {
padding: 8px;
cursor: pointer;
}
.autocomplete-list li:hover {
background-color: #f0f0f0;
}
.autocomplete-list li.active {
background-color: #ddd;
}
.hidden {
display: none;
}
.unsaved {
color: red;
}
label:has(input[type="checkbox"]) {
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1c1b22;
color: rgb(251, 251, 254);
}
a:link { color: #409eff; }
a:visited { color: #409eff; }
a:hover { color: #66b1ff; }
a:active { color: #66b1ff; }
.autocomplete-list {
background-color: #2e2f36;
border: 1px solid #2e2f36;
}
.autocomplete-list li:hover {
background-color: #4c4e58;
}
.autocomplete-list li.active {
background-color: #4c4e58;
}
.specific_integration{
background-color: #2E3A4F;
}
.specific_integration_sub td:first-child {
border-left: 10px solid #2E3A4F;
}
.specific_integration_sub td:last-child {
border-right: 10px solid #2E3A4F;
}
#connection_ui_end td{
background-color: #2E3A4F;
}
}

View file

@ -0,0 +1,118 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>ThunderAI - __MSG_Translate_PageTitle__</title>
<link rel="stylesheet" href="mzta-translate.css" />
<link rel="stylesheet" href="../_lib/connection-ui.css">
<link rel="icon" href="../../images/icon-16px.png" />
</head>
<body>
<div>
<h1 class="page_title">__MSG_Translate_PageTitle__</h1>
<p>__MSG_Translate_info_default__</p>
</div>
<div class="translate_table_title section_title">__MSG_Translate_prompt_prefs_title__</div>
<table id="miczPrefs">
<tr class="translate_tr specific_integration">
<td><span class="opt_title">__MSG_prefs_OptionText_use_specific_integration__</span>
</td>
<td>
<label>
<input type="checkbox" id="translate_use_specific_integration" name="translate_use_specific_integration" class="option-input" />
__MSG_prefs_OptionText_translate_use_specific_integration_Info__
</label>
</td>
</tr>
<tr id="connection_ui_anchor"></tr>
<tr id="connection_ui_end"><td colspan="2"></td></tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate_auto__</span></td>
<td>
<label>
<select id="translate_auto" name="translate_auto" class="option-input">
<option value="0">__MSG_prefs_OptionText_action_auto_disabled__</option>
<option value="1">__MSG_prefs_OptionText_action_auto_manual__</option>
<option value="2">__MSG_prefs_OptionText_action_auto_automatic__</option>
<option value="3">__MSG_prefs_OptionText_action_auto_batch__</option>
</select>
<br>__MSG_prefs_OptionText_translate_auto_Info__
</label>
</td>
</tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate_max_display_length__</span></td>
<td>
<label>
<input type="number" id="translate_max_display_length" name="translate_max_display_length" min="0" class="option-input" />
<br>__MSG_prefs_OptionText_translate_max_display_length_Info__
</label>
</td>
</tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate_lang__</span></td>
<td>
<label>
<input type="text" id="translate_lang" name="translate_lang" class="option-input" />
<br>__MSG_prefs_OptionText_translate_lang_Info__
<br>__MSG_placeholder_string__: <i>{%thunderai_translate_lang%}</i>
</label>
</td>
</tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate_exclude_lang__</span></td>
<td>
<label>
<input type="text" id="translate_exclude_lang" name="translate_exclude_lang" class="option-input" />
<br>__MSG_prefs_OptionText_translate_exclude_lang_Info__
<br>__MSG_placeholder_string__: <i>{%thunderai_translate_exclude_lang%}</i>
</label>
</td>
</tr>
</table>
<!-- PROMPTS -->
<div id="translate_prompt_container">
<!--title and description-->
<span class="section_title">
__MSG_Translate_prompt_text_title__
</span>
<br>
<span class="infoline">
__MSG_prefs_OptionText_btnManagePrompts_infoline__
<a href="https://micz.it/thunderbird-addon-thunderai/data-placeholders/">
__MSG_more_info_string__
</a>
</span>
<br>
<!--main prompt-->
<span class="infoline">
__MSG_prefs_OptionText_Translate_main_prompt__
</span>
<br>
<div class="autocomplete-container">
<textarea id="translate_prompt_text" rows="15"></textarea>
<ul class="autocomplete-list hidden"></ul>
</div>
<div id="translate_info_additional_statements"></div>
<div class="btn_div">
<button id="btn_reset_prompt" disabled>
__MSG_reset_default__
</button>
<button id="btn_save_prompt" disabled>
__MSG_save__
</button>
</div>
</div>
<script src="mzta-translate.js" type="module"></script>
<script src="../../js/mzta-i18n.js"></script>
</body>
</html>

View file

@ -0,0 +1,231 @@
/*
* ThunderAI [https://micz.it/thunderbird-addon-thunderai/]
* Copyright (C) 2024 - 2026 Mic (m@micz.it)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
prefs_default,
integration_options_config
} from '../../options/mzta-options-default.js';
import { taLogger } from "../../js/mzta-logger.js";
import {
getSpecialPrompts,
setSpecialPrompts
} from "../../js/mzta-prompts.js";
import {
getPlaceholders,
mapPlaceholderToSuggestion
} from "../../js/mzta-placeholders.js";
import { textareaAutocomplete } from "../../js/mzta-placeholders-autocomplete.js";
import {
normalizeStringList,
isAPIKeyValue,
setTomSelectBorder
} from "../../js/mzta-utils.js";
import {
initializeSpecificIntegrationUI
} from "../_lib/connection-ui.js";
let autocompleteSuggestions = [];
let taLog = new taLogger("mzta-translate-page", true);
document.addEventListener("DOMContentLoaded", async () => {
let specialPrompts = await getSpecialPrompts();
let translate_prompt = specialPrompts.find((prompt) => prompt.id === 'prompt_translate_this');
if (translate_prompt && translate_prompt.api_type && translate_prompt.api_type !== '') {
let update_prefs = {};
update_prefs['translate_connection_type'] = translate_prompt.api_type;
let integration = translate_prompt.api_type.replace('_api', '');
if (integration_options_config && integration_options_config[integration]) {
for (const key of Object.keys(integration_options_config[integration])) {
if (translate_prompt[key] !== undefined) {
update_prefs[`translate_${integration}_${key}`] = translate_prompt[key];
}
}
}
await browser.storage.sync.set(update_prefs);
}
await initializeSpecificIntegrationUI({
prefix: 'translate',
promptId: 'prompt_translate_this',
taLog: taLog,
restoreOptionsCallback: restoreOptions
});
i18n.updateDocument();
document.querySelectorAll(".option-input").forEach(element => {
element.addEventListener("change", saveOptions);
});
let translate_textarea = document.getElementById("translate_prompt_text");
let translate_save_btn = document.getElementById("btn_save_prompt");
let translate_reset_btn = document.getElementById("btn_reset_prompt");
// on changing textarea
translate_textarea.addEventListener("input", (event) => {
translate_reset_btn.disabled = (event.target.value === browser.i18n.getMessage('prompt_translate_this_full_text'));
translate_save_btn.disabled = (event.target.value === translate_prompt.text);
});
// on clicking reset button
translate_reset_btn.addEventListener("click", () => {
translate_textarea.value = browser.i18n.getMessage("prompt_translate_this_full_text");
translate_reset_btn.disabled = true;
let event = new Event("input", { bubbles: true, cancelable: true });
translate_textarea.dispatchEvent(event);
});
// on clicking save button
translate_save_btn.addEventListener("click", () => {
specialPrompts.find(prompt => prompt.id === 'prompt_translate_this').text = translate_textarea.value;
setSpecialPrompts(specialPrompts);
translate_save_btn.disabled = true;
browser.runtime.sendMessage({ command: "reload_menus" });
});
if(translate_prompt.text === 'prompt_translate_this_full_text'){
translate_prompt.text = browser.i18n.getMessage(translate_prompt.text);
}
translate_textarea.value = translate_prompt.text;
translate_reset_btn.disabled = (translate_textarea.value === browser.i18n.getMessage("prompt_translate_this_full_text"));
autocompleteSuggestions = (await getPlaceholders(true))
.filter((p) => p.id !== "additional_text")
.map(mapPlaceholderToSuggestion);
textareaAutocomplete(translate_textarea, autocompleteSuggestions, 1);
});
// Methods to manage options, derived from: /options/mzta-options.js
function saveOptions(e) {
e.preventDefault();
let options = {};
let element = e.target;
switch (element.type) {
case 'checkbox':
options[element.id] = element.checked;
break;
case 'number':
options[element.id] = element.valueAsNumber;
break;
case 'text':
case 'password':
options[element.id] = element.value.trim();
break;
case 'select-one':
if (element.id === 'translate_auto') {
options[element.id] = parseInt(element.value, 10);
} else {
options[element.id] = element.value;
}
break;
case 'textarea':
options[element.id] = normalizeStringList(element.value);
break;
default:
console.error("[ThunderAI] Unhandled input type:", element.type);
}
browser.storage.sync.set(options);
}
async function restoreOptions() {
function setCurrentChoice(result) {
document.querySelectorAll(".option-input").forEach(element => {
if(!element.id) return;
taLog.log("Options restoring " + element.id + " = " + (isAPIKeyValue(element.id) ? "****************" : result[element.id]));
switch (element.type) {
case 'checkbox':
element.checked = result[element.id] || false;
break;
case 'number':
element.value = result[element.id] ?? 0;
break;
case 'text':
case 'textarea':
case 'password':
let default_text_value = '';
if(element.id == 'translate_lang') default_text_value = prefs_default.default_chatgpt_lang;
element.value = result[element.id] || default_text_value;
break;
default:
if (element.tagName === 'SELECT') {
let default_select_value = 0;
if (element.id === 'translate_auto') {
default_select_value = prefs_default.translate_auto;
}
const restoreValue = result[element.id] ?? default_select_value;
let optionExists = Array.from(element.options).some(opt => opt.value === String(restoreValue));
if (element.tomselect) {
if (!optionExists && restoreValue !== '') {
element.tomselect.addOption({ value: String(restoreValue), text: String(restoreValue) });
}
element.tomselect.setValue(String(restoreValue), true);
setTomSelectBorder(element.tomselect);
} else {
if (!optionExists && restoreValue !== '') {
let newOption = new Option(restoreValue, restoreValue);
element.add(newOption);
}
element.value = restoreValue;
if (element.value === '') {
element.selectedIndex = 0;
}
}
}else{
console.error("[ThunderAI] Unhandled input type:", element.type);
}
}
});
}
let getting = await browser.storage.sync.get(prefs_default);
let specialPrompts = await getSpecialPrompts();
let translate_prompt = specialPrompts.find(prompt => prompt.id === 'prompt_translate_this');
if (translate_prompt) {
if (translate_prompt.api_type && translate_prompt.api_type !== '') {
getting['translate_connection_type'] = translate_prompt.api_type;
} else {
getting['translate_connection_type'] = getting['connection_type'];
}
for (const [integration, options] of Object.entries(integration_options_config)) {
for (const key of Object.keys(options)) {
const propName = `${integration}_${key}`;
if (translate_prompt[propName] !== undefined && translate_prompt[propName] !== '') {
getting[`translate_${propName}`] = translate_prompt[propName];
} else {
getting[`translate_${propName}`] = getting[propName];
}
}
}
}
// If translate_lang is empty, show default_chatgpt_lang as placeholder/default
if (!getting['translate_lang']) {
getting['translate_lang'] = '';
}
setCurrentChoice(getting);
}