feat(PROJ-92,PROJ-93): Weiterleitung in Liste/Detail anzeigen, imap-verify Vollständigkeitsprüfung

PROJ-92: forwarded_via in Suchtreffern, Detail und Threads; Badge im Portal.
PROJ-93: archivmail imap-verify gleicht Message-IDs im IMAP-Postfach mit dem
Archiv ab (strikt lesend, nur Zahlen und Ordner:UID).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-10-29 14:20:34 +01:00
1 parent b15bab355b
commit 0501118bcd
21 files changed
+1258 -37

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
package mailparser
import (
"net/mail"
"strings"
)
// Display only: Return-Path is sender-controlled and must never drive access decisions.
func ForwardedVia(pm *ParsedMail) string {
if pm == nil {
return ""
}
rp := normalizeAddress(pm.ReturnPath)
if rp == "" || rp == normalizeAddress(pm.From) {
return ""
}
for _, list := range [][]string{pm.To, pm.CC, pm.BCC, pm.DeliveryAddresses} {
for _, a := range list {
if normalizeAddress(a) == rp {
return rp
}
}
}
return ""
}
func normalizeAddress(s string) string {
s = strings.TrimSpace(s)
if s == "" || s == "<>" {
return ""
}
if a, err := mail.ParseAddress(s); err == nil {
return strings.ToLower(strings.TrimSpace(a.Address))
}
return strings.ToLower(strings.Trim(s, "<>"))
}
+135
View File
@@ -0,0 +1,135 @@
package mailparser_test
import (
"testing"
"archivmail/pkg/mailparser"
)
func parseRaw(t *testing.T, headers string) *mailparser.ParsedMail {
t.Helper()
pm, err := mailparser.Parse([]byte(headers + "Subject: test\r\n\r\nbody\r\n"))
if err != nil {
t.Fatalf("Parse: %v", err)
}
return pm
}
func TestForwardedVia(t *testing.T) {
tests := []struct {
name string
headers string
want string
}{
{
name: "forward via cc",
headers: "Return-Path: <fwd@example.com>\r\n" +
"From: Carol <carol@example.org>\r\n" +
"To: dave@example.org\r\n" +
"Cc: Fwd <fwd@example.com>\r\n",
want: "fwd@example.com",
},
{
name: "forward via envelope-to",
headers: "Return-Path: <fwd@example.com>\r\n" +
"Envelope-To: fwd@example.com\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org\r\n",
want: "fwd@example.com",
},
{
name: "forward via delivered-to",
headers: "Return-Path: <fwd@example.com>\r\n" +
"Delivered-To: fwd@example.com\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org\r\n",
want: "fwd@example.com",
},
{
name: "forward via bcc",
headers: "Return-Path: <fwd@example.com>\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org\r\n" +
"Bcc: fwd@example.com\r\n",
want: "fwd@example.com",
},
{
name: "return-path equals from",
headers: "Return-Path: <carol@example.org>\r\n" +
"From: Carol <carol@example.org>\r\n" +
"To: dave@example.org, carol@example.org\r\n",
want: "",
},
{
name: "return-path equals from case-insensitive",
headers: "Return-Path: <Carol@Example.ORG>\r\n" +
"From: CAROL@example.org\r\n" +
"To: carol@example.org\r\n",
want: "",
},
{
name: "newsletter bounce address not a recipient",
headers: "Return-Path: <bounce-123@mailer.example.net>\r\n" +
"From: news@example.net\r\n" +
"To: dave@example.org\r\n",
want: "",
},
{
name: "no return-path",
headers: "From: carol@example.org\r\n" +
"To: dave@example.org\r\n",
want: "",
},
{
name: "null return-path",
headers: "Return-Path: <>\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org\r\n",
want: "",
},
{
name: "mixed case recipient",
headers: "Return-Path: <Fwd@Example.COM>\r\n" +
"From: carol@example.org\r\n" +
"To: FWD@example.com\r\n",
want: "fwd@example.com",
},
{
name: "multiple recipients",
headers: "Return-Path: <fwd@example.com>\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org, Erin <erin@example.org>, fwd@example.com\r\n" +
"Cc: frank@example.org\r\n",
want: "fwd@example.com",
},
{
name: "multiple recipients without match",
headers: "Return-Path: <fwd@example.com>\r\n" +
"From: carol@example.org\r\n" +
"To: dave@example.org, erin@example.org\r\n" +
"Cc: frank@example.org\r\n",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mailparser.ForwardedVia(parseRaw(t, tt.headers)); got != tt.want {
t.Errorf("ForwardedVia = %q, want %q", got, tt.want)
}
})
}
}
func TestForwardedViaDirectFields(t *testing.T) {
if got := mailparser.ForwardedVia(nil); got != "" {
t.Errorf("nil: got %q", got)
}
pm := &mailparser.ParsedMail{
From: "carol@example.org",
ReturnPath: "fwd@example.com",
DeliveryAddresses: []string{"fwd@example.com"},
}
if got := mailparser.ForwardedVia(pm); got != "fwd@example.com" {
t.Errorf("delivery address: got %q", got)
}
}