feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen
- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg) - Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist - Feature-Status auf In Review gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package mailparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Attachment represents a MIME attachment in a parsed email.
|
||||
type Attachment struct {
|
||||
Filename string
|
||||
ContentType string
|
||||
Data []byte
|
||||
Size int
|
||||
}
|
||||
|
||||
// ParsedMail holds the structured content of a parsed email message.
|
||||
type ParsedMail struct {
|
||||
From string
|
||||
To []string
|
||||
CC []string
|
||||
Subject string
|
||||
MessageID string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
Date time.Time
|
||||
Attachments []Attachment
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
// Parse parses a raw RFC 2822 / MIME email and returns a ParsedMail.
|
||||
func Parse(raw []byte) (*ParsedMail, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mailparser: read message: %w", err)
|
||||
}
|
||||
|
||||
pm := &ParsedMail{Raw: raw}
|
||||
|
||||
// From
|
||||
if from := msg.Header.Get("From"); from != "" {
|
||||
addrs, err := mail.ParseAddressList(from)
|
||||
if err == nil && len(addrs) > 0 {
|
||||
pm.From = addrs[0].Address
|
||||
} else {
|
||||
pm.From = from
|
||||
}
|
||||
}
|
||||
|
||||
// To
|
||||
if to := msg.Header.Get("To"); to != "" {
|
||||
addrs, err := mail.ParseAddressList(to)
|
||||
if err == nil {
|
||||
for _, a := range addrs {
|
||||
pm.To = append(pm.To, a.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CC
|
||||
if cc := msg.Header.Get("Cc"); cc != "" {
|
||||
addrs, err := mail.ParseAddressList(cc)
|
||||
if err == nil {
|
||||
for _, a := range addrs {
|
||||
pm.CC = append(pm.CC, a.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subject - decode MIME encoded-words
|
||||
pm.Subject = decodeMIMEHeader(msg.Header.Get("Subject"))
|
||||
|
||||
// Message-ID - strip angle brackets
|
||||
msgID := msg.Header.Get("Message-Id")
|
||||
pm.MessageID = strings.Trim(msgID, "<>")
|
||||
|
||||
// Date
|
||||
if d, err := msg.Header.Date(); err == nil {
|
||||
pm.Date = d
|
||||
} else {
|
||||
pm.Date = time.Now()
|
||||
}
|
||||
|
||||
// Parse body / MIME parts
|
||||
contentType := msg.Header.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
// No content-type or parse error: treat as plain text
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
pm.TextBody = string(body)
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if err := parseMultipart(pm, msg.Body, boundary); err != nil {
|
||||
return nil, fmt.Errorf("mailparser: multipart: %w", err)
|
||||
}
|
||||
} else {
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
decoded := decodeBody(body, msg.Header.Get("Content-Transfer-Encoding"))
|
||||
if strings.Contains(mediaType, "html") {
|
||||
pm.HTMLBody = string(decoded)
|
||||
} else {
|
||||
pm.TextBody = string(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
// parseMultipart walks MIME parts and fills text, html, and attachments.
|
||||
func parseMultipart(pm *ParsedMail, body io.Reader, boundary string) error {
|
||||
mr := multipart.NewReader(body, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ct := part.Header.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
mediaType = "application/octet-stream"
|
||||
params = map[string]string{}
|
||||
}
|
||||
|
||||
data, _ := io.ReadAll(part)
|
||||
cte := part.Header.Get("Content-Transfer-Encoding")
|
||||
decoded := decodeBody(data, cte)
|
||||
|
||||
// Check disposition for attachment
|
||||
disp := part.Header.Get("Content-Disposition")
|
||||
dispType, dispParams, _ := mime.ParseMediaType(disp)
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
filename = params["name"]
|
||||
}
|
||||
filename = decodeMIMEHeader(filename)
|
||||
|
||||
if strings.HasPrefix(dispType, "attachment") || filename != "" {
|
||||
pm.Attachments = append(pm.Attachments, Attachment{
|
||||
Filename: filename,
|
||||
ContentType: mediaType,
|
||||
Data: decoded,
|
||||
Size: len(decoded),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Nested multipart
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
if err := parseMultipart(pm, bytes.NewReader(decoded), params["boundary"]); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(mediaType, "text/plain"):
|
||||
pm.TextBody += string(decoded)
|
||||
case strings.Contains(mediaType, "text/html"):
|
||||
pm.HTMLBody += string(decoded)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeBody decodes Content-Transfer-Encoding if needed.
|
||||
func decodeBody(data []byte, cte string) []byte {
|
||||
switch strings.ToLower(strings.TrimSpace(cte)) {
|
||||
case "quoted-printable":
|
||||
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data)))
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
case "base64":
|
||||
clean := bytes.ReplaceAll(data, []byte("\r\n"), []byte{})
|
||||
clean = bytes.ReplaceAll(clean, []byte("\n"), []byte{})
|
||||
clean = bytes.ReplaceAll(clean, []byte("\r"), []byte{})
|
||||
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(clean)))
|
||||
n, err := base64.StdEncoding.Decode(decoded, clean)
|
||||
if err == nil {
|
||||
return decoded[:n]
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// decodeMIMEHeader decodes RFC 2047 encoded-word headers.
|
||||
func decodeMIMEHeader(s string) string {
|
||||
dec := new(mime.WordDecoder)
|
||||
decoded, err := dec.DecodeHeader(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package mailparser_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/archivmail/pkg/mailparser"
|
||||
)
|
||||
|
||||
func readFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("readFixture %s: %v", name, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestParseSimple(t *testing.T) {
|
||||
raw := readFixture(t, "simple.eml")
|
||||
p, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
if p.From != "alice@example.com" {
|
||||
t.Errorf("From = %q, want alice@example.com", p.From)
|
||||
}
|
||||
if len(p.To) != 2 {
|
||||
t.Errorf("To: got %d recipients, want 2", len(p.To))
|
||||
}
|
||||
if len(p.CC) != 1 {
|
||||
t.Errorf("CC: got %d, want 1", len(p.CC))
|
||||
}
|
||||
if p.Subject != "Test Invoice Q1-2026" {
|
||||
t.Errorf("Subject = %q", p.Subject)
|
||||
}
|
||||
if p.MessageID != "test-001@example.com" {
|
||||
t.Errorf("MessageID = %q", p.MessageID)
|
||||
}
|
||||
if !strings.Contains(p.TextBody, "invoice") {
|
||||
t.Errorf("TextBody missing 'invoice': %q", p.TextBody)
|
||||
}
|
||||
if p.Date.IsZero() {
|
||||
t.Error("Date is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipartWithAttachment(t *testing.T) {
|
||||
raw := readFixture(t, "multipart.eml")
|
||||
p, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
if p.From != "sender@corp.de" {
|
||||
t.Errorf("From = %q", p.From)
|
||||
}
|
||||
if len(p.Attachments) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(p.Attachments))
|
||||
}
|
||||
att := p.Attachments[0]
|
||||
if att.Filename != "angebot.pdf" {
|
||||
t.Errorf("Attachment filename = %q, want angebot.pdf", att.Filename)
|
||||
}
|
||||
if !strings.Contains(att.ContentType, "pdf") {
|
||||
t.Errorf("ContentType = %q, want pdf", att.ContentType)
|
||||
}
|
||||
if att.Size == 0 {
|
||||
t.Error("attachment size is 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRawInline(t *testing.T) {
|
||||
raw := []byte("From: test@example.com\r\nTo: dest@example.com\r\nSubject: Hello\r\n\r\nBody text here")
|
||||
p, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if p.From != "test@example.com" {
|
||||
t.Errorf("From = %q", p.From)
|
||||
}
|
||||
if len(p.Attachments) != 0 {
|
||||
t.Errorf("expected 0 attachments, got %d", len(p.Attachments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingDate(t *testing.T) {
|
||||
raw := []byte("From: test@example.com\r\nSubject: No Date\r\n\r\nNo date header")
|
||||
p, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
// Should fall back to time.Now(), so should not be zero
|
||||
if p.Date.IsZero() {
|
||||
t.Error("Date should fall back to now, not zero")
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
From: sender@corp.de
|
||||
To: empfaenger@example.com
|
||||
Subject: Angebot fuer Dienstleistungen
|
||||
Message-ID: <offer-001@corp.de>
|
||||
Date: Fri, 13 Mar 2026 09:00:00 +0100
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="----=_Part_001_boundary"
|
||||
|
||||
------=_Part_001_boundary
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Bitte finden Sie anbei unser Angebot fuer die gewuenschten Dienstleistungen.
|
||||
|
||||
Mit freundlichen Gruessen,
|
||||
Sender
|
||||
|
||||
------=_Part_001_boundary
|
||||
Content-Type: application/pdf; name="angebot.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="angebot.pdf"
|
||||
|
||||
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0
|
||||
ZURlY29kZT4+CnN0cmVhbQp4nCvkMlAwUDC1NNUzMlAwtrBQKEktLk4tSszMS1cozy/KSVEA
|
||||
AAAA//8DAFBLAwQUAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
------=_Part_001_boundary--
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
From: alice@example.com
|
||||
To: bob@example.com, carol@example.com
|
||||
CC: dave@example.com
|
||||
Subject: Test Invoice Q1-2026
|
||||
Message-ID: <test-001@example.com>
|
||||
Date: Thu, 12 Mar 2026 10:00:00 +0000
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Please find the invoice for Q1-2026 attached.
|
||||
This is an invoice for services rendered in January.
|
||||
Reference in New Issue
Block a user