package backend
import (
"bytes"
"fmt"
"html"
"io"
"net/http"
"net/mail"
"os"
"path/filepath"
"strconv"
"strings"
)
// 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)
return
}
renderShell(w, "", `
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 {
renderReadPanePair(w, account, folder, targetAccount, index, raw, "(unlesbar)", "", "", string(raw))
return
}
renderReadPanePair(w, account, folder, targetAccount, index, raw,
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 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) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `Originale
`)
entries, err := os.ReadDir(Cfg.MboxRoot)
if err != nil {
fmt.Fprintf(w, `%s
`, html.EscapeString(err.Error()))
return
}
found := false
for _, entry := range entries {
if !entry.IsDir() {
continue
}
account := entry.Name()
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, account, "*.mbox"))
if len(mboxes) == 0 {
continue
}
found = true
source := sourceDisplayLabel(account)
fmt.Fprintf(w, `%s
`, html.EscapeString(sourceDisplayLabel(account)))
for _, path := range mboxes {
folder := strings.TrimSuffix(filepath.Base(path), ".mbox")
fmt.Fprintf(w, `%s`,
urlEsc(source), urlEsc(folder), html.EscapeString(folder))
}
}
if !found {
fmt.Fprint(w, `Noch keine mbox-Backups.
`)
}
}
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`,
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.
`)
}
}
func renderReadPanePair(w http.ResponseWriter, account, folder, targetAccount string, index int, raw []byte, subject, from, date, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
renderReadPane(w, "Original", subject, from, date, body)
targetHTML := renderTargetPane(account, folder, targetAccount, index, raw)
fmt.Fprintf(w, `%s
`, targetHTML)
}
func renderReadPane(w io.Writer, title, subject, from, date, body string) {
if subject == "" {
subject = "(ohne Betreff)"
}
fmt.Fprintf(w, `%s
`,
html.EscapeString(title), html.EscapeString(subject), html.EscapeString(from), html.EscapeString(date), html.EscapeString(body))
}
func renderTargetPane(account, folder, targetAccount string, index int, raw []byte) string {
var b strings.Builder
b.WriteString(renderTargetAccountSelect(account, folder, targetAccount, index))
match, ok := findForwardedCopy(account, targetAccount, raw)
if !ok {
id := messageIDHeader(raw)
if id == "" {
id = "(ohne Message-ID)"
}
fmt.Fprintf(&b, `Keine lokale Zielkopie gefunden.
Message-ID: %s
`, html.EscapeString(id))
return b.String()
}
msg, err := mail.ReadMessage(bytes.NewReader(match.Raw))
if err != nil {
renderReadContent(&b, "(unlesbar)", match.Location(), "", string(match.Raw))
return b.String()
}
renderReadContent(&b,
decodeHeader(msg.Header.Get("Subject")),
decodeHeader(msg.Header.Get("From")),
decodeHeader(msg.Header.Get("Date"))+" | "+match.Location(),
messageBody(match.Raw),
)
return b.String()
}
func renderInitialTargetPane() string {
var b strings.Builder
b.WriteString(renderTargetAccountSelect("", "", "", -1))
b.WriteString(``)
return b.String()
}
func renderReadContent(w io.Writer, subject, from, date, body string) {
if subject == "" {
subject = "(ohne Betreff)"
}
fmt.Fprintf(w, ``,
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 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
fmt.Fprintf(&b, `Zielkopie
`)
return b.String()
}
type viewerAccount struct {
Name string
SourceLabel string
DisplayLabel string
}
type targetAccountOption struct {
Value string
Label string
}
func viewerAccounts() []viewerAccount {
labels := accountLabels()
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: "Original"}
}
out = append(out, a)
}
return out
}
func sourceDisplayLabel(mboxDir string) string {
if a := accountLabels()[mboxDir]; a.DisplayLabel != "" {
return a.DisplayLabel
}
return "Original"
}
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)
source := a.SrcUser
if source == "" {
source = a.Name
}
n++
out[dir] = viewerAccount{Name: dir, SourceLabel: source, DisplayLabel: fmt.Sprintf("Original %d", n)}
}
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 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) {
out[accountMboxDir(a)] = true
}
}
return out
}
func accountMboxDir(a Account) string {
if strings.TrimSpace(a.MboxDir) != "" {
return a.MboxDir
}
return a.Name
}