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>
212 lines
10 KiB
HTML
212 lines
10 KiB
HTML
<!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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|