Skip to content

Commit 1f2a892

Browse files
committed
feat: run inbox rules retroactively on existing messages (maathimself#155)
1 parent a197eac commit 1f2a892

10 files changed

Lines changed: 161 additions & 7 deletions

File tree

backend/src/routes/rules.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Router } from 'express';
22
import { query } from '../services/db.js';
33
import { requireAuth } from '../middleware/auth.js';
4+
import { applyInboxRules } from '../services/inboxRules.js';
45

56
const router = Router();
67
router.use(requireAuth);
@@ -57,6 +58,95 @@ router.get('/', async (req, res) => {
5758
}
5859
});
5960

61+
router.post('/run', async (req, res) => {
62+
const imapMgr = req.app.get('imapManager');
63+
const { accountId } = req.body;
64+
65+
let accountIds;
66+
try {
67+
if (accountId) {
68+
const owned = await query(
69+
'SELECT id FROM email_accounts WHERE id = $1 AND user_id = $2',
70+
[accountId, req.session.userId]
71+
);
72+
if (!owned.rows.length) return res.status(404).json({ error: 'Account not found' });
73+
accountIds = [accountId];
74+
} else {
75+
const accts = await query(
76+
'SELECT id FROM email_accounts WHERE user_id = $1',
77+
[req.session.userId]
78+
);
79+
accountIds = accts.rows.map(r => r.id);
80+
}
81+
} catch (err) {
82+
console.error('POST /rules/run account lookup error:', err.message);
83+
return res.status(500).json({ error: 'Failed to run rules' });
84+
}
85+
86+
let processed = 0;
87+
let matched = 0;
88+
89+
for (const acctId of accountIds) {
90+
try {
91+
const rulesCheck = await query(
92+
'SELECT COUNT(*) AS cnt FROM inbox_rules WHERE user_id = $1 AND enabled = true AND (account_id IS NULL OR account_id = $2)',
93+
[req.session.userId, acctId]
94+
);
95+
if (parseInt(rulesCheck.rows[0].cnt, 10) === 0) continue;
96+
97+
const acctResult = await query(
98+
'SELECT id, user_id, folder_mappings FROM email_accounts WHERE id = $1',
99+
[acctId]
100+
);
101+
const account = acctResult.rows[0];
102+
if (!account) continue;
103+
104+
const msgResult = await query(
105+
`SELECT id, uid, folder, from_email, from_name, to_addresses, subject, has_attachments, is_read
106+
FROM messages
107+
WHERE account_id = $1 AND lower(folder) = 'inbox'
108+
LIMIT 1000`,
109+
[acctId]
110+
);
111+
if (!msgResult.rows.length) continue;
112+
113+
const messages = msgResult.rows.map(row => {
114+
let toArr = [];
115+
try {
116+
const raw = typeof row.to_addresses === 'string'
117+
? JSON.parse(row.to_addresses)
118+
: row.to_addresses;
119+
if (Array.isArray(raw)) {
120+
toArr = raw.map(a => ({ email: a.address || a.email || '', name: a.name || '' }));
121+
}
122+
} catch {}
123+
return {
124+
id: row.id,
125+
uid: row.uid,
126+
folder: row.folder,
127+
fromEmail: row.from_email || '',
128+
fromName: row.from_name || '',
129+
to: toArr,
130+
subject: row.subject || '',
131+
hasAttachments: !!row.has_attachments,
132+
isRead: !!row.is_read,
133+
is_read: !!row.is_read,
134+
parsedHeaders: {},
135+
};
136+
});
137+
138+
const before = messages.length;
139+
const { remaining } = await applyInboxRules(messages, account, imapMgr);
140+
processed += before;
141+
matched += before - remaining.length;
142+
} catch (err) {
143+
console.error(`POST /rules/run error for account ${acctId}:`, err.message);
144+
}
145+
}
146+
147+
res.json({ processed, matched });
148+
});
149+
60150
router.post('/', async (req, res) => {
61151
const { name, accountId, conditionLogic, conditions, actions, enabled, stopProcessing } = req.body;
62152
if (!Array.isArray(conditions) || !Array.isArray(actions)) {

frontend/src/components/AdminPanel.jsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3906,6 +3906,23 @@ function RulesTab() {
39063906
const [formError, setFormError] = useState('');
39073907
const [formSaving, setFormSaving] = useState(false);
39083908
const [confirmDelete, setConfirmDelete] = useState(null);
3909+
const [runningRules, setRunningRules] = useState(false);
3910+
const [runResult, setRunResult] = useState(null);
3911+
const [runError, setRunError] = useState('');
3912+
3913+
async function handleRunRules() {
3914+
setRunningRules(true);
3915+
setRunResult(null);
3916+
setRunError('');
3917+
try {
3918+
const result = await api.runRules();
3919+
setRunResult(result);
3920+
} catch {
3921+
setRunError(t('admin.rules.runError'));
3922+
} finally {
3923+
setRunningRules(false);
3924+
}
3925+
}
39093926

39103927
useEffect(() => {
39113928
api.getRules()
@@ -4350,14 +4367,32 @@ function RulesTab() {
43504367
<div>
43514368
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
43524369
<span style={{ fontWeight: 600, fontSize: 15 }}>{t('admin.rules.title')}</span>
4353-
<button
4354-
onClick={() => openAdd({})}
4355-
style={{ padding: '7px 14px', background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: 7, fontWeight: 600, fontSize: 13, cursor: 'pointer' }}
4356-
>
4357-
+ {t('admin.rules.newButton')}
4358-
</button>
4370+
<div style={{ display: 'flex', gap: 8 }}>
4371+
<button
4372+
onClick={handleRunRules}
4373+
disabled={runningRules}
4374+
style={{ padding: '7px 14px', background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 7, fontWeight: 600, fontSize: 13, cursor: runningRules ? 'not-allowed' : 'pointer', color: 'var(--text-primary)', opacity: runningRules ? 0.6 : 1 }}
4375+
>
4376+
{runningRules ? t('admin.rules.running') : t('admin.rules.runButton')}
4377+
</button>
4378+
<button
4379+
onClick={() => openAdd({})}
4380+
style={{ padding: '7px 14px', background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: 7, fontWeight: 600, fontSize: 13, cursor: 'pointer' }}
4381+
>
4382+
+ {t('admin.rules.newButton')}
4383+
</button>
4384+
</div>
43594385
</div>
43604386

4387+
{runResult && (
4388+
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12 }}>
4389+
{t('admin.rules.runResult', { matched: runResult.matched, processed: runResult.processed })}
4390+
</div>
4391+
)}
4392+
{runError && (
4393+
<div style={{ fontSize: 12, color: 'var(--red)', marginBottom: 12 }}>{runError}</div>
4394+
)}
4395+
43614396
{loading && <div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>{t('common.loading')}</div>}
43624397

43634398
{!loading && rules.length === 0 && (

frontend/src/locales/de.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,10 @@
892892
"editButton": "Bearbeiten",
893893
"deleteConfirm": "Diese Regel löschen?",
894894
"errorSave": "Regel konnte nicht gespeichert werden.",
895+
"runButton": "Regeln auf Posteingang anwenden",
896+
"running": "Wird ausgeführt…",
897+
"runResult": "Fertig — {{matched}} von {{processed}} Nachrichten abgeglichen",
898+
"runError": "Fehler beim Ausführen der Regeln",
895899
"formTitleEdit": "Regel bearbeiten",
896900
"fieldTo": "An",
897901
"fieldHasAttachment": "Hat Anhang",

frontend/src/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,11 @@
456456
"saving": "Saving…",
457457
"errorRequired": "Rule name and at least one condition and action are required.",
458458
"errorMoveFolder": "Please enter a destination folder for the Move action.",
459-
"errorSave": "Failed to save rule."
459+
"errorSave": "Failed to save rule.",
460+
"runButton": "Run rules on inbox",
461+
"running": "Running…",
462+
"runResult": "Done — {{matched}} of {{processed}} messages matched",
463+
"runError": "Failed to run rules"
460464
},
461465
"blockList": {
462466
"description": "Emails from blocked senders are automatically moved to trash.",

frontend/src/locales/es.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@
448448
"errorRequired": "Se requiere nombre, al menos una condición y una acción.",
449449
"errorMoveFolder": "Ingresa una carpeta de destino para la acción Mover.",
450450
"errorSave": "No se pudo guardar la regla.",
451+
"runButton": "Aplicar reglas a la bandeja de entrada",
452+
"running": "Ejecutando…",
453+
"runResult": "Listo — {{matched}} de {{processed}} mensajes coincidieron",
454+
"runError": "Error al ejecutar las reglas",
451455
"fieldBody": "Cuerpo",
452456
"fieldHeader": "Encabezado",
453457
"opRegex": "coincide con regex",

frontend/src/locales/fr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@
448448
"errorRequired": "Nom, au moins une condition et une action sont requis.",
449449
"errorMoveFolder": "Veuillez entrer un dossier de destination pour l'action Déplacer.",
450450
"errorSave": "Impossible d'enregistrer la règle.",
451+
"runButton": "Appliquer les règles à la boîte de réception",
452+
"running": "En cours…",
453+
"runResult": "Terminé — {{matched}} message(s) sur {{processed}} ont correspondu",
454+
"runError": "Échec de l'application des règles",
451455
"fieldBody": "Corps",
452456
"fieldHeader": "En-tête",
453457
"opRegex": "correspond à l'expression régulière",

frontend/src/locales/it.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@
448448
"errorRequired": "Nome, almeno una condizione e un'azione sono obbligatori.",
449449
"errorMoveFolder": "Inserisci una cartella di destinazione per l'azione Sposta.",
450450
"errorSave": "Impossibile salvare la regola.",
451+
"runButton": "Applica regole alla posta in arrivo",
452+
"running": "In esecuzione…",
453+
"runResult": "Fatto — {{matched}} su {{processed}} messaggi corrispondono",
454+
"runError": "Errore durante l'esecuzione delle regole",
451455
"fieldBody": "Corpo",
452456
"fieldHeader": "Intestazione",
453457
"opRegex": "corrisponde a regex",

frontend/src/locales/ru.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,10 @@
448448
"errorRequired": "Требуется название, хотя бы одно условие и одно действие.",
449449
"errorMoveFolder": "Укажите папку назначения для действия «Переместить».",
450450
"errorSave": "Не удалось сохранить правило.",
451+
"runButton": "Применить правила к входящим",
452+
"running": "Выполняется…",
453+
"runResult": "Готово — {{matched}} из {{processed}} сообщений совпали",
454+
"runError": "Не удалось применить правила",
451455
"fieldBody": "Тело письма",
452456
"fieldHeader": "Заголовок",
453457
"opRegex": "соответствует регулярному выражению",

frontend/src/locales/zhCN.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,10 @@
450450
"errorRequired": "名称、至少一个条件和一个动作为必填项。",
451451
"errorMoveFolder": "请为「移动」动作指定目标文件夹。",
452452
"errorSave": "无法保存规则。",
453+
"runButton": "对收件箱运行规则",
454+
"running": "运行中…",
455+
"runResult": "完成 — {{processed}} 封邮件中有 {{matched}} 封匹配",
456+
"runError": "运行规则失败",
453457
"fieldBody": "正文",
454458
"fieldHeader": "邮件头",
455459
"opRegex": "匹配正则表达式",

frontend/src/utils/api.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export const api = {
166166
updateRule: (id, data) => request('PUT', `/rules/${id}`, data),
167167
deleteRule: (id) => request('DELETE', `/rules/${id}`),
168168
reorderRules:(ids) => request('PATCH', '/rules/reorder', { ids }),
169+
runRules: (accountId) => request('POST', '/rules/run', accountId ? { accountId } : {}),
169170

170171
// Drafts
171172
saveDraft: (data) => request('POST', '/mail/draft', data),

0 commit comments

Comments
 (0)