package backend import ( "bytes" "fmt" "html" "io" "net/http" "net/mail" "os" "path/filepath" "sort" "strconv" "strings" "time" ) // Der Viewer betrachtet die LOKALEN mbox-Dateien im Browser (Outlook-2013- // Dreispalter): links Konto/Ordner-Baum, Mitte Nachrichtenliste, rechts // Lesebereich. Optional Weiterleiten einzelner Mails per SMTP (09-smtp.go). func viewerHandler(w http.ResponseWriter, r *http.Request) { account := resolveViewerAccount(r.URL.Query().Get("account"), r.URL.Query().Get("source")) folder := r.URL.Query().Get("folder") q := strings.TrimSpace(r.URL.Query().Get("q")) if account != "" && folder != "" { renderMessageList(w, account, folder, q) return } if r.Header.Get("HX-Request") == "true" { renderViewerTree(w, account) return } renderShell(w, r, "", `
Backup-Postfach links waehlen.
`) } func messageHandler(w http.ResponseWriter, r *http.Request) { account := resolveViewerAccount(r.URL.Query().Get("account"), r.URL.Query().Get("source")) folder := r.URL.Query().Get("folder") targetAccount := r.URL.Query().Get("target_account") index, err := strconv.Atoi(r.URL.Query().Get("index")) if err != nil { http.Error(w, "bad index", http.StatusBadRequest) return } path, err := mboxPath(account, folder) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } raw, err := ReadMboxMessage(path, index) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { renderReadPaneWithSource(w, account, folder, targetAccount, index, "(unlesbar)", "", "", string(raw)) return } renderReadPaneWithSource(w, account, folder, targetAccount, index, decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("Date")), messageBody(raw), ) } func targetMessageHandler(w http.ResponseWriter, r *http.Request) { account := resolveViewerAccount(r.URL.Query().Get("account"), r.URL.Query().Get("source")) folder := r.URL.Query().Get("folder") targetAccount := r.URL.Query().Get("target_account") index, err := strconv.Atoi(r.URL.Query().Get("index")) if err != nil { http.Error(w, "bad index", http.StatusBadRequest) return } path, err := mboxPath(account, folder) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } raw, err := ReadMboxMessage(path, index) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, "")) } func targetSelectHandler(w http.ResponseWriter, r *http.Request) { targetAccount := r.URL.Query().Get("target_account") w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, renderInitialTargetSelectPane(targetAccount)) } func sourceSelectHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, renderInitialSourceSelectPane(`
Nachricht waehlen.
`)) } func forwardHandler(w http.ResponseWriter, r *http.Request) { // TODO Codex: GET = Weiterleiten-Formular (An/Betreff/Text), POST = // ForwardMessage() aufrufen. NUR hier kommt SMTP zum Einsatz -- der Umzug // selbst nutzt IMAP APPEND. } func renderViewerTree(w http.ResponseWriter, selected string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") accounts := viewerAccounts() if len(accounts) == 0 { fmt.Fprint(w, `
Archiv-Mailbox
Noch keine mbox-Backups.
`) return } selected = resolveViewerAccount(selected, selected) if selected == "" { selected = accounts[0].Name } else { found := false for _, account := range accounts { if account.Name == selected { found = true break } } if !found { selected = accounts[0].Name } } fmt.Fprint(w, `
Archiv-Mailbox
`) mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, selected, "*.mbox")) if len(mboxes) == 0 { fmt.Fprint(w, `
Keine Ordner in dieser Archiv-Mailbox.
`) return } source := sourceDisplayLabel(selected) for _, folder := range archiveFolderInfos(mboxes) { fmt.Fprintf(w, `%s%d`, urlEsc(source), urlEsc(folder.Name), html.EscapeString(folder.Label), folder.Count) } } func renderMessageList(w http.ResponseWriter, account, folder, q string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") path, err := mboxPath(account, folder) if err != nil { fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) return } entries, err := ReadMboxList(path) if err != nil { fmt.Fprintf(w, `
Nachrichten
%s
`, html.EscapeString(err.Error())) return } filtered := entries if q != "" { filtered = filterEntries(path, entries, q) } fmt.Fprintf(w, `
%s / %s (%d)
`, html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), len(filtered)) fmt.Fprintf(w, ``, html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), html.EscapeString(q)) for _, entry := range filtered { subject := entry.Subject if subject == "" { subject = "(ohne Betreff)" } from := entry.From if from == "" { from = "(ohne Absender)" } fmt.Fprintf(w, `%s%s%s`, html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), entry.Index, urlEsc(sourceDisplayLabel(account)), urlEsc(folder), entry.Index, html.EscapeString(from), html.EscapeString(subject), html.EscapeString(entry.Date)) } if len(entries) == 0 { fmt.Fprint(w, `
Keine Nachrichten in dieser mbox.
`) } else if len(filtered) == 0 { fmt.Fprint(w, `
Keine Treffer.
`) } } type archiveFolderInfo struct { Name string Label string Count int } func archiveFolderInfos(paths []string) []archiveFolderInfo { out := make([]archiveFolderInfo, 0, len(paths)) for _, path := range paths { name := strings.TrimSuffix(filepath.Base(path), ".mbox") entries, err := ReadMboxList(path) count := 0 if err == nil { count = len(entries) } out = append(out, archiveFolderInfo{ Name: name, Label: archiveFolderLabel(name), Count: count, }) } sort.SliceStable(out, func(i, j int) bool { return archiveFolderSortKey(out[i].Name) < archiveFolderSortKey(out[j].Name) }) return out } func archiveFolderLabel(name string) string { name = strings.TrimSpace(name) switch strings.ToLower(name) { case "", "inbox": return "Posteingang" case "drafts", "entw&apw-rfe", "entwürfe", "entwuerfe": return "Entwürfe" case "sent", "gesendet", "gesendete", "gesendete objekte", "gesendete elemente": return "Gesendet" case "trash", "papierkorb", "gelöscht", "geloescht", "gelöschte objekte", "geloeschte objekte": return "Papierkorb" case "archive", "archiv": return "Archiv" case "spam", "junk": return "Spam" } parts := strings.FieldsFunc(name, func(r rune) bool { return r == '/' || r == '\\' }) if len(parts) == 0 { parts = strings.Split(name, "_") } label := strings.TrimSpace(parts[len(parts)-1]) if label == "" { return name } return label } func archiveFolderSortKey(name string) string { lc := strings.ToLower(strings.TrimSpace(name)) switch lc { case "inbox": return "00:" + lc case "drafts", "entw&apw-rfe", "entwürfe", "entwuerfe": return "01:" + lc case "sent", "gesendet", "gesendete", "gesendete objekte", "gesendete elemente": return "02:" + lc case "spam", "junk": return "03:" + lc case "trash", "papierkorb", "gelöscht", "geloescht", "gelöschte objekte", "geloeschte objekte": return "04:" + lc case "archive", "archiv": return "05:" + lc default: return "10:" + lc } } func manualCopyHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } account := resolveViewerAccount(r.FormValue("account"), r.FormValue("source")) folder := r.FormValue("folder") targetAccount := r.FormValue("target_account") index, err := strconv.Atoi(r.FormValue("index")) if err != nil { http.Error(w, "bad index", http.StatusBadRequest) return } path, err := mboxPath(account, folder) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } raw, err := ReadMboxMessage(path, index) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } note, err := appendManualTargetCopy(account, folder, targetAccount, raw) if err != nil { note = "Fehler beim manuellen Umzug: " + err.Error() } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, renderTargetPane(account, folder, targetAccount, index, raw, note)) } func renderReadPaneWithSource(w http.ResponseWriter, account, folder, targetAccount string, index int, subject, from, date, body string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") renderReadPane(w, "Quelle", account, folder, targetAccount, index, subject, from, date, body) } func renderReadPane(w io.Writer, title, account, folder, targetAccount string, index int, subject, from, date, body string) { if subject == "" { subject = "(ohne Betreff)" } fmt.Fprint(w, renderSourcePaneHead(account, folder, targetAccount, index)) fmt.Fprintf(w, `

