Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dcbf9d3
feat(summary): add AI-powered message summary feature
yonie Dec 26, 2025
bd66a37
feat(summary): add auto-summary preference for message previews
yonie Dec 26, 2025
ea402a6
Merge upstream v3.8.0 changes, preserving auto_summary_enabled setting
yonie Jan 2, 2026
a34394d
feat(i18n): add auto summary preference strings for es, fr, and sv lo…
yonie Jan 2, 2026
3f63cc3
feat(summary): implement new configuration model, fix multilingual su…
yonie Jan 2, 2026
90efefd
feat(i18n): add auto summary feature strings for multiple locales
yonie Jan 2, 2026
e5f5d08
feat(i18n): add auto summary strings for cs, ru, zh_Hans, and zh_Hant…
yonie Jan 2, 2026
af2ae04
fix en locale structure breaking the extension
yonie Jan 2, 2026
6406995
Merge branch 'v3.8.0' into main
yonie Jan 4, 2026
0ceaf79
Merge branch 'v3.8.0' into main
yonie Jan 6, 2026
ce33cc1
Merge branch 'v3.8.0' into main
yonie Jan 9, 2026
feaaac5
Merge branch 'v4.0.0' into main
yonie Feb 6, 2026
1630b7c
Merge branch 'v4.0.0' into main
yonie Feb 12, 2026
2ba069e
Merge remote-tracking branch 'origin/v4.0.0' into pr/yonie/579
micz Feb 17, 2026
b60e758
removing auto translation from locales files
micz Feb 17, 2026
7e758a5
removed a method call for Thudnerbird 115
micz Feb 17, 2026
2df835d
Fix auto-summary: inline styles, refresh button, and proper headerMes…
yonie Feb 24, 2026
c325633
Merge branch 'v4.0.0' into main
yonie Feb 24, 2026
ca07cf5
Fix duplicate 'Generating summary' when using manual mode button
yonie Feb 24, 2026
7dfdc2e
Fix auto-summary: strip markdown formatting from AI responses
yonie Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,24 @@
"message": "You have denied the optional permission needed to fetch models for this integration.",
"description": ""
},
"prefs_OptionText_auto_summary": {
"message": "Enable automatic AI summarization for message previews"
},
"prefs_OptionText_auto_summary_Info": {
"message": "If checked, ThunderAI will automatically generate and display AI summaries above email messages when they are opened. Note that this means all messages you preview will immediately be sent to the configured AI service."
},
"auto_summary_title": {
"message": "ThunderAI Summary"
},
"auto_summary_generating": {
"message": "Generating AI summary..."
},
"auto_summary_failed": {
"message": "Failed to generate AI summary. Please confirm your settings and try again."
},
"auto_summary_prompt": {
"message": "Please provide a concise summary of the following email message. The summary should be 3-5 sentences maximum and capture the main points:\n\n"
},
"customPrompts_export_include_api_settings": {
"message": "Do you want to include the API settings in the export? Be aware that also the API Key will be saved in the file!",
"description": ""
Expand Down Expand Up @@ -1897,6 +1915,50 @@
"message": "Spam check in progress...",
"description": ""
},
"prefs_OptionText_summarize_auto": {
"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": "Generate automatically",
"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": ""
},
"summarize_title": {
"message": "Summary",
"description": ""
},
"summarize_generating": {
"message": "Generating summary...",
"description": ""
},
"summarize_error": {
"message": "Failed to generate summary",
"description": ""
},
"summarize_click_to_generate": {
"message": "Click here to generate a summary",
"description": ""
},
"summarize_chatgpt_web_not_supported": {
"message": "Auto-summary requires an API-based connection. Please configure an API connection in ThunderAI settings.",
"description": ""
},
"summarize_refresh": {
"message": "Refresh summary",
"description": ""
},
"antispam_by": {
"message": "Antispam by",
"description": ""
Expand All @@ -1909,4 +1971,4 @@
"message": "Click here! Try ThunderStats!",
"description": ""
}
}
}
143 changes: 142 additions & 1 deletion js/mzta-compose-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -719,11 +719,152 @@ switch (message.command) {
document.body.insertBefore(container, document.body.firstChild);
return Promise.resolve(true);

case "showSummary":
const generatingBanner = document.getElementById('mzta-summary-generating');
if(generatingBanner) generatingBanner.remove();

const triggerBtn = document.getElementById('mzta-summary-trigger');
if(triggerBtn) triggerBtn.remove();

const summaryBanner = document.getElementById('mzta-summary-banner');
if(summaryBanner) summaryBanner.remove();

const summaryData = message.data;
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';
let titleColor = isDarkSummary ? '#ff6b6b' : '#d70022';

if (summaryData.error) {
bgColorSummary = isDarkSummary ? '#3a1a1a' : '#f7e6e6';
textColorSummary = isDarkSummary ? '#ffcccc' : '#660000';
borderColorSummary = '#660000';
titleColor = isDarkSummary ? '#ffcccc' : '#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;`;

const summaryHeader = document.createElement('div');
summaryHeader.style.cssText = `display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;`;

const summaryTitle = document.createElement('div');
summaryTitle.className = 'thunderai-summary-title';
summaryTitle.textContent = browser.i18n.getMessage("summarize_title");
summaryTitle.style.cssText = `font-weight: bold; font-size: 14px; color: ${titleColor};`;

const refreshBtn = document.createElement('span');
refreshBtn.textContent = '↻';
refreshBtn.title = browser.i18n.getMessage("summarize_refresh") || 'Refresh summary';
refreshBtn.style.cssText = `cursor: pointer; opacity: 0.6; font-size: 16px; transition: opacity 0.2s;`;
refreshBtn.onmouseover = () => refreshBtn.style.opacity = '1';
refreshBtn.onmouseout = () => refreshBtn.style.opacity = '0.6';
refreshBtn.onclick = async () => {
refreshBtn.onclick = null;
refreshBtn.style.opacity = '0.6';
browser.runtime.sendMessage({
command: "refreshSummary",
headerMessageId: summaryData.headerMessageId
});
};

summaryHeader.appendChild(summaryTitle);
summaryHeader.appendChild(refreshBtn);
summaryContainer.appendChild(summaryHeader);

const summaryText = document.createElement('div');
summaryText.className = 'thunderai-summary-content';
if (summaryData.error) {
summaryText.textContent = summaryData.message || browser.i18n.getMessage("summarize_error");
} else {
summaryText.textContent = summaryData.summary;
}
summaryText.style.cssText = `font-size: 14px; line-height: 1.4;`;

summaryContainer.appendChild(summaryText);

document.body.insertBefore(summaryContainer, document.body.firstChild);
return Promise.resolve(true);

case "showSummaryGenerating":
const existingGenerating = document.getElementById('mzta-summary-generating');
if(existingGenerating) return Promise.resolve(true);

const existingSummary = document.getElementById('mzta-summary-banner');
if(existingSummary) existingSummary.remove();

const existingTrigger = document.getElementById('mzta-summary-trigger');
if(existingTrigger) existingTrigger.remove();

const isDarkGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;

let bgColorGen = isDarkGen ? '#2a2a2a' : '#f0f0f0';
let textColorGen = isDarkGen ? '#e0e0e0' : '#333';
let borderColorGen = isDarkGen ? '#444' : '#ddd';
let titleColorGen = isDarkGen ? '#ff6b6b' : '#d70022';

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;`;

const generatingTitle = document.createElement('div');
generatingTitle.className = 'thunderai-summary-title';
generatingTitle.textContent = browser.i18n.getMessage("summarize_generating");
generatingTitle.style.cssText = `font-weight: bold; font-size: 14px; margin-bottom: 0.5rem; color: ${titleColorGen};`;

generatingContainer.appendChild(generatingTitle);

document.body.insertBefore(generatingContainer, document.body.firstChild);
return Promise.resolve(true);

case "showSummaryButton":
const existingButton = document.getElementById('mzta-summary-trigger');
if(existingButton) 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';
let titleColorBtn = isDarkBtn ? '#ff6b6b' : '#d70022';

const triggerContainer = document.createElement('div');
triggerContainer.id = 'mzta-summary-trigger';
triggerContainer.className = 'thunderai-summary-pane';
triggerContainer.style.cssText = `background-color: ${bgColorBtn}; color: ${textColorBtn}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorBtn}; cursor: pointer; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`;

const triggerText = document.createElement('div');
triggerText.className = 'thunderai-summary-title';
triggerText.textContent = browser.i18n.getMessage("summarize_click_to_generate");
triggerText.style.cssText = `font-weight: bold; font-size: 14px; margin-bottom: 0; color: ${titleColorBtn};`;

triggerContainer.appendChild(triggerText);
triggerContainer.onclick = async () => {
triggerContainer.onclick = null;
triggerContainer.id = 'mzta-summary-generating';
triggerContainer.style.cursor = 'default';
triggerText.textContent = browser.i18n.getMessage("summarize_generating");
browser.runtime.sendMessage({
command: "triggerSummaryGeneration",
headerMessageId: message.headerMessageId
});
};

document.body.insertBefore(triggerContainer, document.body.firstChild);
return Promise.resolve(true);

default:
// do nothing
return Promise.resolve(false);
break;
}
});

browser.runtime.sendMessage({ command: "checkSpamReport" });
browser.runtime.sendMessage({ command: "checkSpamReport" });
browser.runtime.sendMessage({ command: "initSummary" });
115 changes: 115 additions & 0 deletions js/mzta-summary-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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/>.
*/

export const taSummaryCache = {
logger: console,
_data_prefix: 'mzta-summary-',
_processing_prefix: 'mzta-summary-processing-',
_max_summaries: 100,

async setProcessing(data_id) {
const key = this._processing_prefix + data_id;
await browser.storage.session.set({ [key]: true });
},

async isProcessing(data_id) {
const key = this._processing_prefix + data_id;
let output = await browser.storage.session.get(key);
return output[key] || false;
},

async saveSummary(data, data_id) {
const key = this._data_prefix + data_id;
await browser.storage.session.set({ [key]: data });
await browser.storage.session.remove(this._processing_prefix + data_id);
},

async saveError(data_id, error_message) {
let data = {
error: true,
message: error_message,
summary_date: new Date(),
headerMessageId: data_id
};
await this.saveSummary(data, data_id);
return data;
},

async loadSummary(data_id) {
const key = this._data_prefix + data_id;
let output = await browser.storage.session.get(key);
return output[key] || null;
},

async removeSummary(data_id) {
const key = this._data_prefix + data_id;
await browser.storage.session.remove(key);
await browser.storage.session.remove(this._processing_prefix + data_id);
},

async getAllSummaries() {
let allData = await browser.storage.session.get(null);
let summaryData = {};

for (const [key, value] of Object.entries(allData)) {
if (key.startsWith(this._data_prefix)) {
summaryData[key.replace(this._data_prefix, '')] = value;
}
}

return summaryData;
},

async clearSummaries() {
let allData = await browser.storage.session.get(null);
let keysToDelete = Object.keys(allData).filter(key => key.startsWith(this._data_prefix) || key.startsWith(this._processing_prefix));

for (let key of keysToDelete) {
await browser.storage.session.remove(key);
}
},

async truncSummaries() {
let data = await this.getAllSummaries();
let sortedData = this.sortSummariesByDate(data);
let keys = Object.keys(sortedData);

if (keys.length > this._max_summaries) {
for (let i = this._max_summaries; i < keys.length; i++) {
await browser.storage.session.remove(this._data_prefix + keys[i]);
}
}
},

sortSummariesByDate(data) {
if (!data) return {};
const summaryKeys = Object.keys(data);
summaryKeys.sort((a, b) => {
const dateA = new Date(data[a].summary_date);
const dateB = new Date(data[b].summary_date);
return dateB - dateA;
});

let sortedSummaries = {};
summaryKeys.forEach((key) => {
sortedSummaries[key] = data[key];
});

return sortedSummaries;
}
};
Loading