Add multi-action rules UI

This commit is contained in:
Jordan Wages 2025-06-25 03:23:13 -05:00
commit 74fb932b45
5 changed files with 212 additions and 36 deletions

View file

@ -97,6 +97,9 @@ Sortana requests the following Thunderbird permissions:
- `storage` store configuration and cached classification results. - `storage` store configuration and cached classification results.
- `messagesRead` read message contents for classification. - `messagesRead` read message contents for classification.
- `messagesMove` move messages when a rule specifies a target folder. - `messagesMove` move messages when a rule specifies a target folder.
- `messagesUpdate` change message properties such as tags and junk status.
- `messagesTagsList` retrieve existing message tags for rule actions.
- `accountsRead` list accounts and folders for move actions.
## License ## License

View file

@ -33,7 +33,13 @@ async function sha256Hex(str) {
const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]); const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]);
logger.setDebug(store.debugLogging); logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store); await AiClassifier.setConfig(store);
aiRules = Array.isArray(store.aiRules) ? store.aiRules : []; aiRules = Array.isArray(store.aiRules) ? store.aiRules.map(r => {
if (r.actions) return r;
const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
return { criterion: r.criterion, actions };
}) : [];
logger.aiLog("configuration loaded", {debug: true}, store); logger.aiLog("configuration loaded", {debug: true}, store);
} catch (err) { } catch (err) {
logger.aiLog("failed to load config", {level: 'error'}, err); logger.aiLog("failed to load config", {level: 'error'}, err);
@ -73,7 +79,13 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
logger.aiLog("onNewMailReceived", {debug: true}, messages); logger.aiLog("onNewMailReceived", {debug: true}, messages);
if (!aiRules.length) { if (!aiRules.length) {
const { aiRules: stored } = await browser.storage.local.get("aiRules"); const { aiRules: stored } = await browser.storage.local.get("aiRules");
aiRules = Array.isArray(stored) ? stored : []; aiRules = Array.isArray(stored) ? stored.map(r => {
if (r.actions) return r;
const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
return { criterion: r.criterion, actions };
}) : [];
} }
for (const msg of (messages?.messages || messages || [])) { for (const msg of (messages?.messages || messages || [])) {
const id = msg.id ?? msg; const id = msg.id ?? msg;
@ -84,11 +96,14 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
const cacheKey = await sha256Hex(`${id}|${rule.criterion}`); const cacheKey = await sha256Hex(`${id}|${rule.criterion}`);
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
if (matched) { if (matched) {
if (rule.tag) { for (const act of (rule.actions || [])) {
await messenger.messages.update(id, {tags: [rule.tag]}); if (act.type === 'tag' && act.tagKey) {
} await messenger.messages.update(id, {tags: [act.tagKey]});
if (rule.moveTo) { } else if (act.type === 'move' && act.folder) {
await messenger.messages.move([id], rule.moveTo); await messenger.messages.move([id], act.folder);
} else if (act.type === 'junk') {
await messenger.messages.update(id, {junk: !!act.junk});
}
} }
} }
} }

View file

@ -23,5 +23,12 @@
"page": "options/options.html", "page": "options/options.html",
"open_in_tab": true "open_in_tab": true
}, },
"permissions": [ "storage", "messagesRead", "accountsRead" ] "permissions": [
"storage",
"messagesRead",
"messagesMove",
"messagesUpdate",
"messagesTagsList",
"accountsRead"
]
} }

View file

