871 lines
28 KiB
Go
871 lines
28 KiB
Go
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, "", `<div class="ef-empty">Backup-Postfach links waehlen.</div>`)
|
|
}
|
|
|
|
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(`<div class="ef-empty">Nachricht waehlen.</div>`))
|
|
}
|
|
|
|
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, `<div class="pane-head archive-head"><span>Archiv-Mailbox</span></div><div class="ef-empty">Noch keine mbox-Backups.</div>`)
|
|
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, `<div class="pane-head archive-head"><span>Archiv-Mailbox</span><form class="archive-select-form" hx-get="/view" hx-target="#tree" hx-swap="innerHTML" hx-trigger="change from:#archive-mailbox-select"><select id="archive-mailbox-select" class="archive-select" name="source">`)
|
|
for _, account := range accounts {
|
|
attr := ""
|
|
if account.Name == selected {
|
|
attr = ` selected`
|
|
}
|
|
fmt.Fprintf(w, `<option value="%s"%s>%s</option>`, html.EscapeString(account.DisplayLabel), attr, html.EscapeString(account.SourceLabel))
|
|
}
|
|
fmt.Fprint(w, `</select></form></div>`)
|
|
|
|
mboxes, _ := filepath.Glob(filepath.Join(Cfg.MboxRoot, selected, "*.mbox"))
|
|
if len(mboxes) == 0 {
|
|
fmt.Fprint(w, `<div class="ef-empty">Keine Ordner in dieser Archiv-Mailbox.</div>`)
|
|
return
|
|
}
|
|
source := sourceDisplayLabel(selected)
|
|
for _, folder := range archiveFolderInfos(mboxes) {
|
|
fmt.Fprintf(w, `<a class="tree-node folder" href="#" hx-get="/view?source=%s&folder=%s" hx-target="#list" hx-swap="innerHTML"><span class="tree-label">%s</span><span class="tree-count">%d</span></a>`,
|
|
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, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
|
|
return
|
|
}
|
|
entries, err := ReadMboxList(path)
|
|
if err != nil {
|
|
fmt.Fprintf(w, `<div class="pane-head">Nachrichten</div><div class="ef-empty">%s</div>`, html.EscapeString(err.Error()))
|
|
return
|
|
}
|
|
filtered := entries
|
|
if q != "" {
|
|
filtered = filterEntries(path, entries, q)
|
|
}
|
|
fmt.Fprintf(w, `<div class="pane-head">%s / %s (%d)</div>`, html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), len(filtered))
|
|
fmt.Fprintf(w, `<form class="searchbar" hx-get="/view" hx-target="#list" hx-swap="innerHTML">
|
|
<input type="hidden" name="source" value="%s">
|
|
<input type="hidden" name="folder" value="%s">
|
|
<input class="input search-input" type="search" name="q" value="%s" placeholder="Suchen">
|
|
<button class="btn secondary search-btn" type="submit">Suchen</button>
|
|
</form>`, 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, `<a class="msg-row" href="#" draggable="true" data-source="%s" data-folder="%s" data-index="%d" hx-get="/view/message?source=%s&folder=%s&index=%d" hx-include="#target-account-select" hx-target="#read" hx-swap="innerHTML"><span class="msg-from">%s</span><span class="msg-subject">%s</span><span class="msg-date">%s</span></a>`,
|
|
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, `<div class="ef-empty">Keine Nachrichten in dieser mbox.</div>`)
|
|
} else if len(filtered) == 0 {
|
|
fmt.Fprint(w, `<div class="ef-empty">Keine Treffer.</div>`)
|
|
}
|
|
}
|
|
|
|
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, `<div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
|
|
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, `<div class="audit-note %s">%s</div>`, 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, `<div class="audit-note bad">Zielpostfach-Pruefung fehlgeschlagen: %s</div>`, html.EscapeString(err.Error()))
|
|
}
|
|
match, ok := findForwardedCopy(account, targetAccount, raw)
|
|
if !ok {
|
|
id := messageIDHeader(raw)
|
|
if id == "" {
|
|
id = "(ohne Message-ID)"
|
|
}
|
|
fmt.Fprintf(&b, `<div class="ef-empty">Keine Zielkopie gefunden.<br><br>Message-ID: %s</div>`, 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(`<div class="ef-empty">Nachricht waehlen.</div>`)
|
|
}
|
|
|
|
func renderInitialReadPane(body string) string {
|
|
if strings.TrimSpace(body) == "" {
|
|
body = `<div class="ef-empty">Nachricht waehlen.</div>`
|
|
}
|
|
return renderInitialSourceSelectPane(body)
|
|
}
|
|
|
|
func renderInitialSourceSelectPane(body string) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<div class="pane-head mode-head"><span class="mode-label">Quelle</span><form class="target-select-form"><select id="source-account-select" name="source" class="target-select">`)
|
|
for _, opt := range sourceAccountOptions() {
|
|
fmt.Fprintf(&b, `<option value="%s">%s</option>`, html.EscapeString(opt.Value), html.EscapeString(opt.Label))
|
|
}
|
|
b.WriteString(`</select></form><button class="btn secondary compact mode-toggle" type="button" title="Zur Zielansicht wechseln" aria-label="Zur Zielansicht wechseln" hx-get="/view/target/select" hx-target="#read" hx-swap="innerHTML"><span class="swap-icon">⇄</span></button></div><div class="read-body">`)
|
|
b.WriteString(body)
|
|
b.WriteString(`</div>`)
|
|
return b.String()
|
|
}
|
|
|
|
func renderInitialTargetSelectPane(selected string) string {
|
|
var b strings.Builder
|
|
b.WriteString(`<div class="pane-head mode-head"><span class="mode-label">Ziel</span><form class="target-select-form"><select id="target-account-select" name="target_account" class="target-select"><option value="">Automatisch</option>`)
|
|
for _, opt := range targetAccountOptions() {
|
|
attr := ""
|
|
if opt.Value == selected {
|
|
attr = ` selected`
|
|
}
|
|
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
|
|
}
|
|
b.WriteString(`</select></form><button class="btn secondary compact mode-toggle" type="button" title="Zur Quellenansicht wechseln" aria-label="Zur Quellenansicht wechseln" hx-get="/view/source/select" hx-target="#read" hx-swap="innerHTML"><span class="swap-icon">⇄</span></button></div><div class="read-body"><div class="ef-empty">Nachricht waehlen.</div></div>`)
|
|
return b.String()
|
|
}
|
|
|
|
func renderReadContent(w io.Writer, subject, from, date, body string) {
|
|
if subject == "" {
|
|
subject = "(ohne Betreff)"
|
|
}
|
|
fmt.Fprintf(w, `<div class="read-body"><div class="read-head"><h1 class="read-subject">%s</h1><div class="read-meta">Von: %s<br>Datum: %s</div></div><pre class="read-pre">%s</pre></div>`,
|
|
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(`<div class="pane-head mode-head"><span class="mode-label">Ziel</span><form class="target-select-form"`)
|
|
if account != "" && folder != "" && index >= 0 {
|
|
fmt.Fprintf(&b, ` hx-get="/view/target" hx-target="#read" hx-swap="innerHTML" hx-trigger="change from:#target-account-select"`)
|
|
}
|
|
fmt.Fprintf(&b, `>
|
|
<input type="hidden" name="source" value="%s">
|
|
<input type="hidden" name="folder" value="%s">
|
|
<input type="hidden" name="index" value="%d">
|
|
<select id="target-account-select" name="target_account" class="target-select">
|
|
<option value="">Automatisch</option>`, html.EscapeString(sourceDisplayLabel(account)), html.EscapeString(folder), index)
|
|
for _, opt := range options {
|
|
attr := ""
|
|
if opt.Value == selected {
|
|
attr = ` selected`
|
|
}
|
|
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
|
|
}
|
|
fmt.Fprintf(&b, `</select></form><button class="btn secondary compact mode-toggle" type="button" title="Zur Quellenansicht wechseln" aria-label="Zur Quellenansicht wechseln" hx-get="/view/message?source=%s&folder=%s&index=%d&target_account=%s" hx-target="#read" hx-swap="innerHTML"><span class="swap-icon">⇄</span></button></div>`,
|
|
urlEsc(sourceDisplayLabel(account)), urlEsc(folder), index, urlEsc(selected))
|
|
if account != "" && folder != "" && index >= 0 {
|
|
fmt.Fprintf(&b, `<div class="drop-zone" data-drop-target="manual-copy" data-source="%s" data-folder="%s" data-index="%d">Mail hier ablegen fuer manuellen Umzug</div>`,
|
|
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, `<div class="pane-head mode-head"><span class="mode-label">Quelle</span><form class="target-select-form" hx-get="/view/message" hx-target="#read" hx-swap="innerHTML" hx-trigger="change from:#source-account-select">
|
|
<input type="hidden" name="folder" value="%s">
|
|
<input type="hidden" name="index" value="%d">
|
|
<input type="hidden" name="target_account" value="%s">
|
|
<select id="source-account-select" name="source" class="target-select">`,
|
|
html.EscapeString(folder), index, html.EscapeString(targetAccount))
|
|
for _, opt := range sourceAccountOptions() {
|
|
attr := ""
|
|
if opt.Value == sourceDisplayLabel(account) {
|
|
attr = ` selected`
|
|
}
|
|
fmt.Fprintf(&b, `<option value="%s"%s>%s</option>`, html.EscapeString(opt.Value), attr, html.EscapeString(opt.Label))
|
|
}
|
|
fmt.Fprintf(&b, `</select></form><button class="btn secondary compact mode-toggle" type="button" title="Zur Zielansicht wechseln" aria-label="Zur Zielansicht wechseln" hx-get="/view/target?source=%s&folder=%s&index=%d&target_account=%s" hx-target="#read" hx-swap="innerHTML"><span class="swap-icon">⇄</span></button></div>`,
|
|
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)
|
|
}
|