Add email-path checks
This commit is contained in:
parent
a97729fea6
commit
5d335c6a6c
7 changed files with 517 additions and 31 deletions
|
|
@ -589,6 +589,14 @@ components:
|
|||
type: string
|
||||
format: date-time
|
||||
description: When this hop occurred
|
||||
ip:
|
||||
type: string
|
||||
description: IP address of the sending server (IPv4 or IPv6)
|
||||
example: "192.0.2.1"
|
||||
reverse:
|
||||
type: string
|
||||
description: Reverse DNS (PTR record) for the IP address
|
||||
example: "mail.example.com"
|
||||
|
||||
DomainAlignment:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ package analyzer
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
|
@ -209,6 +212,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header
|
|||
|
||||
analysis.Headers = &headers
|
||||
|
||||
// Received chain
|
||||
receivedChain := h.parseReceivedChain(email)
|
||||
if len(receivedChain) > 0 {
|
||||
analysis.ReceivedChain = &receivedChain
|
||||
}
|
||||
|
||||
// Domain alignment
|
||||
domainAlignment := h.analyzeDomainAlignment(email)
|
||||
if domainAlignment != nil {
|
||||
|
|
@ -356,3 +365,113 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue
|
|||
|
||||
return issues
|
||||
}
|
||||
|
||||
// parseReceivedChain extracts the chain of Received headers from an email
|
||||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
|
||||
if email == nil || email.Header == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
receivedHeaders := email.Header["Received"]
|
||||
if len(receivedHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var chain []api.ReceivedHop
|
||||
|
||||
for _, receivedValue := range receivedHeaders {
|
||||
hop := h.parseReceivedHeader(receivedValue)
|
||||
if hop != nil {
|
||||
chain = append(chain, *hop)
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
// parseReceivedHeader parses a single Received header value
|
||||
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
|
||||
hop := &api.ReceivedHop{}
|
||||
|
||||
// Normalize whitespace - Received headers can span multiple lines
|
||||
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
||||
|
||||
// Extract "from" field
|
||||
fromRegex := regexp.MustCompile(`(?i)from\s+([^\s(]+)`)
|
||||
if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
from := matches[1]
|
||||
hop.From = &from
|
||||
}
|
||||
|
||||
// Extract "by" field
|
||||
byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`)
|
||||
if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
by := matches[1]
|
||||
hop.By = &by
|
||||
}
|
||||
|
||||
// Extract "with" field (protocol) - must come after "by" and before "id" or "for"
|
||||
// This ensures we get the mail transfer protocol, not other "with" occurrences
|
||||
withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)`)
|
||||
if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
with := matches[1]
|
||||
hop.With = &with
|
||||
}
|
||||
|
||||
// Extract "id" field
|
||||
idRegex := regexp.MustCompile(`(?i)id\s+([^\s;]+)`)
|
||||
if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
id := matches[1]
|
||||
hop.Id = &id
|
||||
}
|
||||
|
||||
// Extract IP address from parentheses after "from"
|
||||
// Pattern: from hostname (anything [IPv4/IPv6])
|
||||
ipRegex := regexp.MustCompile(`\[([^\]]+)\]`)
|
||||
if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
ipStr := matches[1]
|
||||
|
||||
// Handle IPv6: prefix (some MTAs include this)
|
||||
ipStr = strings.TrimPrefix(ipStr, "IPv6:")
|
||||
|
||||
// Check if it's a valid IP (IPv4 or IPv6)
|
||||
if net.ParseIP(ipStr) != nil {
|
||||
hop.Ip = &ipStr
|
||||
|
||||
// Perform reverse DNS lookup
|
||||
if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 {
|
||||
// Remove trailing dot from PTR record
|
||||
reverse := strings.TrimSuffix(reverseNames[0], ".")
|
||||
hop.Reverse = &reverse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract timestamp - usually at the end after semicolon
|
||||
// Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)"
|
||||
timestampRegex := regexp.MustCompile(`;\s*(.+)$`)
|
||||
if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||||
timestampStr := strings.TrimSpace(matches[1])
|
||||
|
||||
// Remove timezone name in parentheses if present
|
||||
timestampStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(timestampStr, "")
|
||||
|
||||
// Try parsing with common email date formats
|
||||
formats := []string{
|
||||
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
|
||||
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||||
"2 Jan 2006 15:04:05 -0700",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if parsedTime, err := time.Parse(format, timestampStr); err == nil {
|
||||
hop.Timestamp = &parsedTime
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hop
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import (
|
|||
"net/mail"
|
||||
"net/textproto"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestCalculateHeaderScore(t *testing.T) {
|
||||
|
|
@ -395,3 +397,341 @@ func createHeaderWithFields(fields map[string]string) mail.Header {
|
|||
}
|
||||
return header
|
||||
}
|
||||
|
||||
func TestParseReceivedChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
receivedHeaders []string
|
||||
expectedHops int
|
||||
validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
|
||||
}{
|
||||
{
|
||||
name: "No Received headers",
|
||||
receivedHeaders: []string{},
|
||||
expectedHops: 0,
|
||||
},
|
||||
{
|
||||
name: "Single Received header",
|
||||
receivedHeaders: []string{
|
||||
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
},
|
||||
expectedHops: 1,
|
||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||
if len(hops) == 0 {
|
||||
t.Fatal("Expected at least one hop")
|
||||
}
|
||||
hop := hops[0]
|
||||
|
||||
if hop.From == nil || *hop.From != "mail.example.com" {
|
||||
t.Errorf("From = %v, want 'mail.example.com'", hop.From)
|
||||
}
|
||||
if hop.By == nil || *hop.By != "mx.receiver.com" {
|
||||
t.Errorf("By = %v, want 'mx.receiver.com'", hop.By)
|
||||
}
|
||||
if hop.With == nil || *hop.With != "ESMTPS" {
|
||||
t.Errorf("With = %v, want 'ESMTPS'", hop.With)
|
||||
}
|
||||
if hop.Id == nil || *hop.Id != "ABC123" {
|
||||
t.Errorf("Id = %v, want 'ABC123'", hop.Id)
|
||||
}
|
||||
if hop.Ip == nil || *hop.Ip != "192.0.2.1" {
|
||||
t.Errorf("Ip = %v, want '192.0.2.1'", hop.Ip)
|
||||
}
|
||||
if hop.Timestamp == nil {
|
||||
t.Error("Timestamp should not be nil")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple Received headers",
|
||||
receivedHeaders: []string{
|
||||
"from mail1.example.com (mail1.example.com [192.0.2.1]) by mx1.receiver.com with ESMTP id 111; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
||||
},
|
||||
expectedHops: 2,
|
||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||
if len(hops) != 2 {
|
||||
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
||||
}
|
||||
|
||||
// Check first hop
|
||||
if hops[0].From == nil || *hops[0].From != "mail1.example.com" {
|
||||
t.Errorf("First hop From = %v, want 'mail1.example.com'", hops[0].From)
|
||||
}
|
||||
|
||||
// Check second hop
|
||||
if hops[1].From == nil || *hops[1].From != "mail2.example.com" {
|
||||
t.Errorf("Second hop From = %v, want 'mail2.example.com'", hops[1].From)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6 address",
|
||||
receivedHeaders: []string{
|
||||
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
||||
},
|
||||
expectedHops: 1,
|
||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||
if len(hops) == 0 {
|
||||
t.Fatal("Expected at least one hop")
|
||||
}
|
||||
hop := hops[0]
|
||||
|
||||
if hop.Ip == nil {
|
||||
t.Fatal("IP should not be nil for IPv6 address")
|
||||
}
|
||||
// Should strip the "IPv6:" prefix
|
||||
if *hop.Ip != "2607:5300:203:2818::1" {
|
||||
t.Errorf("Ip = %v, want '2607:5300:203:2818::1'", *hop.Ip)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiline Received header",
|
||||
receivedHeaders: []string{
|
||||
`from nemunai.re (unknown [IPv6:2607:5300:203:2818::1])
|
||||
(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
|
||||
key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256)
|
||||
(No client certificate requested)
|
||||
(Authenticated sender: nemunaire)
|
||||
by djehouty.pomail.fr (Postfix) with ESMTPSA id 1EFD11611EA
|
||||
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
||||
},
|
||||
expectedHops: 1,
|
||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||
if len(hops) == 0 {
|
||||
t.Fatal("Expected at least one hop")
|
||||
}
|
||||
hop := hops[0]
|
||||
|
||||
if hop.From == nil || *hop.From != "nemunai.re" {
|
||||
t.Errorf("From = %v, want 'nemunai.re'", hop.From)
|
||||
}
|
||||
if hop.By == nil || *hop.By != "djehouty.pomail.fr" {
|
||||
t.Errorf("By = %v, want 'djehouty.pomail.fr'", hop.By)
|
||||
}
|
||||
if hop.With == nil {
|
||||
t.Error("With should not be nil")
|
||||
} else if *hop.With != "ESMTPSA" {
|
||||
t.Errorf("With = %q, want 'ESMTPSA'", *hop.With)
|
||||
}
|
||||
if hop.Id == nil || *hop.Id != "1EFD11611EA" {
|
||||
t.Errorf("Id = %v, want '1EFD11611EA'", hop.Id)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Received header with minimal information",
|
||||
receivedHeaders: []string{
|
||||
"from unknown by localhost",
|
||||
},
|
||||
expectedHops: 1,
|
||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
||||
if len(hops) == 0 {
|
||||
t.Fatal("Expected at least one hop")
|
||||
}
|
||||
hop := hops[0]
|
||||
|
||||
if hop.From == nil || *hop.From != "unknown" {
|
||||
t.Errorf("From = %v, want 'unknown'", hop.From)
|
||||
}
|
||||
if hop.By == nil || *hop.By != "localhost" {
|
||||
t.Errorf("By = %v, want 'localhost'", hop.By)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewHeaderAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
header := make(mail.Header)
|
||||
if len(tt.receivedHeaders) > 0 {
|
||||
header["Received"] = tt.receivedHeaders
|
||||
}
|
||||
|
||||
email := &EmailMessage{
|
||||
Header: header,
|
||||
}
|
||||
|
||||
chain := analyzer.parseReceivedChain(email)
|
||||
|
||||
if len(chain) != tt.expectedHops {
|
||||
t.Errorf("parseReceivedChain() returned %d hops, want %d", len(chain), tt.expectedHops)
|
||||
}
|
||||
|
||||
if tt.validateFirst != nil {
|
||||
tt.validateFirst(t, email, chain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReceivedHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
receivedValue string
|
||||
expectFrom *string
|
||||
expectBy *string
|
||||
expectWith *string
|
||||
expectId *string
|
||||
expectIp *string
|
||||
expectHasTs bool
|
||||
}{
|
||||
{
|
||||
name: "Complete Received header",
|
||||
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
expectFrom: strPtr("mail.example.com"),
|
||||
expectBy: strPtr("mx.receiver.com"),
|
||||
expectWith: strPtr("ESMTPS"),
|
||||
expectId: strPtr("ABC123"),
|
||||
expectIp: strPtr("192.0.2.1"),
|
||||
expectHasTs: true,
|
||||
},
|
||||
{
|
||||
name: "Minimal Received header",
|
||||
receivedValue: "from sender.example.com by receiver.example.com",
|
||||
expectFrom: strPtr("sender.example.com"),
|
||||
expectBy: strPtr("receiver.example.com"),
|
||||
expectWith: nil,
|
||||
expectId: nil,
|
||||
expectIp: nil,
|
||||
expectHasTs: false,
|
||||
},
|
||||
{
|
||||
name: "Received header with ESMTPA",
|
||||
receivedValue: "from [192.0.2.50] by mail.example.com with ESMTPA id XYZ789; Tue, 02 Jan 2024 08:30:00 -0500",
|
||||
expectFrom: strPtr("[192.0.2.50]"),
|
||||
expectBy: strPtr("mail.example.com"),
|
||||
expectWith: strPtr("ESMTPA"),
|
||||
expectId: strPtr("XYZ789"),
|
||||
expectIp: strPtr("192.0.2.50"),
|
||||
expectHasTs: true,
|
||||
},
|
||||
{
|
||||
name: "Received header without IP",
|
||||
receivedValue: "from mail.example.com by mx.receiver.com with SMTP; Wed, 03 Jan 2024 14:20:00 +0000",
|
||||
expectFrom: strPtr("mail.example.com"),
|
||||
expectBy: strPtr("mx.receiver.com"),
|
||||
expectWith: strPtr("SMTP"),
|
||||
expectId: nil,
|
||||
expectIp: nil,
|
||||
expectHasTs: true,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewHeaderAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hop := analyzer.parseReceivedHeader(tt.receivedValue)
|
||||
|
||||
if hop == nil {
|
||||
t.Fatal("parseReceivedHeader returned nil")
|
||||
}
|
||||
|
||||
// Check From
|
||||
if !equalStrPtr(hop.From, tt.expectFrom) {
|
||||
t.Errorf("From = %v, want %v", ptrToStr(hop.From), ptrToStr(tt.expectFrom))
|
||||
}
|
||||
|
||||
// Check By
|
||||
if !equalStrPtr(hop.By, tt.expectBy) {
|
||||
t.Errorf("By = %v, want %v", ptrToStr(hop.By), ptrToStr(tt.expectBy))
|
||||
}
|
||||
|
||||
// Check With
|
||||
if !equalStrPtr(hop.With, tt.expectWith) {
|
||||
t.Errorf("With = %v, want %v", ptrToStr(hop.With), ptrToStr(tt.expectWith))
|
||||
}
|
||||
|
||||
// Check Id
|
||||
if !equalStrPtr(hop.Id, tt.expectId) {
|
||||
t.Errorf("Id = %v, want %v", ptrToStr(hop.Id), ptrToStr(tt.expectId))
|
||||
}
|
||||
|
||||
// Check Ip
|
||||
if !equalStrPtr(hop.Ip, tt.expectIp) {
|
||||
t.Errorf("Ip = %v, want %v", ptrToStr(hop.Ip), ptrToStr(tt.expectIp))
|
||||
}
|
||||
|
||||
// Check Timestamp
|
||||
if tt.expectHasTs {
|
||||
if hop.Timestamp == nil {
|
||||
t.Error("Timestamp should not be nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
||||
analyzer := NewHeaderAnalyzer()
|
||||
|
||||
email := &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
"To": "recipient@example.com",
|
||||
"Subject": "Test",
|
||||
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"Message-ID": "<abc123@example.com>",
|
||||
}),
|
||||
MessageID: "<abc123@example.com>",
|
||||
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
}
|
||||
|
||||
// Add Received headers
|
||||
email.Header["Received"] = []string{
|
||||
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com with ESMTP id ABC123; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
"from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000",
|
||||
}
|
||||
|
||||
analysis := analyzer.GenerateHeaderAnalysis(email)
|
||||
|
||||
if analysis == nil {
|
||||
t.Fatal("GenerateHeaderAnalysis returned nil")
|
||||
}
|
||||
|
||||
if analysis.ReceivedChain == nil {
|
||||
t.Fatal("ReceivedChain should not be nil")
|
||||
}
|
||||
|
||||
chain := *analysis.ReceivedChain
|
||||
if len(chain) != 2 {
|
||||
t.Fatalf("Expected 2 hops in ReceivedChain, got %d", len(chain))
|
||||
}
|
||||
|
||||
// Check first hop
|
||||
if chain[0].From == nil || *chain[0].From != "mail.example.com" {
|
||||
t.Errorf("First hop From = %v, want 'mail.example.com'", chain[0].From)
|
||||
}
|
||||
|
||||
// Check second hop
|
||||
if chain[1].From == nil || *chain[1].From != "relay.example.com" {
|
||||
t.Errorf("Second hop From = %v, want 'relay.example.com'", chain[1].From)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for testing
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func ptrToStr(p *string) string {
|
||||
if p == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func equalStrPtr(a, b *string) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { RBLCheck } from "$lib/api/types.gen";
|
||||
import type { RBLCheck, ReceivedHop } from "$lib/api/types.gen";
|
||||
import { getScoreColorClass } from "$lib/score";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
import EmailPathCard from "./EmailPathCard.svelte";
|
||||
|
||||
interface Props {
|
||||
blacklists: Record<string, RBLCheck[]>;
|
||||
blacklistGrade?: string;
|
||||
blacklistScore?: number;
|
||||
receivedChain?: ReceivedHop[];
|
||||
}
|
||||
|
||||
let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
|
||||
let { blacklists, blacklistGrade, blacklistScore, receivedChain }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
|
|
@ -32,6 +34,10 @@
|
|||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{#if receivedChain}
|
||||
<EmailPathCard {receivedChain} />
|
||||
{/if}
|
||||
|
||||
<div class="row row-cols-1 row-cols-lg-2">
|
||||
{#each Object.entries(blacklists) as [ip, checks]}
|
||||
<div class="col mb-3">
|
||||
|
|
|
|||
41
web/src/lib/components/EmailPathCard.svelte
Normal file
41
web/src/lib/components/EmailPathCard.svelte
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import type { ReceivedHop } from "$lib/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
receivedChain: ReceivedHop[];
|
||||
}
|
||||
|
||||
let { receivedChain }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="mb-3">
|
||||
<h5>Email Path (Received Chain)</h5>
|
||||
<div class="list-group">
|
||||
{#each receivedChain as hop, i}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<span class="badge bg-primary me-2">{receivedChain.length - i}</span>
|
||||
{hop.reverse || '-'} <span class="text-muted">({hop.ip})</span> → {hop.by || 'Unknown'}
|
||||
</h6>
|
||||
<small class="text-muted" title={hop.timestamp}>{hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'}</small>
|
||||
</div>
|
||||
{#if hop.with || hop.id}
|
||||
<p class="mb-1 small">
|
||||
{#if hop.with}
|
||||
<span class="text-muted">Protocol:</span> <code>{hop.with}</code>
|
||||
{/if}
|
||||
{#if hop.id}
|
||||
<span class="text-muted ms-3">ID:</span> <code>{hop.id}</code>
|
||||
{/if}
|
||||
{#if hop.from}
|
||||
<span class="text-muted ms-3">Helo:</span> <code>{hop.from}</code>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -160,34 +160,5 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if headerAnalysis.received_chain && headerAnalysis.received_chain.length > 0}
|
||||
<div class="mt-3">
|
||||
<h6>Email Path (Received Chain)</h6>
|
||||
<div class="list-group">
|
||||
{#each headerAnalysis.received_chain as hop, i}
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<span class="badge bg-primary me-2">{i + 1}</span>
|
||||
{hop.from || 'Unknown'} → {hop.by || 'Unknown'}
|
||||
</h6>
|
||||
<small class="text-muted">{hop.timestamp || '-'}</small>
|
||||
</div>
|
||||
{#if hop.with || hop.id}
|
||||
<p class="mb-1 small">
|
||||
{#if hop.with}
|
||||
<span class="text-muted">Protocol:</span> <code>{hop.with}</code>
|
||||
{/if}
|
||||
{#if hop.id}
|
||||
<span class="text-muted ms-3">ID:</span> <code>{hop.id}</code>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@
|
|||
blacklists={report.blacklists}
|
||||
blacklistGrade={report.summary?.blacklist_grade}
|
||||
blacklistScore={report.summary?.blacklist_score}
|
||||
receivedChain={report.header_analysis?.received_chain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue