diff --git a/README.md b/README.md index 1458d4e..f9a92f7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ message meets a specified criterion. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. - **Light/Dark themes** – automatically match Thunderbird's appearance with optional manual override. -- **Automatic rules** – create rules that tag, move, copy, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages. +- **Automatic rules** – create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages. - **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match. - **Context menu** – apply AI rules from the message list or the message display action button. - **Status icons** – toolbar icons show when classification is in progress and briefly display success or error states. @@ -70,9 +70,11 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e 1. Open the add-on's options and set the URL of your classification service. 2. Use the **Classification Rules** section to add a criterion and optional - actions such as tagging, moving, copying, deleting or archiving a message when it matches. Drag rules to + actions such as tagging, moving, copying, forwarding, replying, + deleting or archiving a message when it matches. Drag rules to reorder them, check *Only apply to unread messages* to skip read mail, and - check *Stop after match* to halt further processing. + check *Stop after match* to halt further processing. Forward and reply actions + open a compose window using the account that received the message. 3. Save your settings. New mail will be evaluated automatically using the configured rules. @@ -116,6 +118,7 @@ Sortana requests the following Thunderbird permissions: - `accountsRead` – list accounts and folders for move or copy actions. - `menus` – add context menu commands. - `tabs` – open new tabs and query the active tab. +- `compose` – create reply and forward compose windows for matching rules. ## Thunderbird Add-on Store Disclosures diff --git a/_locales/en-US/messages.json b/_locales/en-US/messages.json index 7d8fa37..983bb86 100644 --- a/_locales/en-US/messages.json +++ b/_locales/en-US/messages.json @@ -23,9 +23,14 @@ ,"action.flag": { "message": "flag" } ,"action.copy": { "message": "copy" } ,"action.delete": { "message": "delete" } - ,"action.archive": { "message": "archive" } + ,"action.archive": { "message": "archive" } + ,"action.forward": { "message": "forward" } + ,"action.reply": { "message": "reply" } ,"param.markRead": { "message": "mark read" } ,"param.markUnread": { "message": "mark unread" } ,"param.flag": { "message": "flag" } ,"param.unflag": { "message": "unflag" } + ,"param.address": { "message": "address" } + ,"param.replyAll": { "message": "reply all" } + ,"param.replySender": { "message": "reply sender" } } diff --git a/background.js b/background.js index 3309339..9a230e1 100644 --- a/background.js +++ b/background.js @@ -209,15 +209,20 @@ async function processMessage(id) { try { const full = await messenger.messages.getFull(id); const text = buildEmailText(full); + let hdr; let currentTags = []; let alreadyRead = false; + let identityId = null; try { - const hdr = await messenger.messages.get(id); + hdr = await messenger.messages.get(id); currentTags = Array.isArray(hdr.tags) ? [...hdr.tags] : []; alreadyRead = hdr.read === true; + const ids = await messenger.identities.list(hdr.folder.accountId); + identityId = ids[0]?.id || null; } catch (e) { currentTags = []; alreadyRead = false; + identityId = null; } for (const rule of aiRules) { @@ -247,6 +252,10 @@ async function processMessage(id) { await messenger.messages.delete([id]); } else if (act.type === 'archive') { await messenger.messages.archive([id]); + } else if (act.type === 'forward' && act.address && identityId) { + await browser.compose.beginForward(id, { to: [act.address], identityId }); + } else if (act.type === 'reply' && act.replyType && identityId) { + await browser.compose.beginReply(id, { replyType: act.replyType, identityId }); } } if (rule.stopProcessing) { diff --git a/manifest.json b/manifest.json index 0783cef..fe019ab 100644 --- a/manifest.json +++ b/manifest.json @@ -42,6 +42,7 @@ "menus", "scripting", "tabs", - "theme" + "theme", + "compose" ] } diff --git a/options/options.js b/options/options.js index ce6c81b..951c135 100644 --- a/options/options.js +++ b/options/options.js @@ -171,7 +171,7 @@ document.addEventListener('DOMContentLoaded', async () => { const typeWrapper = document.createElement('div'); typeWrapper.className = 'select is-small mr-2'; const typeSelect = document.createElement('select'); - ['tag','move','copy','junk','read','flag','delete','archive'].forEach(t => { + ['tag','move','copy','junk','read','flag','delete','archive','forward','reply'].forEach(t => { const opt = document.createElement('option'); opt.value = t; opt.textContent = t; @@ -242,6 +242,23 @@ document.addEventListener('DOMContentLoaded', async () => { sel.value = String(action.flagged ?? true); wrap.appendChild(sel); paramSpan.appendChild(wrap); + } else if (typeSelect.value === 'forward') { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'input is-small forward-input'; + input.placeholder = 'address@example.com'; + input.value = action.address || ''; + paramSpan.appendChild(input); + } else if (typeSelect.value === 'reply') { + const wrap = document.createElement('div'); + wrap.className = 'select is-small'; + const sel = document.createElement('select'); + sel.className = 'reply-select'; + sel.appendChild(new Option('all','all')); + sel.appendChild(new Option('sender','sender')); + sel.value = action.replyType || 'all'; + wrap.appendChild(sel); + paramSpan.appendChild(wrap); } else if (typeSelect.value === 'delete' || typeSelect.value === 'archive') { paramSpan.appendChild(document.createElement('span')); } @@ -530,6 +547,12 @@ document.addEventListener('DOMContentLoaded', async () => { if (type === 'flag') { return { type, flagged: row.querySelector('.flag-select').value === 'true' }; } + if (type === 'forward') { + return { type, address: row.querySelector('.forward-input').value.trim() }; + } + if (type === 'reply') { + return { type, replyType: row.querySelector('.reply-select').value }; + } return { type }; }); const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;