@ -78,6 +78,19 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.tab-button {
background: #e0e0e0;
}
.tab-button.active {
background: #007acc;
color: #fff;
}
.tab {
margin-top: 20px;
}
#rules-container { #rules-container {
margin-top: 10px; margin-top: 10px;
} }
@ -93,6 +106,10 @@
margin-top: 10px; margin-top: 10px;
} }
.action-row {
margin-bottom: 5px;
}
button { button {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
@ -132,7 +149,13 @@
<img src="../resources/img/full-logo.png" alt="AI Filter Logo"> <img src="../resources/img/full-logo.png" alt="AI Filter Logo">
</header> </header>
<nav style="display:flex; gap:10px; justify-content:center; margin-top:10px;">
<button type="button" data-tab="settings" class="tab-button">Settings</button>
<button type="button" data-tab="rules" class="tab-button">Rules</button>
</nav>
<main> <main>
<div id="settings-tab" class="tab">
<div class="form-group"> <div class="form-group">
<label for="endpoint">Endpoint:</label> <label for="endpoint">Endpoint:</label>
<input type="text" id="endpoint" placeholder="https://api.example.com"> <input type="text" id="endpoint" placeholder="https://api.example.com">
@ -213,10 +236,14 @@
</div> </div>
</div> </div>
<hr> </div>
<div id="rules-tab" class="tab" style="display:none">
<h2>Classification Rules</h2> <h2>Classification Rules</h2>
<div id="rules-container"></div> <div id="rules-container"></div>
<button id="add-rule" type="button">Add Rule</button> <button id="add-rule" type="button">Add Rule</button>
</div>
</main> </main>
<script src="options.js"></script> <script src="options.js"></script>

View file

@ -10,6 +10,16 @@ document.addEventListener('DOMContentLoaded', async () => {
'debugLogging', 'debugLogging',
'aiRules' 'aiRules'
]); ]);
const tabButtons = document.querySelectorAll('.tab-button');
const tabs = document.querySelectorAll('.tab');
tabButtons.forEach(btn => btn.addEventListener('click', () => {
tabButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
tabs.forEach(tab => {
tab.style.display = tab.id === `${btn.dataset.tab}-tab` ? 'block' : 'none';
});
}));
tabButtons[0]?.click();
logger.setDebug(defaults.debugLogging === true); logger.setDebug(defaults.debugLogging === true);
const DEFAULT_AI_PARAMS = { const DEFAULT_AI_PARAMS = {
max_tokens: 4096, max_tokens: 4096,
@ -66,6 +76,26 @@ document.addEventListener('DOMContentLoaded', async () => {
if (el) el.value = val; if (el) el.value = val;
} }
let tagList = [];
let folderList = [];
try {
tagList = await messenger.messages.tags.list();
} catch (e) {
logger.aiLog('failed to list tags', {level:'error'}, e);
}
try {
const accounts = await messenger.accounts.list(true);
const collect = (f, prefix='') => {
folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
};
for (const acct of accounts) {
(acct.folders || []).forEach(f => collect(f, `${acct.name}/`));
}
} catch (e) {
logger.aiLog('failed to list folders', {level:'error'}, e);
}
const DEFAULT_SYSTEM = 'Determine whether the email satisfies the user\'s criterion.'; const DEFAULT_SYSTEM = 'Determine whether the email satisfies the user\'s criterion.';
const systemBox = document.getElementById('system-instructions'); const systemBox = document.getElementById('system-instructions');
systemBox.value = defaults.customSystemPrompt || DEFAULT_SYSTEM; systemBox.value = defaults.customSystemPrompt || DEFAULT_SYSTEM;
@ -76,6 +106,70 @@ document.addEventListener('DOMContentLoaded', async () => {
const rulesContainer = document.getElementById('rules-container'); const rulesContainer = document.getElementById('rules-container');
const addRuleBtn = document.getElementById('add-rule'); const addRuleBtn = document.getElementById('add-rule');
function createActionRow(action = {type: 'tag'}) {
const row = document.createElement('div');
row.className = 'action-row';
const typeSelect = document.createElement('select');
['tag','move','junk'].forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
typeSelect.appendChild(opt);
});
typeSelect.value = action.type;
const paramSpan = document.createElement('span');
function updateParams() {
paramSpan.innerHTML = '';
if (typeSelect.value === 'tag') {
const sel = document.createElement('select');
sel.className = 'tag-select';
for (const t of tagList) {
const opt = document.createElement('option');
opt.value = t.key;
opt.textContent = t.tag;
sel.appendChild(opt);
}
sel.value = action.tagKey || '';
paramSpan.appendChild(sel);
} else if (typeSelect.value === 'move') {
const sel = document.createElement('select');
sel.className = 'folder-select';
for (const f of folderList) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
sel.appendChild(opt);
}
sel.value = action.folder || '';
paramSpan.appendChild(sel);
} else if (typeSelect.value === 'junk') {
const sel = document.createElement('select');
sel.className = 'junk-select';
sel.appendChild(new Option('mark junk','true'));
sel.appendChild(new Option('mark not junk','false'));
sel.value = String(action.junk ?? true);
paramSpan.appendChild(sel);
}
}
typeSelect.addEventListener('change', updateParams);
updateParams();
const removeBtn = document.createElement('button');
removeBtn.textContent = 'Remove';
removeBtn.type = 'button';
removeBtn.addEventListener('click', () => row.remove());
row.appendChild(typeSelect);
row.appendChild(paramSpan);
row.appendChild(removeBtn);
return row;
}
function renderRules(rules = []) { function renderRules(rules = []) {
rulesContainer.innerHTML = ''; rulesContainer.innerHTML = '';
for (const rule of rules) { for (const rule of rules) {
@ -86,45 +180,63 @@ document.addEventListener('DOMContentLoaded', async () => {
critInput.type = 'text'; critInput.type = 'text';
critInput.placeholder = 'Criterion'; critInput.placeholder = 'Criterion';
critInput.value = rule.criterion || ''; critInput.value = rule.criterion || '';
critInput.className = 'criterion';
const tagInput = document.createElement('input'); const actionsContainer = document.createElement('div');
tagInput.type = 'text'; actionsContainer.className = 'rule-actions';
tagInput.placeholder = 'Tag (e.g. $label1)';
tagInput.value = rule.tag || '';
const moveInput = document.createElement('input'); for (const act of (rule.actions || [])) {
moveInput.type = 'text'; actionsContainer.appendChild(createActionRow(act));
moveInput.placeholder = 'Folder URL'; }
moveInput.value = rule.moveTo || '';
const actionsDiv = document.createElement('div'); const addAction = document.createElement('button');
actionsDiv.className = 'rule-actions'; addAction.textContent = 'Add Action';
addAction.type = 'button';
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
const delBtn = document.createElement('button'); const delBtn = document.createElement('button');
delBtn.textContent = 'Delete'; delBtn.textContent = 'Delete Rule';
delBtn.type = 'button'; delBtn.type = 'button';
delBtn.addEventListener('click', () => div.remove()); delBtn.addEventListener('click', () => div.remove());
actionsDiv.appendChild(delBtn);
div.appendChild(critInput); div.appendChild(critInput);
div.appendChild(tagInput); div.appendChild(actionsContainer);
div.appendChild(moveInput); div.appendChild(addAction);
div.appendChild(actionsDiv); div.appendChild(delBtn);
rulesContainer.appendChild(div); rulesContainer.appendChild(div);
} }
} }
addRuleBtn.addEventListener('click', () => { addRuleBtn.addEventListener('click', () => {
renderRules([...rulesContainer.querySelectorAll('.rule')].map(el => ({ const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
criterion: el.children[0].value, const criterion = ruleEl.querySelector('.criterion').value;
tag: el.children[1].value, const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
moveTo: el.children[2].value const type = row.querySelector('select').value;
})).concat([{ criterion: '', tag: '', moveTo: '' }])); if (type === 'tag') {
return { type, tagKey: row.querySelector('.tag-select').value };
}
if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value };
}
if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' };
}
return { type };
});
return { criterion, actions };
});
data.push({ criterion: '', actions: [] });
renderRules(data);
}); });
renderRules(defaults.aiRules || []); renderRules((defaults.aiRules || []).map(r => {
if (r.actions) return r;
const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
return { criterion: r.criterion, actions };
}));
document.getElementById('save').addEventListener('click', async () => { document.getElementById('save').addEventListener('click', async () => {
const endpoint = document.getElementById('endpoint').value; const endpoint = document.getElementById('endpoint').value;
@ -140,11 +252,23 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
} }
const debugLogging = debugToggle.checked; const debugLogging = debugToggle.checked;
const rules = [...rulesContainer.querySelectorAll('.rule')].map(el => ({ const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
criterion: el.children[0].value, const criterion = ruleEl.querySelector('.criterion').value;
tag: el.children[1].value, const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
moveTo: el.children[2].value const type = row.querySelector('select').value;
})).filter(r => r.criterion); if (type === 'tag') {
return { type, tagKey: row.querySelector('.tag-select').value };
}
if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value };
}
if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' };
}
return { type };
});
return { criterion, actions };
}).filter(r => r.criterion);
await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules }); await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules });
try { try {
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });