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:
+78
-1
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user