Übergabe von E-Mails als PDF mit eingebetteten Anlagen

This commit is contained in:
2026-09-07 22:33:55 +02:00
parent fd87813cbe
commit d032e3b19c
16 changed files with 1363 additions and 28 deletions

View File

@@ -16,6 +16,7 @@ import {
ladeEinstellungen, vollstaendig, sichererName, versionslabel,
titelAusBetreff, zugriffsmuster, t
} from "./lib/settings.js";
import { baueMailPdf, alsEmpfaenger } from "./lib/mailpdf.js";
/**
* Ergebnis einer Nachrichten-Abfrage vereinheitlichen.
@@ -90,6 +91,16 @@ function alsDatum(wert) {
return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10);
}
/** Datum mit Uhrzeit für die Anzeige im PDF. */
function alsZeitpunkt(wert) {
if (!wert) return "";
const d = wert instanceof Date ? wert : new Date(wert);
if (Number.isNaN(d.getTime())) return "";
const z = (n) => String(n).padStart(2, "0");
return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()} `
+ `${z(d.getHours())}:${z(d.getMinutes())} Uhr`;
}
// Beim Laden eine Zeile ausgeben. Fehlt sie in der Konsole, ist das
// Skript gar nicht erst gestartet - und dann kommt bei jedem Aufruf aus
// einem Dialog nur "An unexpected error occurred".
@@ -196,6 +207,7 @@ async function anhangAblegen(info, tab) {
betreff: nachricht.subject,
absender: nachricht.author,
datum: alsDatum(nachricht.date),
zeitpunkt: alsZeitpunkt(nachricht.date),
dateien
});
}
@@ -217,6 +229,7 @@ async function nachrichtAblegen(info, tab) {
betreff: nachricht.subject,
absender: nachricht.author,
datum: alsDatum(nachricht.date),
zeitpunkt: alsZeitpunkt(nachricht.date),
dateien: anhaenge.map((a) => ({
name: sichererName(a.name),
size: a.size,
@@ -400,14 +413,272 @@ async function berechtigungPruefen() {
// -------------------------------------------------------------- Vorgänge
async function datenHolen(auftrag) {
if (auftrag.quelle === "nachricht") {
const roh = await browser.messages.getRaw(auftrag.messageId);
const blob = new Blob([roh], { type: "message/rfc822" });
const name = sichererName(
`${titelAusBetreff(auftrag.betreff)}.eml`);
return [{ blob, name }];
/**
* Nachrichtentext als reinen Text beschaffen.
*
* Bevorzugt wird der text/plain-Teil. Gibt es nur HTML, übernimmt
* Thunderbird die Umwandlung - convertToPlainText ist dieselbe Funktion,
* die beim Antworten in Nur-Text greift, und liefert deutlich bessere
* Ergebnisse als eine selbstgebaute Entschlackung.
*/
async function nachrichtentext(messageId) {
let teile = [];
if (browser.messages.listInlineTextParts) {
try {
teile = await browser.messages.listInlineTextParts(messageId) || [];
} catch { /* ältere Stände kennen die Funktion nicht */ }
}
// Rückfall: die Baumstruktur aus getFull durchwandern
if (!teile.length) {
try {
const voll = await browser.messages.getFull(messageId);
const sammeln = (teil) => {
if (teil?.body && /^text\/(plain|html)$/.test(teil.contentType || "")) {
teile.push({ contentType: teil.contentType, content: teil.body });
}
for (const k of teil?.parts || []) sammeln(k);
};
sammeln(voll);
} catch (err) {
console.warn("[paperless] getFull fehlgeschlagen", err);
}
}
const klartext = teile.find((x) => (x.contentType || "") === "text/plain");
if (klartext?.content?.trim()) return klartext.content;
const html = teile.find((x) => (x.contentType || "") === "text/html");
if (html?.content) {
if (browser.messengerUtilities?.convertToPlainText) {
try {
return await browser.messengerUtilities.convertToPlainText(
html.content);
} catch { /* dann der grobe Weg */ }
}
// Notbehelf ohne die Thunderbird-Funktion. Bewusst schlicht: Blöcke
// werden zu Zeilenumbrüchen, Auszeichnung fällt weg.
return html.content
.replace(/<\s*(br|\/p|\/div|\/tr|\/li|\/h[1-6])[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<").replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"').replace(/&#39;/gi, "'")
.replace(/\n{3,}/g, "\n\n");
}
return "";
}
/**
* Nachricht als PDF aufbereiten.
*
* Das Original und alle Anhänge werden im PDF eingebettet - es geht also
* nichts verloren, auch wenn die Darstellung nicht originalgetreu ist.
*/
/**
* Adresse des Kontos, in dem die Nachricht liegt.
*
* Bei Nachrichten an Blindkopie-Empfänger geht aus den Kopfzeilen nicht
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
* entscheidende Angabe.
*/
/**
* Adresse des Kontos, in dem die Nachricht liegt.
*
* Bei Nachrichten an Blindkopie-Empfänger geht aus An und Kopie nicht
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
* entscheidende Angabe.
*
* Zwei Wege, weil der erste eine Berechtigung braucht:
*
* 1. accounts-Schnittstelle. Genau, aber accountsRead ist nötig - und
* dieselbe Berechtigung entscheidet auch darüber, ob messages.get()
* überhaupt eine Ordnerangabe liefert.
* 2. Zustellkopfzeilen. Delivered-To und Verwandte tragen die Adresse,
* an die tatsächlich zugestellt wurde. Sie sind nicht immer
* vorhanden und stammen vom zustellenden Server, reichen aber ohne
* zusätzliche Berechtigung aus.
*/
async function kontoAdresse(kopf, messageId) {
const ueberAccounts = await kontoUeberAccounts(kopf);
if (ueberAccounts) return ueberAccounts;
return kontoUeberKopfzeilen(messageId);
}
async function kontoUeberAccounts(kopf) {
if (!browser.accounts?.get) {
console.debug("[paperless] accounts-Schnittstelle nicht verfügbar - "
+ "Berechtigung accountsRead fehlt. Ausweichweg über "
+ "die Zustellkopfzeilen.");
return "";
}
// Die Kontokennung steckt je nach Version an verschiedenen Stellen.
const kandidaten = [
kopf?.folder?.accountId,
kopf?.folder?.id,
typeof kopf?.folder === "string" ? kopf.folder : null,
kopf?.accountId
].filter(Boolean);
const kennungen = [];
for (const k of kandidaten) {
kennungen.push(String(k));
const m = /^([^:/]+)/.exec(String(k));
if (m && m[1] !== String(k)) kennungen.push(m[1]);
}
for (const kennung of kennungen) {
try {
const konto = await browser.accounts.get(kennung);
const adresse = adresseAusKonto(konto);
if (adresse) return adresse;
} catch { /* nächster Kandidat */ }
}
// Gibt es nur ein Konto, ist die Zuordnung ohnehin eindeutig.
try {
const alle = await browser.accounts.list(false);
if (alle?.length === 1) return adresseAusKonto(alle[0]);
} catch { /* dann eben nicht */ }
return "";
}
function adresseAusKonto(konto) {
if (!konto) return "";
const ident = konto.identities || [];
const erste = ident.find((i) => i.email) || ident[0];
if (erste?.email) {
return erste.name ? `${erste.name} <${erste.email}>` : erste.email;
}
return konto.name || "";
}
/**
* Empfangsadresse aus den Zustellkopfzeilen.
*
* Diese Kopfzeilen setzt der zustellende Server. Delivered-To ist die
* verbreitetste; X-Original-To und Envelope-To kommen bei anderen
* Servern vor.
*/
async function kontoUeberKopfzeilen(messageId) {
if (!messageId || !browser.messages?.getFull) return "";
try {
const voll = await browser.messages.getFull(messageId);
const h = voll?.headers || {};
for (const name of ["delivered-to", "x-original-to", "envelope-to",
"x-envelope-to", "x-rcpt-to"]) {
const wert = h[name];
if (Array.isArray(wert) && wert.length && String(wert[0]).trim()) {
return String(wert[0]).trim();
}
if (typeof wert === "string" && wert.trim()) return wert.trim();
}
console.debug("[paperless] keine Zustellkopfzeile gefunden. Vorhanden:",
Object.keys(h).join(", "));
} catch (err) {
console.debug("[paperless] Kopfzeilen nicht lesbar:", err?.message);
}
return "";
}
async function nachrichtAlsPdf(auftrag, s) {
const id = auftrag.messageId;
const kopf = await browser.messages.get(id).catch(() => ({}));
const text = await nachrichtentext(id);
const konto = await kontoAdresse(kopf, id);
const dateien = [];
const aufstellung = [];
// Das Original zuerst - es ist der wichtigste Anhang.
try {
const roh = await browser.messages.getRaw(id);
dateien.push({
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`),
daten: new TextEncoder().encode(roh),
mime: "message/rfc822",
beschreibung: "Originalnachricht"
});
} catch (err) {
console.warn("[paperless] getRaw fehlgeschlagen", err);
}
// Dann die Anhänge, sofern gewünscht.
if (s.pdfAnhaengeEinbetten !== false) {
for (const a of auftrag.dateien || []) {
aufstellung.push({ name: a.name, groesse: a.size });
try {
const datei = await browser.messages.getAttachmentFile(
a.messageId, a.partName);
dateien.push({
name: sichererName(a.name),
daten: new Uint8Array(await datei.arrayBuffer()),
mime: a.contentType || "application/octet-stream",
beschreibung: "Anhang der Nachricht"
});
} catch (err) {
console.warn("[paperless] Anhang nicht lesbar:", a.name, err);
}
}
} else {
for (const a of auftrag.dateien || []) {
aufstellung.push({ name: a.name, groesse: a.size });
}
}
const pdf = baueMailPdf({
betreff: auftrag.betreff || kopf.subject || "",
konto,
von: kopf.author || auftrag.absender || "",
an: alsEmpfaenger(kopf.recipients),
kopie: alsEmpfaenger(kopf.ccList),
datum: auftrag.zeitpunkt || auftrag.datum || "",
text,
anhaenge: aufstellung,
dateien
}, {
schrift: Number(s.pdfSchriftgroesse) || 11,
format: s.pdfFormat || "a4",
rand: {
links: Number(s.pdfRandLinks ?? 20),
rechts: Number(s.pdfRandRechts ?? 10),
oben: Number(s.pdfRandOben ?? 10),
unten: Number(s.pdfRandUnten ?? 10)
}
});
return {
blob: new Blob([pdf], { type: "application/pdf" }),
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.pdf`)
};
}
async function datenHolen(auftrag, s) {
if (auftrag.quelle === "nachricht") {
// Als PDF aufbereiten oder im Rohformat ablegen - beides hat seine
// Berechtigung. Das Rohformat ist verlustfrei und wieder in ein
// Mailprogramm einlesbar, das PDF dafür lesbar und durchsuchbar.
// Die Auswahl aus dem Dialog hat Vorrang vor der Voreinstellung.
const alsPdf = auftrag.alsPdf !== undefined
? Boolean(auftrag.alsPdf)
: (s.mailFormat || "pdf") === "pdf";
if (alsPdf) {
try {
return [await nachrichtAlsPdf(auftrag, s)];
} catch (err) {
console.error("[paperless] PDF-Aufbereitung gescheitert", err);
// Nicht abbrechen: lieber die Rohfassung ablegen als gar nichts.
}
}
const roh = await browser.messages.getRaw(auftrag.messageId);
return [{
blob: new Blob([roh], { type: "message/rfc822" }),
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`)
}];
}
const out = [];
for (const d of auftrag.dateien || []) {
if (!d.ausgewaehlt) continue;
@@ -421,7 +692,7 @@ async function datenHolen(auftrag) {
async function ablegen(nachricht) {
const c = await client();
const s = await ladeEinstellungen();
const dateien = await datenHolen(nachricht.auftrag);
const dateien = await datenHolen(nachricht.auftrag, s);
if (!dateien.length) throw new PaperlessError(t("errNothingSelected"));
const ergebnisse = [];