feat: ARIA kann E-Mails lesen und senden

IMAP/SMTP mit SSL/TLS, STARTTLS oder unverschluesselt. Senden mit Text, HTML,
Anhaengen und eingebetteten Bildern (cid:), Antworten mit In-Reply-To. Lesen,
Suchen, Ordner anlegen (auch Unterordner), Mails verschieben.
Tools: email_status/send/list/read/folders/folder_create/move.

Whitelist als harte Grenze im Sendepfad, nicht im Prompt: alles, was nicht
drinsteht, ist gesperrt, leer = nirgendwohin. Jeder Empfaenger zaehlt (An, Cc,
Bcc), ein fremder blockiert die ganze Mail. Genau eine Schreibweise:
name@domain, '*' nur als *@domain, *@*.domain oder *@*. '*hacker-net.de' liest
sich, als passte es auf 'evilhacker-net.de' — solche Kurzformen lehnt das
Speichern mit Korrekturvorschlag ab. Verglichen wird die Domain als Ganzes.

Zugangsdaten sieht ARIA nie:
- liegen im Brain unter /data. NICHT in /shared — das ist in ARIAs Container
  eingehaengt, /data nicht.
- keine Schnittstelle gibt das Passwort zurueck, nur password_set.
- Brain-Export laesst email_secret.json + email_admin.key aus (GNU- und
  busybox-tar geprueft, der Diagnostic-Container ist Alpine).
- Anhangspfade nach realpath geprueft: ein Symlink in /shared/uploads auf die
  Geheimnis-Datei wuerde sonst im Brain aufgeloest und verschickt.
- Konfiguration nur mit Admin-Schluessel (X-Aria-Admin, liegt in /data). Die
  Diagnostic haengt ihn an, weist aber Anfragen aus ARIAs Container ab —
  sonst koennte eine eingehende Mail sie per curl die Whitelist oeffnen lassen.
Grenze: ARIAs Container hat SSH auf die VM. Gegen einen gezielten Einbruch
schuetzt das nicht, nur dagegen, dass Zugangsdaten von selbst auftauchen.

Eingehende Mails sind fuer ARIA als Daten markiert, nicht als Anweisungen.

Diagnostic: Einstellungen → Integrationen → ARIA E-Mail, plus
"📬 Postfach oeffnen" — ARIAs ganzes Postfach in eigenem Fenster, Unterordner
eingerueckt, Posteingang + Gesendet oben. Nur lesend, markiert nichts als
gelesen. Mail-HTML in einem Rahmen ohne Skripte und ohne Nachladen von aussen
(kein Tracking-Pixel); eingebettete Bilder kommen als data: mit. Jede
gesendete Mail landet als Kopie in "Gesendet" — fehlt der Ordner, wird er
angelegt, statt die Kopie still zu verlieren.

