imap-mail-filter-service/app/templates/filters.html

636 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Filterregeln — IMAP Mail Filter{% endblock %}
{% block content %}
<h1>Filterregeln</h1>
<label for="account-select">Konto auswählen:</label>
<select id="account-select" onchange="loadFilters()">
<option value="">— Konto wählen —</option>
{% for acc in accounts %}
<option value="{{ acc.id }}" {{ 'selected' if selected_account_id == acc.id else '' }}>{{ acc.name }} ({{ acc.username }})</option>
{% endfor %}
</select>
<div id="filters-container">
<p>Bitte ein Konto auswählen.</p>
</div>
<dialog id="filter-dialog">
<article style="min-width: 60vw;">
<header>
<button aria-label="Close" rel="prev" onclick="document.getElementById('filter-dialog').close()"></button>
<h3 id="dialog-title">Neue Filterregel</h3>
</header>
<form id="filter-form">
<input type="hidden" id="filter-id" value="">
<label>
Name
<input type="text" id="filter-name" required placeholder="z.B. Newsletter sortieren">
</label>
<div class="grid">
<label>
Priorität
<input type="number" id="filter-priority" value="100">
</label>
<label>
Quellordner
<div role="group">
<input type="text" id="filter-source-folder" value="INBOX" readonly>
<button type="button" class="outline" onclick="openFolderBrowser('filter-source-folder')">Durchsuchen</button>
</div>
</label>
</div>
<label>
<input type="checkbox" id="filter-stop-processing">
Nach Treffer keine weiteren Regeln anwenden
</label>
<h4>Bedingungen <small>(alle müssen zutreffen)</small></h4>
<div id="conditions-list"></div>
<button type="button" class="outline small" onclick="addCondition()">+ Bedingung</button>
<h4>Aktionen</h4>
<div id="actions-list"></div>
<button type="button" class="outline small" onclick="addAction()">+ Aktion</button>
<footer>
<div role="group">
<button type="submit">Speichern</button>
<button type="button" class="outline" onclick="document.getElementById('filter-dialog').close()">Abbrechen</button>
</div>
</footer>
</form>
</article>
</dialog>
<dialog id="folder-browser">
<article style="min-width: 50vw; max-width: 700px;">
<header>
<button aria-label="Close" rel="prev" onclick="document.getElementById('folder-browser').close()"></button>
<h3>Ordner auswählen</h3>
</header>
<div id="folder-tree" style="max-height: 400px; overflow-y: auto;">
<p aria-busy="true">Ordner werden geladen...</p>
</div>
<div id="folder-status" style="display:none; margin-top:0.5rem; padding:0.5rem; border-radius:4px; background: var(--pico-muted-border-color);"></div>
</article>
</dialog>
{% endblock %}
{% block scripts %}
<style>
.folder-row {
display: flex;
align-items: center;
padding: 0.35rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.folder-row:hover {
background: var(--pico-primary-focus);
}
.folder-row .folder-icon {
flex-shrink: 0;
width: 1.4em;
margin-right: 0.4rem;
}
.folder-row .folder-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.folder-row .folder-path {
font-size: 0.75rem;
opacity: 0.45;
margin-left: 0.5rem;
white-space: nowrap;
}
.folder-row .folder-add-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0.1rem 0.3rem;
border-radius: 4px;
opacity: 0.5;
transition: opacity 0.15s;
color: var(--pico-color);
}
.folder-row .folder-add-btn:hover {
opacity: 1;
background: var(--pico-primary-focus);
}
.folder-new-input {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0;
margin-bottom: 0;
}
.folder-new-input input {
margin-bottom: 0;
padding: 0.3rem 0.5rem;
font-size: 0.9rem;
height: auto;
}
.folder-new-input button {
margin-bottom: 0;
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
white-space: nowrap;
}
</style>
<script>
const FIELDS = [
{value: 'from', label: 'Von', type: 'text'},
{value: 'to', label: 'An', type: 'text'},
{value: 'subject', label: 'Betreff', type: 'text'},
{value: 'body', label: 'Text', type: 'text'},
{value: 'has_attachment', label: 'Hat Anhang', type: 'bool'},
{value: 'date', label: 'Datum', type: 'date'},
];
const TEXT_MATCH_TYPES = [
{value: 'contains', label: 'enthält'},
{value: 'regex', label: 'Regex'},
{value: 'exact', label: 'exakt'},
];
const DATE_MATCH_TYPES = [
{value: 'on_date', label: 'am (exakt)', inputType: 'date', placeholder: ''},
{value: 'before', label: 'vor', inputType: 'date', placeholder: ''},
{value: 'after', label: 'ab / nach', inputType: 'date', placeholder: ''},
{value: 'date_range', label: 'Zeitraum (vonbis)', inputType: 'daterange', placeholder: ''},
{value: 'year', label: 'im Jahr', inputType: 'number', placeholder: 'z.B. 2025'},
{value: 'last_n_days', label: 'letzte N Tage', inputType: 'number', placeholder: 'Anzahl Tage'},
{value: 'last_n_weeks', label: 'letzte N Wochen', inputType: 'number', placeholder: 'Anzahl Wochen'},
{value: 'last_n_months', label: 'letzte N Monate', inputType: 'number', placeholder: 'Anzahl Monate'},
{value: 'older_than_days', label: 'älter als N Tage', inputType: 'number', placeholder: 'Anzahl Tage'},
{value: 'older_than_weeks', label: 'älter als N Wochen', inputType: 'number', placeholder: 'Anzahl Wochen'},
{value: 'older_than_months', label: 'älter als N Monate', inputType: 'number', placeholder: 'Anzahl Monate'},
];
// Combined for display in filter list
const ALL_MATCH_TYPES = [...TEXT_MATCH_TYPES, ...DATE_MATCH_TYPES];
const ACTION_TYPES = [
{value: 'move', label: 'Verschieben in Ordner', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'},
{value: 'forward', label: 'Weiterleiten an', needsParam: true, needsFolder: false, paramLabel: 'E-Mail-Adresse'},
{value: 'delete', label: 'Löschen', needsParam: false, needsFolder: false},
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false, needsFolder: false},
];
async function loadFilters() {
const accountId = document.getElementById('account-select').value;
const container = document.getElementById('filters-container');
if (!accountId) {
container.innerHTML = '<p>Bitte ein Konto auswählen.</p>';
return;
}
const resp = await fetch(`/api/filters/account/${accountId}`);
const filters = await resp.json();
if (filters.length === 0) {
container.innerHTML = `
<p>Keine Filterregeln für dieses Konto.</p>
<button onclick="openNewFilter()">Neue Regel erstellen</button>
`;
return;
}
let html = '<button onclick="openNewFilter()" style="margin-bottom:1rem">Neue Regel</button><table><thead><tr><th>Prio</th><th>Name</th><th>Ordner</th><th>Bedingungen</th><th>Aktionen</th><th></th></tr></thead><tbody>';
for (const f of filters) {
const conds = f.conditions.map(c => {
const fieldLabel = FIELDS.find(x => x.value === c.field)?.label || c.field;
const matchLabel = ALL_MATCH_TYPES.find(x => x.value === c.match_type)?.label || c.match_type;
return `${c.negate ? 'NICHT ' : ''}${fieldLabel} ${matchLabel} "${c.value}"`;
}).join('<br>');
const acts = f.actions.map(a => {
const at = ACTION_TYPES.find(x => x.value === a.action_type);
return `${at?.label || a.action_type}${a.parameter ? ': ' + a.parameter : ''}`;
}).join('<br>');
html += `<tr>
<td>${f.priority}</td>
<td>${f.name}${f.stop_processing ? ' <small>(stop)</small>' : ''}</td>
<td>${f.source_folder}</td>
<td><small>${conds}</small></td>
<td><small>${acts}</small></td>
<td>
<div role="group">
<button class="outline small" onclick='editFilter(${JSON.stringify(f)})'>Bearbeiten</button>
<button class="outline small contrast" onclick="deleteFilter(${f.id})">Löschen</button>
</div>
</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
}
function addCondition(data = null) {
const list = document.getElementById('conditions-list');
const uid = Date.now() + '_' + list.children.length;
const div = document.createElement('div');
div.className = 'cond-row';
div.style.marginBottom = '0.5rem';
div.dataset.uid = uid;
const selectedField = FIELDS.find(f => f.value === (data?.field || 'from')) || FIELDS[0];
div.innerHTML = `
<div style="display:flex; gap:0.4rem; align-items:center; flex-wrap:wrap;">
<select class="cond-field" style="flex:0 0 auto; width:auto; margin-bottom:0;" onchange="onCondFieldChange(this)">
${FIELDS.map(f => `<option value="${f.value}" data-type="${f.type}" ${data?.field === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
</select>
<div class="cond-match-container" style="flex:0 0 auto;"></div>
<div class="cond-value-container" style="flex:1; min-width:150px;"></div>
<label style="white-space:nowrap; margin-bottom:0;"><input type="checkbox" class="cond-negate" ${data?.negate ? 'checked' : ''} style="margin-bottom:0;"> NOT</label>
<button type="button" class="outline small contrast" onclick="this.closest('.cond-row').remove()" style="margin-bottom:0;">X</button>
</div>
`;
list.appendChild(div);
updateConditionInputs(div, selectedField.type, data);
}
function onCondFieldChange(select) {
const row = select.closest('.cond-row');
const opt = select.options[select.selectedIndex];
const fieldType = opt.dataset.type;
updateConditionInputs(row, fieldType, null);
}
function updateConditionInputs(row, fieldType, data) {
const matchContainer = row.querySelector('.cond-match-container');
const valueContainer = row.querySelector('.cond-value-container');
if (fieldType === 'date') {
// Date field: show date match types
matchContainer.innerHTML = `
<select class="cond-match" style="margin-bottom:0; width:auto;" onchange="onDateMatchChange(this)">
${DATE_MATCH_TYPES.map(m => `<option value="${m.value}" data-input-type="${m.inputType}" data-placeholder="${m.placeholder}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>
`;
const matchSelect = matchContainer.querySelector('select');
const selectedMatch = DATE_MATCH_TYPES.find(m => m.value === (data?.match_type || 'on_date')) || DATE_MATCH_TYPES[0];
renderDateValueInput(valueContainer, selectedMatch, data?.value || '');
} else if (fieldType === 'bool') {
// Boolean (has_attachment): just true/false
matchContainer.innerHTML = `
<select class="cond-match" style="margin-bottom:0; width:auto;">
<option value="exact" selected>ist</option>
</select>
`;
valueContainer.innerHTML = `
<select class="cond-value" style="margin-bottom:0;">
<option value="true" ${data?.value === 'true' ? 'selected' : ''}>Ja</option>
<option value="false" ${data?.value === 'false' ? 'selected' : ''}>Nein</option>
</select>
`;
} else {
// Text fields
matchContainer.innerHTML = `
<select class="cond-match" style="margin-bottom:0; width:auto;">
${TEXT_MATCH_TYPES.map(m => `<option value="${m.value}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>
`;
valueContainer.innerHTML = `
<input type="text" class="cond-value" value="${data?.value || ''}" placeholder="Wert" required style="margin-bottom:0;">
`;
}
}
function onDateMatchChange(select) {
const row = select.closest('.cond-row');
const opt = select.options[select.selectedIndex];
const inputType = opt.dataset.inputType;
const placeholder = opt.dataset.placeholder;
const matchInfo = DATE_MATCH_TYPES.find(m => m.value === select.value);
const valueContainer = row.querySelector('.cond-value-container');
renderDateValueInput(valueContainer, matchInfo, '');
}
function renderDateValueInput(container, matchInfo, currentValue) {
if (matchInfo.inputType === 'daterange') {
// Two date inputs for range
const parts = currentValue ? currentValue.split(',') : ['', ''];
container.innerHTML = `
<div style="display:flex; gap:0.3rem; align-items:center;">
<input type="date" class="cond-value-from" value="${parts[0] || ''}" style="margin-bottom:0; flex:1;">
<span></span>
<input type="date" class="cond-value-to" value="${parts[1] || ''}" style="margin-bottom:0; flex:1;">
</div>
`;
} else if (matchInfo.inputType === 'date') {
container.innerHTML = `<input type="date" class="cond-value" value="${currentValue || ''}" style="margin-bottom:0;">`;
} else if (matchInfo.inputType === 'number') {
container.innerHTML = `<input type="number" class="cond-value" value="${currentValue || ''}" placeholder="${matchInfo.placeholder}" min="1" style="margin-bottom:0;">`;
}
}
function addAction(data = null) {
const list = document.getElementById('actions-list');
const idx = list.children.length;
const div = document.createElement('div');
div.style.marginBottom = '0.5rem';
const selectedType = ACTION_TYPES.find(a => a.value === (data?.action_type || 'move'));
const hideParam = selectedType && !selectedType.needsParam && !data?.parameter;
const inputId = `act_param_${Date.now()}_${idx}`;
div.innerHTML = `
<div style="display:flex; gap:0.5rem; align-items:center;">
<select name="act_type_${idx}" style="flex:0 0 auto; width:auto;" onchange="toggleActionParam(this)">
${ACTION_TYPES.map(a => `<option value="${a.value}" data-needs-param="${a.needsParam}" data-needs-folder="${a.needsFolder}" data-param-label="${a.paramLabel || ''}" ${data?.action_type === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
</select>
<div class="act-param-group" style="flex:1; display:${hideParam ? 'none' : 'flex'}; gap:0.5rem; align-items:center;">
<input type="text" name="${inputId}" value="${data?.parameter || ''}" placeholder="${selectedType?.paramLabel || 'Parameter'}" style="margin-bottom:0; flex:1;" ${selectedType?.needsFolder ? 'readonly' : ''}>
<button type="button" class="outline act-browse-btn" onclick="openFolderBrowser('${inputId}')" style="${selectedType?.needsFolder ? '' : 'display:none;'} margin-bottom:0; white-space:nowrap;">Durchsuchen</button>
</div>
<button type="button" class="outline small contrast" onclick="this.closest('[style*=margin-bottom]').remove()" style="margin-bottom:0;">X</button>
</div>
`;
list.appendChild(div);
}
function toggleActionParam(select) {
const opt = select.options[select.selectedIndex];
const row = select.closest('div');
const group = row.querySelector('.act-param-group');
const browseBtn = group.querySelector('.act-browse-btn');
const input = group.querySelector('input[type=text]');
if (opt.dataset.needsParam === 'true') {
group.style.display = 'flex';
input.placeholder = opt.dataset.paramLabel;
input.readOnly = opt.dataset.needsFolder === 'true';
} else {
group.style.display = 'none';
input.value = '';
}
browseBtn.style.display = opt.dataset.needsFolder === 'true' ? '' : 'none';
}
function openNewFilter() {
document.getElementById('dialog-title').textContent = 'Neue Filterregel';
document.getElementById('filter-id').value = '';
document.getElementById('filter-name').value = '';
document.getElementById('filter-priority').value = '100';
document.getElementById('filter-source-folder').value = 'INBOX';
document.getElementById('filter-stop-processing').checked = false;
document.getElementById('conditions-list').innerHTML = '';
document.getElementById('actions-list').innerHTML = '';
addCondition();
addAction();
document.getElementById('filter-dialog').showModal();
}
function editFilter(f) {
document.getElementById('dialog-title').textContent = 'Regel bearbeiten';
document.getElementById('filter-id').value = f.id;
document.getElementById('filter-name').value = f.name;
document.getElementById('filter-priority').value = f.priority;
document.getElementById('filter-source-folder').value = f.source_folder;
document.getElementById('filter-stop-processing').checked = f.stop_processing;
document.getElementById('conditions-list').innerHTML = '';
document.getElementById('actions-list').innerHTML = '';
for (const c of f.conditions) addCondition(c);
for (const a of f.actions) addAction(a);
document.getElementById('filter-dialog').showModal();
}
function collectFormData() {
const accountId = document.getElementById('account-select').value;
const conditions = [];
const condRows = document.getElementById('conditions-list').children;
for (let i = 0; i < condRows.length; i++) {
const row = condRows[i];
const fieldSelect = row.querySelector('.cond-field');
const matchSelect = row.querySelector('.cond-match');
const negate = row.querySelector('.cond-negate');
// Get value based on input type
let value = '';
const rangeFrom = row.querySelector('.cond-value-from');
const rangeTo = row.querySelector('.cond-value-to');
if (rangeFrom && rangeTo) {
value = rangeFrom.value + ',' + rangeTo.value;
} else {
const valInput = row.querySelector('.cond-value');
value = valInput ? valInput.value : '';
}
conditions.push({
field: fieldSelect.value,
match_type: matchSelect.value,
value: value,
negate: negate ? negate.checked : false,
});
}
const actions = [];
const actRows = document.getElementById('actions-list').children;
for (let i = 0; i < actRows.length; i++) {
const row = actRows[i];
const select = row.querySelector('select');
const input = row.querySelector('.act-param-group input[type=text]');
actions.push({
action_type: select.value,
parameter: input ? (input.value || null) : null,
});
}
return {
account_id: parseInt(accountId),
name: document.getElementById('filter-name').value,
priority: parseInt(document.getElementById('filter-priority').value),
source_folder: document.getElementById('filter-source-folder').value,
stop_processing: document.getElementById('filter-stop-processing').checked,
enabled: true,
conditions,
actions,
};
}
document.getElementById('filter-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = collectFormData();
const filterId = document.getElementById('filter-id').value;
let resp;
if (filterId) {
resp = await fetch(`/api/filters/${filterId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
} else {
resp = await fetch('/api/filters/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
}
if (resp.ok) {
document.getElementById('filter-dialog').close();
loadFilters();
} else {
const err = await resp.json();
alert('Fehler: ' + JSON.stringify(err));
}
});
async function deleteFilter(id) {
if (!confirm('Filterregel wirklich löschen?')) return;
await fetch(`/api/filters/${id}`, {method: 'DELETE'});
loadFilters();
}
// --- Ordner-Browser (Baumansicht) ---
let folderTargetInputName = null;
let folderSeparator = '.';
async function openFolderBrowser(targetInputName) {
const accountId = document.getElementById('account-select').value;
if (!accountId) {
alert('Bitte zuerst ein Konto auswählen');
return;
}
folderTargetInputName = targetInputName;
document.getElementById('folder-status').style.display = 'none';
document.getElementById('folder-browser').showModal();
await refreshFolderList(accountId);
}
async function refreshFolderList(accountId) {
const container = document.getElementById('folder-tree');
container.innerHTML = '<p aria-busy="true">Ordner werden geladen...</p>';
try {
const resp = await fetch(`/api/accounts/${accountId}/folders`);
const data = await resp.json();
// Detect separator: if any folder contains ".", use "."; otherwise "/"
folderSeparator = data.folders.some(f => f.includes('.')) ? '.' : '/';
renderFolderTree(data.folders);
} catch(e) {
container.innerHTML = `<p>Fehler beim Laden: ${e.message}</p>`;
}
}
function buildTree(folders) {
const root = {children: {}, folders: []};
for (const path of folders) {
const parts = path.split(folderSeparator);
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!node.children[part]) {
node.children[part] = {
name: part,
fullPath: parts.slice(0, i + 1).join(folderSeparator),
children: {},
depth: i,
};
}
node = node.children[part];
}
}
return root;
}
function renderFolderTree(folders) {
const container = document.getElementById('folder-tree');
if (!folders.length) {
container.innerHTML = '<p>Keine Ordner gefunden.</p>';
return;
}
const tree = buildTree(folders);
container.innerHTML = renderNode(tree, -1);
}
function renderNode(node, depth) {
let html = '';
const sortedKeys = Object.keys(node.children).sort();
for (const key of sortedKeys) {
const child = node.children[key];
const indent = (depth + 1) * 1.2;
const esc = child.fullPath.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const hasChildren = Object.keys(child.children).length > 0;
html += `<div class="folder-row" style="padding-left:${indent + 0.5}rem;" onclick="selectFolder('${esc}')">
<span class="folder-icon">${hasChildren ? '📂' : '📁'}</span>
<span class="folder-label">${child.name}</span>
${depth >= 0 ? `<span class="folder-path">${child.fullPath}</span>` : ''}
<button class="folder-add-btn" title="Unterordner erstellen" onclick="event.stopPropagation(); showNewSubfolder('${esc}', this)">📁+</button>
</div>`;
if (hasChildren) {
html += renderNode(child, depth + 1);
}
}
return html;
}
function selectFolder(folderName) {
const target = document.querySelector(`[name="${folderTargetInputName}"]`)
|| document.getElementById(folderTargetInputName);
if (target) target.value = folderName;
document.getElementById('folder-browser').close();
}
function showNewSubfolder(parentPath, btn) {
// Remove any existing new-folder input
document.querySelectorAll('.folder-new-input').forEach(el => el.remove());
const row = btn.closest('.folder-row');
const inputRow = document.createElement('div');
inputRow.className = 'folder-new-input';
inputRow.style.paddingLeft = row.style.paddingLeft;
inputRow.style.marginLeft = '1.8rem';
inputRow.innerHTML = `
<span style="margin-right:0.3rem;">📁</span>
<input type="text" placeholder="Neuer Ordnername" autofocus>
<button type="button" class="outline small" onclick="doCreateSubfolder('${parentPath.replace(/'/g, "\\'")}', this)">Erstellen</button>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
`;
row.after(inputRow);
inputRow.querySelector('input').focus();
inputRow.querySelector('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
doCreateSubfolder(parentPath, inputRow.querySelector('.outline.small'));
}
if (e.key === 'Escape') inputRow.remove();
});
}
async function doCreateSubfolder(parentPath, btn) {
const input = btn.parentElement.querySelector('input');
const name = input.value.trim();
if (!name) return;
const fullPath = parentPath + folderSeparator + name;
const accountId = document.getElementById('account-select').value;
const status = document.getElementById('folder-status');
btn.setAttribute('aria-busy', 'true');
try {
const resp = await fetch(`/api/accounts/${accountId}/folders`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({folder_name: fullPath}),
});
const data = await resp.json();
if (resp.ok) {
status.style.display = 'block';
status.textContent = `Ordner "${fullPath}" erstellt`;
setTimeout(() => status.style.display = 'none', 3000);
await refreshFolderList(accountId);
} else {
status.style.display = 'block';
status.textContent = 'Fehler: ' + (data.detail || JSON.stringify(data));
}
} catch(e) {
status.style.display = 'block';
status.textContent = 'Fehler: ' + e.message;
}
btn.removeAttribute('aria-busy');
}
// Auto-load if account is pre-selected
if (document.getElementById('account-select').value) loadFilters();
</script>
{% endblock %}