%s

Von: %s
Datum: %s
%s
`, html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body)) } func renderTargetPane(account, folder, targetAccount string, index int, raw []byte, note string) string { var b strings.Builder b.WriteString(renderTargetAccountSelect(account, folder, targetAccount, index)) if note != "" { class := "ok" if strings.HasPrefix(note, "Fehler") { class = "bad" } fmt.Fprintf(&b, `
%s
`, class, html.EscapeString(note)) } if match, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok { renderTargetMatch(&b, match) return b.String() } else if err != nil { fmt.Fprintf(&b, `
Zielpostfach-Pruefung fehlgeschlagen: %s
`, html.EscapeString(err.Error())) } match, ok := findForwardedCopy(account, targetAccount, raw) if !ok { id := messageIDHeader(raw) if id == "" { id = "(ohne Message-ID)" } fmt.Fprintf(&b, `
Keine Zielkopie gefunden.

Message-ID: %s
`, html.EscapeString(id)) return b.String() } renderTargetMatch(&b, match) return b.String() } func renderTargetMatch(b *strings.Builder, match forwardedMatch) { msg, err := mail.ReadMessage(bytes.NewReader(match.Raw)) if err != nil { renderReadContent(b, "(unlesbar)", match.Location(), "", string(match.Raw)) return } renderReadContent(b, decodeHeader(msg.Header.Get("Subject")), decodeHeader(msg.Header.Get("From")), decodeHeader(msg.Header.Get("Date"))+" | "+match.Location(), messageBody(match.Raw), ) } func renderInitialTargetPane() string { return renderInitialSourceSelectPane(`
Nachricht waehlen.
`) } func renderInitialReadPane(body string) string { if strings.TrimSpace(body) == "" { body = `
Nachricht waehlen.
` } return renderInitialSourceSelectPane(body) } func renderInitialSourceSelectPane(body string) string { var b strings.Builder b.WriteString(`
Quelle
`) b.WriteString(body) b.WriteString(`
`) return b.String() } func renderInitialTargetSelectPane(selected string) string { var b strings.Builder b.WriteString(`
Ziel
Nachricht waehlen.
`) return b.String() } func renderReadContent(w io.Writer, subject, from, date, body string) { if subject == "" { subject = "(ohne Betreff)" } fmt.Fprintf(w, `

