addes folder backup attachments logging

This commit is contained in:
2026-03-19 15:31:36 +01:00
parent 61c4384111
commit d148248682
29 changed files with 2298 additions and 115 deletions
+394 -42
View File
@@ -34,7 +34,10 @@
</label>
<label>
Quellordner
<input type="text" id="filter-source-folder" value="INBOX">
<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>
@@ -59,27 +62,121 @@
</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'},
{value: 'to', label: 'An'},
{value: 'subject', label: 'Betreff'},
{value: 'body', label: 'Text'},
{value: 'has_attachment', label: 'Hat Anhang'},
{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 MATCH_TYPES = [
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, paramLabel: 'Zielordner'},
{value: 'forward', label: 'Weiterleiten an', needsParam: true, paramLabel: 'E-Mail-Adresse'},
{value: 'delete', label: 'Löschen', needsParam: false},
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false},
{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() {
@@ -102,7 +199,7 @@ async function loadFilters() {
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 = MATCH_TYPES.find(x => x.value === c.match_type)?.label || c.match_type;
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 => {
@@ -129,51 +226,143 @@ async function loadFilters() {
function addCondition(data = null) {
const list = document.getElementById('conditions-list');
const idx = list.children.length;
const uid = Date.now() + '_' + list.children.length;
const div = document.createElement('div');
div.className = 'grid';
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 = `
<select name="cond_field_${idx}">
${FIELDS.map(f => `<option value="${f.value}" ${data?.field === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
</select>
<select name="cond_match_${idx}">
${MATCH_TYPES.map(m => `<option value="${m.value}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>
<input type="text" name="cond_value_${idx}" value="${data?.value || ''}" placeholder="Wert" required>
<label style="white-space:nowrap"><input type="checkbox" name="cond_negate_${idx}" ${data?.negate ? 'checked' : ''}> NOT</label>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
<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.className = 'grid';
div.style.marginBottom = '0.5rem';
const selectedType = ACTION_TYPES.find(a => a.value === data?.action_type);
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 = `
<select name="act_type_${idx}" onchange="toggleActionParam(this)">
${ACTION_TYPES.map(a => `<option value="${a.value}" data-needs-param="${a.needsParam}" data-param-label="${a.paramLabel || ''}" ${data?.action_type === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
</select>
<input type="text" name="act_param_${idx}" value="${data?.parameter || ''}" placeholder="${selectedType?.paramLabel || 'Parameter'}" ${selectedType?.needsParam === false && !data?.parameter ? 'style=display:none' : ''}>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
<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 input = select.parentElement.querySelector('input[type=text]');
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') {
input.style.display = '';
group.style.display = 'flex';
input.placeholder = opt.dataset.paramLabel;
input.readOnly = opt.dataset.needsFolder === 'true';
} else {
input.style.display = 'none';
group.style.display = 'none';
input.value = '';
}
browseBtn.style.display = opt.dataset.needsFolder === 'true' ? '' : 'none';
}
function openNewFilter() {
@@ -210,14 +399,26 @@ function collectFormData() {
const condRows = document.getElementById('conditions-list').children;
for (let i = 0; i < condRows.length; i++) {
const row = condRows[i];
const selects = row.querySelectorAll('select');
const input = row.querySelector('input[type=text]');
const checkbox = row.querySelector('input[type=checkbox]');
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: selects[0].value,
match_type: selects[1].value,
value: input.value,
negate: checkbox.checked,
field: fieldSelect.value,
match_type: matchSelect.value,
value: value,
negate: negate ? negate.checked : false,
});
}
const actions = [];
@@ -225,10 +426,10 @@ function collectFormData() {
for (let i = 0; i < actRows.length; i++) {
const row = actRows[i];
const select = row.querySelector('select');
const input = row.querySelector('input[type=text]');
const input = row.querySelector('.act-param-group input[type=text]');
actions.push({
action_type: select.value,
parameter: input.value || null,
parameter: input ? (input.value || null) : null,
});
}
return {
@@ -277,6 +478,157 @@ async function deleteFilter(id) {
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>