Tests: 65 Einheitstests (Whitelist inkl. evilhacker-net.de, MIME-Aufbau,
Header-Injection, Symlink-Ausbruch, Geheimnis nie in Ausgaben) + 50 gegen einen
echten Mailserver (GreenMail): TLS-Login, Senden mit Anhang + Bild, Flags,
Suche mit Umlaut, Gesendet-Kopie, In-Reply-To, Ordner mit Umlaut, MOVE und
der Fallback ohne MOVE (der keine fremden geloeschten Mails mitreisst).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 18:14:31 +02:00
co-authored by Claude Opus 5.5
parent 6d978d72b3
commit d684bbb5f0
7 changed files with 1705 additions and 2 deletions
+211
View File
@@ -0,0 +1,211 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ARIA Postfach</title>
<style>
:root {
--bg: #0A0A14; --panel: #12121E; --line: #1E1E2E; --text: #E0E0F0;
--muted: #8888AA; --faint: #555570; --accent: #0096FF; --sel: rgba(0,150,255,0.14);
--unread: #E0E0F0; --err: #FF6B6B;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; }
.app { display: grid; grid-template-columns: 190px 360px 1fr; height: 100vh; }
.col { border-right: 1px solid var(--line); overflow: auto; min-width: 0; }
header { padding: 10px 12px; border-bottom: 1px solid var(--line); font-weight: 700;
display: flex; align-items: center; gap: 8px; position: sticky; top: 0; background: var(--bg); z-index: 1; }
.folder { padding: 7px 12px; cursor: pointer; color: var(--muted); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.folder:hover { background: var(--panel); }
.folder.active { background: var(--sel); color: var(--accent); font-weight: 600; }
.tools { padding: 8px; border-bottom: 1px solid var(--line); display: flex; gap: 6px;
position: sticky; top: 41px; background: var(--bg); z-index: 1; }
input[type=search] { flex: 1; min-width: 0; background: var(--panel); color: var(--text);
border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; font: inherit; }
button { background: var(--panel); color: var(--text); border: 1px solid #2A2A3E;
border-radius: 4px; padding: 4px 9px; font: inherit; font-size: 12px; cursor: pointer; }
button:hover { border-color: var(--accent); }
.msg { padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer; }
.msg:hover { background: var(--panel); }
.msg.active { background: var(--sel); }
.msg .from { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; display: flex; gap: 6px; }
.msg .from .date { margin-left: auto; color: var(--faint); flex: 0 0 auto; }
.msg .subj { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.msg.unread .subj { color: var(--unread); font-weight: 700; }
.msg.unread .from::before { content: "●"; color: var(--accent); }
.view { display: flex; flex-direction: column; min-width: 0; }
.meta { padding: 12px 16px; border-bottom: 1px solid var(--line); }
.meta h2 { margin: 0 0 6px; font-size: 16px; }
.meta div { color: var(--muted); font-size: 12px; }
.atts { padding: 8px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
.atts a { color: var(--accent); text-decoration: none; background: var(--panel);
border: 1px solid #2A2A3E; border-radius: 12px; padding: 2px 10px; font-size: 12px; }
.body { flex: 1; min-height: 0; }
.body iframe { width: 100%; height: 100%; border: 0; background: #fff; }
.body pre { margin: 0; padding: 16px; white-space: pre-wrap; word-break: break-word;
font: inherit; height: 100%; overflow: auto; }
.empty { padding: 30px; text-align: center; color: var(--faint); }
.err { padding: 14px; color: var(--err); }
.note { font-size: 11px; color: var(--faint); font-weight: 400; margin-left: auto; }
.more { padding: 10px; text-align: center; }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; grid-template-rows: auto 40vh 1fr; height: auto; }
.col { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
}
</style>
</head>
<body>
<div class="app">
<div class="col">
<header>📬 ARIA</header>
<div id="folders"><div class="empty">Lade …</div></div>
</div>
<div class="col">
<header><span id="folder-title">INBOX</span><span class="note" id="count"></span></header>
<div class="tools">
<input type="search" id="search" placeholder="Suchen …" onkeydown="if(event.key==='Enter')reload()">
<button onclick="reload()" title="Neu laden">↻</button>
</div>
<div id="list"><div class="empty">Lade …</div></div>
</div>
<div class="col view" id="view">
<div class="empty">Mail auswählen.<br><br>
<span style="font-size:11px;">Nur lesen — hier wird nichts als gelesen markiert,
damit ARIA weiterhin weiß, was neu ist.</span></div>
</div>
</div>
<script>
// ARIAs Postfach, nur lesend. Holt alles ueber den Brain-Proxy der
// Diagnostic (/api/brain/email/*). Zugangsdaten kennt diese Seite nicht.
const API = '/api/brain/email';
const PAGE = 50;
let folder = 'INBOX', offset = 0, total = 0, activeUid = null;
let folderRoles = {};
const esc = s => String(s ?? '').replace(/[&<>"']/g,
c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function api(path) {
const r = await fetch(API + path);
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
return d;
}
function shortDate(s) {
const t = Date.parse(s);
if (isNaN(t)) return s || '';
const d = new Date(t), now = new Date();
return d.toDateString() === now.toDateString()
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function nameOnly(from) {
const m = /^\s*"?([^"<]*?)"?\s*<([^>]+)>/.exec(from || '');
return m ? (m[1] || m[2]) : (from || '');
}
async function loadFolders() {
const box = document.getElementById('folders');
try {
const d = await api('/folders');
folderRoles = Object.fromEntries(d.folders.map(f => [f.name, f.role || '']));
const icon = { inbox: '📥 ', sent: '📤 ' };
box.innerHTML = d.folders.map(f =>
`<div class="folder${f.name === folder ? ' active' : ''}" data-name="${esc(f.name)}"
title="${esc(f.label)}" style="padding-left:${12 + 14 * (f.role ? 0 : (f.depth || 0))}px">${icon[f.role] || ''}${esc(f.role ? f.label : (f.short || f.label))}</div>`).join('') || '<div class="empty">Keine Ordner</div>';
box.querySelectorAll('.folder').forEach(el => el.onclick = () => selectFolder(el.dataset.name, el.title));
} catch (e) {
box.innerHTML = `<div class="err">${esc(e.message)}</div>`;
document.getElementById('list').innerHTML = '';
}
}
function selectFolder(name, label) {
folder = name;
document.getElementById('folder-title').textContent = label || name;
document.querySelectorAll('.folder').forEach(el => el.classList.toggle('active', el.dataset.name === name));
reload();
}
function reload() { offset = 0; loadList(false); }
async function loadList(append) {
const list = document.getElementById('list');
if (!append) list.innerHTML = '<div class="empty">Lade …</div>';
const q = document.getElementById('search').value.trim();
try {
const d = await api(`/messages?folder=${encodeURIComponent(folder)}&limit=${PAGE}&offset=${offset}`
+ (q ? `&search=${encodeURIComponent(q)}` : ''));
total = d.total;
document.getElementById('count').textContent = total ? `${total}` : '';
const sent = folderRoles[folder] === 'sent';
const html = d.messages.map(m => `
<div class="msg${m.seen || sent ? '' : ' unread'}${m.uid === activeUid ? ' active' : ''}" data-uid="${esc(m.uid)}">
<div class="from"><span>${sent ? 'an ' + esc(nameOnly(m.to)) : esc(nameOnly(m.from))}</span><span class="date">${esc(shortDate(m.date))}</span></div>
<div class="subj">${m.maybe_attachments ? '📎 ' : ''}${esc(m.subject || '(kein Betreff)')}</div>
</div>`).join('');
if (!append) list.innerHTML = '';
list.querySelector('.more')?.remove();
list.insertAdjacentHTML('beforeend', html || (append ? '' : '<div class="empty">Keine Mails.</div>'));
offset += d.messages.length;
if (offset < total) list.insertAdjacentHTML('beforeend',
`<div class="more"><button onclick="loadList(true)">Weitere laden (${total - offset})</button></div>`);
list.querySelectorAll('.msg').forEach(el => el.onclick = () => openMessage(el.dataset.uid));
} catch (e) {
list.innerHTML = `<div class="err">${esc(e.message)}</div>`;
}
}
// Mail-HTML in einem Rahmen OHNE Skripte und ohne Zugriff auf diese Seite
// (sandbox ohne allow-scripts/allow-same-origin). Die CSP verbietet jedes
// Nachladen von aussen — kein Tracking-Pixel meldet "gelesen". Bilder, die
// in der Mail stecken, kommen als data: (das Brain baut cid: um).
function frameFor(html) {
const csp = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:";
const doc = `<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<base target="_blank"><style>body{font:14px/1.5 sans-serif;margin:16px;color:#111;}
img{max-width:100%;height:auto;}</style></head><body>${html}</body></html>`;
const f = document.createElement('iframe');
f.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox');
f.setAttribute('referrerpolicy', 'no-referrer');
f.srcdoc = doc;
return f;
}
async function openMessage(uid) {
activeUid = uid;
document.querySelectorAll('.msg').forEach(el => el.classList.toggle('active', el.dataset.uid === uid));
const view = document.getElementById('view');
view.innerHTML = '<div class="empty">Lade …</div>';
try {
const m = await api(`/message?folder=${encodeURIComponent(folder)}&uid=${encodeURIComponent(uid)}`);
const atts = m.attachments.length
? `<div class="atts">${m.attachments.map(a => `<a href="${API}/attachment?folder=${encodeURIComponent(folder)}&uid=${encodeURIComponent(uid)}&index=${a.index}"
download="${esc(a.name)}">📎 ${esc(a.name)} <span style="color:var(--faint)">${Math.max(1, Math.round(a.size / 1024))} KB</span></a>`).join('')}</div>`
: '';
view.innerHTML = `
<div class="meta">
<h2>${esc(m.subject || '(kein Betreff)')}</h2>
<div>Von: ${esc(m.from)}</div>
<div>An: ${esc(m.to)}${m.cc ? ' · Cc: ' + esc(m.cc) : ''}</div>
<div>${esc(m.date)}</div>
</div>${atts}<div class="body" id="body"></div>`;
const body = document.getElementById('body');
if (m.html) body.appendChild(frameFor(m.html));
else body.innerHTML = `<pre>${esc(m.text || '(leer)')}</pre>`;
} catch (e) {
view.innerHTML = `<div class="err">${esc(e.message)}</div>`;
}
}
loadFolders().then(reload);
</script>
</body>
</html>
+171
View File
@@ -1108,6 +1108,76 @@
</div>
</div>
<!-- ARIA E-Mail -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📧 ARIA E-Mail</h2>
<button class="btn" onclick="openMailbox()" title="ARIAs Postfach in einem eigenen Fenster — nur lesen, markiert nichts als gelesen" style="padding:4px 12px;font-size:11px;">📬 Postfach öffnen</button>
</div>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;line-height:1.55;">
ARIA kann Mails lesen und senden — Text, HTML, Anhänge, eingebettete Bilder.
<strong style="color:#E0E0F0;">Die Zugangsdaten sieht sie nie:</strong> gespeichert im
Brain unter <code>/data</code> (nicht in ihrem Container), keine Schnittstelle gibt das
Passwort zurück, der Brain-Export lässt es aus. Ändern kann diese Einstellungen nur die
Diagnostic — Anfragen aus ARIAs Container werden abgewiesen.
</div>
<div class="card" style="max-width:780px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Absender-Name<input type="text" id="mail-from-name" placeholder="ARIA" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Absender-Adresse<input type="text" id="mail-from-address" placeholder="aria@hacker-net.de" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
</div>
<div style="margin-top:12px;font-size:12px;color:#E0E0F0;font-weight:600;">Posteingang (IMAP)</div>
<div style="display:grid;grid-template-columns:2fr 1fr 1.4fr;gap:10px;margin-top:4px;">
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Server<input type="text" id="mail-imap-host" placeholder="imap.beispiel.de" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Port<input type="number" id="mail-imap-port" placeholder="993" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Verschlüsselung<select id="mail-imap-encryption" onchange="mailEncChanged('mail-imap-encryption')" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;"><option value="ssl">SSL/TLS</option><option value="starttls">STARTTLS</option><option value="none">keine (unverschlüsselt)</option></select></label>
</div>
<div style="margin-top:12px;font-size:12px;color:#E0E0F0;font-weight:600;">Postausgang (SMTP)</div>
<div style="display:grid;grid-template-columns:2fr 1fr 1.4fr;gap:10px;margin-top:4px;">
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Server<input type="text" id="mail-smtp-host" placeholder="smtp.beispiel.de" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Port<input type="number" id="mail-smtp-port" placeholder="465" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Verschlüsselung<select id="mail-smtp-encryption" onchange="mailEncChanged('mail-smtp-encryption')" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;"><option value="ssl">SSL/TLS</option><option value="starttls">STARTTLS</option><option value="none">keine (unverschlüsselt)</option></select></label>
</div>
<div style="margin-top:12px;font-size:12px;color:#E0E0F0;font-weight:600;">Anmeldung</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:4px;">
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Benutzername<input type="text" id="mail-username" placeholder="meist die Mail-Adresse" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">Passwort<input type="password" id="mail-password" placeholder="" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
</div>
<details style="margin-top:8px;font-size:11px;color:#8888AA;">
<summary style="cursor:pointer;">SMTP mit abweichender Anmeldung (selten, z.B. Relay)</summary>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px;">
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">SMTP-Benutzername<input type="text" id="mail-smtp-username" placeholder="leer = wie oben" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
<label style="display:flex;flex-direction:column;gap:3px;font-size:11px;color:#8888AA;">SMTP-Passwort<input type="password" id="mail-smtp-password" placeholder="" autocomplete="off" style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;"></label>
</div>
</details>
<div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;font-size:11px;color:#8888AA;">
<label style="cursor:pointer;"><input type="checkbox" id="mail-verify-tls" checked> Zertifikat prüfen</label>
<label style="cursor:pointer;" title="Eine Kopie jeder Mail, die ARIA sendet, landet in deinem Ordner „Gesendet“ — dann siehst du im Postfach, was sie verschickt hat"><input type="checkbox" id="mail-save-sent" checked> Kopie in „Gesendet“ ablegen</label>
</div>
<div style="margin-top:14px;font-size:12px;color:#E0E0F0;font-weight:600;">Whitelist — nur hierhin darf ARIA senden</div>
<div style="font-size:11px;color:#8888AA;margin:4px 0 6px;line-height:1.5;">
Eine Zeile pro Eintrag. Alles, was nicht hier steht, ist gesperrt —
<strong style="color:#FF9F0A;">leer = ARIA kann an niemanden senden.</strong>
Gilt für jeden Empfänger (An, Cc, Bcc); ein einziger fremder blockiert die ganze Mail.
<br>
<code>stefan@hacker-net.de</code> genau diese Adresse ·
<code>*@hacker-net.de</code> alle Adressen der Domain ·
<code>*@*.hacker-net.de</code> alle Subdomains ·
<code>*@*</code> alles
</div>
<textarea id="mail-whitelist" rows="5" placeholder="*@hacker-net.de&#10;mama@gmx.de"
style="background:#0A0A14;color:#E0E0F0;border:1px solid #2A2A3E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;font-family:monospace;resize:vertical;"></textarea>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="btn" onclick="saveMailConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
<button class="btn secondary" onclick="testMailConfig()" title="Meldet sich an beiden Servern an — sendet nichts" style="padding:6px 14px;font-size:12px;">🔌 Verbindung testen</button>
<span id="mail-status" style="color:#8888AA;font-size:11px;white-space:pre-wrap;"></span>
</div>
</div>
</div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
@@ -5003,6 +5073,7 @@
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
loadMailConfig();
refreshVoiceIdStatus();
send({ action: 'get_attachment_limit' });
send({ action: 'get_privacy_flags' });
@@ -5275,6 +5346,106 @@
send({ action: 'host_forget', hostId });
}
// ── ARIA E-Mail ───────────────────────────────────────────────────
// Konfiguration ueber /api/email-admin/* — die Diagnostic haengt dort den
// Admin-Schluessel an (siehe server.js). Passwoerter kommen NIE zurueck,
// nur password_set; ein leeres Passwortfeld heisst "unveraendert".
const MAIL_PORTS = { imap: { ssl: 993, starttls: 143, none: 143 },
smtp: { ssl: 465, starttls: 587, none: 25 } };
function _mailStatus(text, color) {
const el = document.getElementById('mail-status');
if (el) { el.textContent = text; el.style.color = color || '#8888AA'; }
}
// Port beim Wechsel der Verschluesselung mitziehen — aber nur, wenn noch
// der Standard-Port der alten Wahl drinsteht. Ein bewusst eigener Port
// bleibt stehen.
function mailEncChanged(id) {
const proto = id.includes('imap') ? 'imap' : 'smtp';
const sel = document.getElementById(id);
const port = document.getElementById(`mail-${proto}-port`);
const prev = sel.dataset.prev || sel.value;
const std = Object.values(MAIL_PORTS[proto]).map(String);
if (!port.value || std.includes(port.value) && port.value === String(MAIL_PORTS[proto][prev])) {
port.value = MAIL_PORTS[proto][sel.value];
}
sel.dataset.prev = sel.value;
}
async function loadMailConfig() {
try {
const r = await fetch('/api/email-admin/config');
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v ?? ''; };
set('mail-from-name', d.from_name); set('mail-from-address', d.from_address);
set('mail-imap-host', d.imap_host); set('mail-imap-port', d.imap_port);
set('mail-imap-encryption', d.imap_encryption);
set('mail-smtp-host', d.smtp_host); set('mail-smtp-port', d.smtp_port);
set('mail-smtp-encryption', d.smtp_encryption);
set('mail-username', d.username); set('mail-smtp-username', d.smtp_username);
set('mail-password', ''); set('mail-smtp-password', '');
document.getElementById('mail-password').placeholder = d.password_set ? '•••••• gesetzt — leer lassen = unverändert' : 'nicht gesetzt';
document.getElementById('mail-smtp-password').placeholder = d.smtp_password_set ? '•••••• gesetzt' : 'leer = wie oben';
['mail-imap-encryption', 'mail-smtp-encryption'].forEach(id => {
const el = document.getElementById(id); if (el) el.dataset.prev = el.value; });
document.getElementById('mail-verify-tls').checked = d.verify_tls !== false;
document.getElementById('mail-save-sent').checked = d.save_sent !== false;
set('mail-whitelist', (d.whitelist || []).join('\n'));
_mailStatus((d.whitelist || []).length ? '' : 'Whitelist leer — ARIA kann an niemanden senden.', '#FF9F0A');
} catch (e) { _mailStatus('Laden fehlgeschlagen: ' + e.message, '#FF6B6B'); }
}
async function saveMailConfig() {
const v = id => (document.getElementById(id)?.value || '').trim();
const body = {
from_name: v('mail-from-name'), from_address: v('mail-from-address'),
imap_host: v('mail-imap-host'), imap_port: Number(v('mail-imap-port') || 993),
imap_encryption: v('mail-imap-encryption'),
smtp_host: v('mail-smtp-host'), smtp_port: Number(v('mail-smtp-port') || 465),
smtp_encryption: v('mail-smtp-encryption'),
username: v('mail-username'), smtp_username: v('mail-smtp-username'),
verify_tls: document.getElementById('mail-verify-tls').checked,
save_sent: document.getElementById('mail-save-sent').checked,
whitelist: v('mail-whitelist').split(/\n+/).map(x => x.trim()).filter(Boolean),
};
// Passwoerter nur mitschicken, wenn etwas eingetippt wurde.
const pw = document.getElementById('mail-password').value;
const spw = document.getElementById('mail-smtp-password').value;
if (pw) body.password = pw;
if (spw) body.smtp_password = spw;
if (body.imap_encryption === 'none' || body.smtp_encryption === 'none') {
if (!confirm('Ohne Verschlüsselung gehen Passwort und Mails im Klartext durchs Netz. Trotzdem speichern?')) return;
}
_mailStatus('Speichere …');
try {
const r = await fetch('/api/email-admin/config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
await loadMailConfig();
_mailStatus('✓ Gespeichert' + ((d.whitelist || []).length ? '' : ' — Whitelist leer, ARIA kann an niemanden senden.'),
(d.whitelist || []).length ? '#34C759' : '#FF9F0A');
} catch (e) { _mailStatus('✗ ' + e.message, '#FF6B6B'); }
}
async function testMailConfig() {
_mailStatus('Teste Anmeldung an beiden Servern …');
try {
const r = await fetch('/api/email-admin/test', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
const line = (n, x) => x && x.ok ? `✓ ${n} ok` : `✗ ${n}: ${(x && x.error) || '?'}`;
const ok = d.imap?.ok && d.smtp?.ok;
_mailStatus(line('IMAP', d.imap) + '\n' + line('SMTP', d.smtp), ok ? '#34C759' : '#FF6B6B');
} catch (e) { _mailStatus('✗ ' + e.message, '#FF6B6B'); }
}
function openMailbox() {
window.open('/email.html', 'aria-mailbox', 'width=1200,height=800');
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
let jellyfinServers = [];
+78 -1
View File
@@ -2075,6 +2075,67 @@ setInterval(checkDiskSpace, 30000);
// ── HTTP Server + WebSocket fuer Browser ────────────────
const htmlPath = path.join(__dirname, "index.html");
const mailHtmlPath = path.join(__dirname, "email.html");
// ── ARIA E-Mail: Admin-Weg zur Konfiguration ────────────────────────
// Die Brain-Endpunkte /email/config + /email/test verlangen den Header
// X-Aria-Admin. Der Schluessel liegt in ./aria-data/brain/data — hier unter
// /brain/data eingehaengt, in ARIAs Container (proxy) NICHT.
//
// Haken: ARIAs Container erreicht die Diagnostic ueber das Docker-Netz.
// Wuerde die Diagnostic den Schluessel blind anhaengen, koennte ARIA per curl
// ueber diesen Umweg doch die Whitelist aendern. Darum: Anfragen, die aus
// ihrem Container kommen, bekommen den Schluessel nicht.
const MAIL_ADMIN_KEY_FILE = "/brain/data/email_admin.key";
const dnsPromises = require("dns").promises;
async function requestFromAriaContainer(req) {
const ip = String(req.socket.remoteAddress || "").replace(/^::ffff:/, "");
for (const host of ["aria-proxy", "proxy"]) {
try {
const addrs = await dnsPromises.lookup(host, { all: true });
if (addrs.some(a => a.address === ip)) return true;
} catch (_) { /* Name unbekannt → naechster */ }
}
return false;
}
function mailAdminKey() {
try { return fs.readFileSync(MAIL_ADMIN_KEY_FILE, "utf-8").trim(); } catch (_) { return ""; }
}
async function handleMailAdmin(req, res, brainPath) {
const reply = (code, obj) => {
res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify(obj));
};
if (await requestFromAriaContainer(req)) {
log("warn", "server", `E-Mail-Konfiguration: Zugriff aus ARIAs Container abgewiesen (${req.method} ${brainPath})`);
return reply(403, { detail: "Die E-Mail-Konfiguration aendert nur Stefan in der Diagnostic." });
}
const key = mailAdminKey();
if (!key) return reply(503, { detail: "Brain hat noch keinen Admin-Schluessel angelegt — laeuft aria-brain?" });
let body = "";
req.on("data", c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
req.on("end", () => {
const proxyReq = http.request({
host: "aria-brain", port: 8080, path: brainPath, method: req.method,
headers: { "Content-Type": "application/json", "X-Aria-Admin": key,
...(body ? { "Content-Length": Buffer.byteLength(body) } : {}) },
timeout: 60000,
}, (pr) => {
let out = "";
pr.on("data", c => out += c);
pr.on("end", () => {
res.writeHead(pr.statusCode || 502, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(out);
});
});
proxyReq.on("error", (err) => reply(503, { detail: "Brain nicht erreichbar: " + err.message }));
if (body) proxyReq.write(body);
proxyReq.end();
});
}
const server = http.createServer((req, res) => {
if (req.url === "/" || req.url === "/index.html") {
@@ -2089,6 +2150,16 @@ const server = http.createServer((req, res) => {
"Expires": "0",
});
res.end(fs.readFileSync(htmlPath, "utf-8"));
} else if (req.url === "/email.html") {
// ARIAs Postfach in eigenem Fenster (Button in Einstellungen → ARIA E-Mail).
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
res.end(fs.readFileSync(mailHtmlPath, "utf-8"));
} else if (req.url === "/api/email-admin/config" && (req.method === "GET" || req.method === "POST")) {
handleMailAdmin(req, res, "/email/config");
return;
} else if (req.url === "/api/email-admin/test" && req.method === "POST") {
handleMailAdmin(req, res, "/email/test");
return;
} else if (req.url.startsWith("/uploads/")) {
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
@@ -2835,7 +2906,13 @@ const server = http.createServer((req, res) => {
"Content-Disposition": `attachment; filename="aria-brain-${ts}.tar.gz"`,
"Cache-Control": "no-store",
});
const tar = spawn("tar", ["czf", "-", "-C", "/brain", "."]);
// E-Mail-Passwort + Admin-Schluessel NICHT ins Backup: ein Export
// wandert auf andere Rechner, in Cloud-Ordner, an Kumpels. Nach einem
// Import traegt Stefan das Passwort neu ein, der Schluessel entsteht
// beim Brain-Start von selbst.
const tar = spawn("tar", ["czf", "-", "-C", "/brain",
"--exclude=./data/email_secret.json",
"--exclude=./data/email_admin.key", "."]);
tar.stdout.pipe(res);
let stderr = "";
tar.stderr.on("data", d => stderr += d.toString());