%s

Von: %s
Datum: %s
%s
`, html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body)) } func mboxPath(account, folder string) (string, error) { if account == "" || folder == "" || strings.Contains(account, "..") || strings.Contains(folder, "..") { return "", fmt.Errorf("ungueltiges Postfach") } root, err := filepath.Abs(Cfg.MboxRoot) if err != nil { return "", err } path, err := filepath.Abs(filepath.Join(root, account, safeMboxName(folder)+".mbox")) if err != nil { return "", err } rel, err := filepath.Rel(root, path) if err != nil || strings.HasPrefix(rel, "..") { return "", fmt.Errorf("ungueltiger mbox-Pfad") } return path, nil } func urlEsc(s string) string { r := strings.NewReplacer("%", "%25", " ", "%20", "&", "%26", "?", "%3F", "#", "%23", "+", "%2B", "/", "%2F") return r.Replace(s) } func filterEntries(path string, entries []MboxEntry, q string) []MboxEntry { needle := strings.ToLower(strings.TrimSpace(q)) if needle == "" { return entries } var out []MboxEntry for _, entry := range entries { hay := strings.ToLower(entry.From + " " + entry.Subject + " " + entry.Date) if strings.Contains(hay, needle) { out = append(out, entry) continue } raw, err := ReadMboxMessage(path, entry.Index) if err == nil && strings.Contains(strings.ToLower(messageBody(raw)), needle) { out = append(out, entry) } } return out } type forwardedMatch struct { Account string Folder string Index int Raw []byte } func (m forwardedMatch) Location() string { return fmt.Sprintf("%s / %s #%d", m.Account, m.Folder, m.Index) } func findTargetIMAPCopy(account, targetAccount string, raw []byte) (forwardedMatch, bool, error) { id := messageIDHeader(raw) if id == "" { return forwardedMatch{}, false, nil } targetUser := strings.TrimSpace(targetAccount) if targetUser == "" { source, ok := accountByMboxDir(account) if !ok { return forwardedMatch{}, false, nil } targetUser = strings.TrimSpace(source.DstUser) } for _, a := range accountsForTargetUser(targetUser) { dst, err := OpenIMAPTarget(a) if err != nil { return forwardedMatch{}, false, err } match, ok, scanErr := scanTargetForMessage(dst, targetUser, id) closeErr := dst.Close() if scanErr != nil { return forwardedMatch{}, false, scanErr } if closeErr != nil { return forwardedMatch{}, false, closeErr } if ok { return match, true, nil } } return forwardedMatch{}, false, nil } func scanTargetForMessage(dst TargetMailbox, targetUser, messageID string) (forwardedMatch, bool, error) { folders, err := dst.Folders() if err != nil { return forwardedMatch{}, false, err } for _, folder := range folders { msgs, err := dst.Fetch(folder.Name) if err != nil { continue } for i, msg := range msgs { if msg.MessageID == messageID || messageIDHeader(msg.Body) == messageID { return forwardedMatch{ Account: "Ziel: " + targetUser, Folder: folder.Name, Index: i, Raw: msg.Body, }, true, nil } } } return forwardedMatch{}, false, nil } func findForwardedCopy(account, targetAccount string, raw []byte) (forwardedMatch, bool) { id := messageIDHeader(raw) if id == "" { return forwardedMatch{}, false } allowedTargets := targetMboxDirs(targetAccount) entries, err := os.ReadDir(Cfg.MboxRoot) if err != nil { return forwardedMatch{}, false } for _, entry := range entries { if !entry.IsDir() || entry.Name() == account { continue } if targetAccount != "" && !allowedTargets[entry.Name()] { continue } mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox")) for _, path := range mboxes { msgs, err := ReadMboxList(path) if err != nil { continue } for _, msg := range msgs { candidate, err := ReadMboxMessage(path, msg.Index) if err != nil { continue } if messageIDHeader(candidate) == id { return forwardedMatch{ Account: entry.Name(), Folder: strings.TrimSuffix(filepath.Base(path), ".mbox"), Index: msg.Index, Raw: candidate, }, true } } } } return forwardedMatch{}, false } func messageIDHeader(raw []byte) string { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return "" } return strings.TrimSpace(msg.Header.Get("Message-ID")) } func renderTargetAccountSelect(account, folder, selected string, index int) string { options := targetAccountOptions() var b strings.Builder b.WriteString(`
Ziel
= 0 { fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`) } fmt.Fprintf(&b, `>
`, urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(selected)) if account != "" && folder != "" && index >= 0 { fmt.Fprintf(&b, `
Mail hier ablegen fuer manuellen Umzug
`, html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), index) } return b.String() } func renderSourcePaneHead(account, folder, targetAccount string, index int) string { var b strings.Builder fmt.Fprintf(&b, `
Quelle
`, urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(targetAccount)) return b.String() } func appendManualTargetCopy(account, folder, targetAccount string, raw []byte) (string, error) { id := messageIDHeader(raw) if id == "" { return "", fmt.Errorf("mail ohne Message-ID kann nicht sauber dedupliziert werden") } if _, ok, err := findTargetIMAPCopy(account, targetAccount, raw); ok { return "Schon vorhanden: Zielkopie wurde per Message-ID gefunden.", nil } else if err != nil { return "", err } a, ok := accountByMboxDir(account) if !ok { return "", fmt.Errorf("kein Konto zur mbox %s gefunden", account) } if strings.TrimSpace(targetAccount) != "" && !strings.EqualFold(strings.TrimSpace(a.DstUser), strings.TrimSpace(targetAccount)) { candidates := accountsForTargetUser(targetAccount) if len(candidates) == 0 { return "", fmt.Errorf("kein Zielkonto %s gefunden", targetAccount) } a = candidates[0] } dst, err := OpenIMAPTarget(a) if err != nil { return "", err } defer dst.Close() targets, err := dst.Folders() if err != nil { return "", err } dstFolder := MapSourceToTarget(folder, nil, "/", dst.Delim(), targets, "") if dstFolder == "" { dstFolder = folder } if err := dst.EnsureFolder(dstFolder); err != nil { return "", err } msg := RawMessage{MessageID: id, Body: raw, InternalDate: messageDate(raw)} if err := dst.Append(dstFolder, msg); err != nil { return "", err } if a.ID != 0 { _ = MarkCopied(a.ID, folder, id) } return "Manuell kopiert nach " + strings.TrimSpace(a.DstUser) + " / " + dstFolder + ".", nil } func messageDate(raw []byte) time.Time { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { return time.Time{} } if t, err := mail.ParseDate(msg.Header.Get("Date")); err == nil { return t } return time.Time{} } type viewerAccount struct { Name string SourceLabel string DisplayLabel string } type targetAccountOption struct { Value string Label string } func viewerAccounts() []viewerAccount { labels := accountLabels() if DB != nil { archives, err := ListArchiveMailboxes() if err == nil { out := make([]viewerAccount, 0, len(archives)) for _, archive := range archives { a := labels[archive] if a.Name == "" { a = viewerAccount{Name: archive, SourceLabel: archive, DisplayLabel: "Archiv-mbox: " + archive} } out = append(out, a) } return out } } entries, err := os.ReadDir(Cfg.MboxRoot) if err != nil { return nil } var out []viewerAccount for _, entry := range entries { if !entry.IsDir() { continue } mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, entry.Name(), "*.mbox")) if len(mboxes) == 0 { continue } a := labels[entry.Name()] if a.Name == "" { a = viewerAccount{Name: entry.Name(), SourceLabel: entry.Name(), DisplayLabel: "Archiv-mbox: " + entry.Name()} } out = append(out, a) } return out } func sourceDisplayLabel(mboxDir string) string { if a := accountLabels()[mboxDir]; a.DisplayLabel != "" { return a.DisplayLabel } return "Archiv-mbox: " + mboxDir } func sourceAccountLabel(mboxDir string) string { if a := accountLabels()[mboxDir]; a.SourceLabel != "" { return a.SourceLabel } return mboxDir } func resolveViewerAccount(account, source string) string { if strings.TrimSpace(account) != "" { return account } source = strings.TrimSpace(source) if source == "" { return "" } for dir, label := range accountLabels() { if strings.EqualFold(label.SourceLabel, source) || strings.EqualFold(label.DisplayLabel, source) { return dir } } return source } func accountLabels() map[string]viewerAccount { out := map[string]viewerAccount{} if DB == nil { return out } accounts, err := ListAccounts() if err != nil { return out } n := 0 for _, a := range accounts { dir := accountMboxDir(a) if dir == "" { continue } source := a.SrcUser if source == "" { source = a.Name } n++ out[dir] = viewerAccount{Name: dir, SourceLabel: source, DisplayLabel: "Quell-mbox: " + source} } return out } func accountByMboxDir(mboxDir string) (Account, bool) { if DB == nil { return Account{}, false } accounts, err := ListAccounts() if err != nil { return Account{}, false } for _, a := range accounts { if dir := accountMboxDir(a); dir != "" && dir == mboxDir { return a, true } } return Account{}, false } func accountsForTargetUser(targetUser string) []Account { if DB == nil { return nil } accounts, err := ListAccounts() if err != nil { return nil } var out []Account for _, a := range accounts { if strings.EqualFold(strings.TrimSpace(a.DstUser), targetUser) { out = append(out, a) } } return out } func targetAccountOptions() []targetAccountOption { if DB == nil { return nil } accounts, err := ListAccounts() if err != nil { return nil } seen := map[string]bool{} var out []targetAccountOption for _, a := range accounts { target := strings.TrimSpace(a.DstUser) if target == "" || seen[target] { continue } seen[target] = true out = append(out, targetAccountOption{Value: target, Label: target}) } return out } func sourceAccountOptions() []targetAccountOption { accounts := viewerAccounts() out := make([]targetAccountOption, 0, len(accounts)) for _, a := range accounts { value := a.DisplayLabel if value == "" { value = a.Name } label := a.SourceLabel if label == "" { label = value } out = append(out, targetAccountOption{Value: value, Label: label}) } return out } func targetMboxDirs(targetUser string) map[string]bool { out := map[string]bool{} if targetUser == "" || DB == nil { return out } accounts, err := ListAccounts() if err != nil { return out } for _, a := range accounts { if strings.EqualFold(strings.TrimSpace(a.DstUser), targetUser) { if dir := accountMboxDir(a); dir != "" { out[dir] = true } } } return out } func accountMboxDir(a Account) string { return strings.TrimSpace(a.MboxDir) }