Add a summary after score
This commit is contained in:
parent
3588af3267
commit
8b3ab541ba
11 changed files with 425 additions and 14 deletions
|
|
@ -557,7 +557,7 @@ components:
|
|||
example: true
|
||||
importance:
|
||||
type: string
|
||||
enum: [required, recommended, optional]
|
||||
enum: [required, recommended, optional, newsletter]
|
||||
description: How important this header is for deliverability
|
||||
example: "required"
|
||||
issues:
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
<div class="list-group list-group-flush">
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item" id="authentication-iprev">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.iprev.result)} {getAuthResultClass(authentication.iprev.result)} me-2 fs-5"></i>
|
||||
<div>
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
<!-- SPF (Required) -->
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="d-flex align-items-start" id="authentication-spf">
|
||||
{#if authentication.spf}
|
||||
<i class="bi {getAuthResultIcon(authentication.spf.result)} {getAuthResultClass(authentication.spf.result)} me-2 fs-5"></i>
|
||||
<div>
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
</div>
|
||||
|
||||
<!-- DKIM (Required) -->
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item" id="authentication-dkim">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.dkim && authentication.dkim.length > 0}
|
||||
<i class="bi {getAuthResultIcon(authentication.dkim[0].result)} {getAuthResultClass(authentication.dkim[0].result)} me-2 fs-5"></i>
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
</div>
|
||||
|
||||
<!-- DMARC (Required) -->
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item" id="authentication-dmarc">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.dmarc}
|
||||
<i class="bi {getAuthResultIcon(authentication.dmarc.result)} {getAuthResultClass(authentication.dmarc.result)} me-2 fs-5"></i>
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
</div>
|
||||
|
||||
<!-- BIMI (Optional) -->
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item" id="authentication-bimi">
|
||||
<div class="d-flex align-items-start">
|
||||
{#if authentication.bimi && authentication.bimi.result != "none"}
|
||||
<i class="bi {getAuthResultIcon(authentication.bimi.result)} {getAuthResultClass(authentication.bimi.result)} me-2 fs-5"></i>
|
||||
|
|
@ -269,7 +269,7 @@
|
|||
|
||||
<!-- ARC (Optional) -->
|
||||
{#if authentication.arc}
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item" id="authentication-arc">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi {getAuthResultIcon(authentication.arc.result)} {getAuthResultClass(authentication.arc.result)} me-2 fs-5"></i>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</script>
|
||||
|
||||
{#if bimiRecord}
|
||||
<div class="card mb-4">
|
||||
<div class="card mb-4" id="dns-bimi">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-0">
|
||||
<i
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
</script>
|
||||
|
||||
{#if dmarcRecord}
|
||||
<div class="card mb-4">
|
||||
<div class="card mb-4" id="dns-dmarc">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-0">
|
||||
<i
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</script>
|
||||
|
||||
{#if receivedChain && receivedChain.length > 0}
|
||||
<div class="mb-3">
|
||||
<div class="mb-3" id="email-path">
|
||||
<h5>Email Path (Received Chain)</h5>
|
||||
<div class="list-group">
|
||||
{#each receivedChain as hop, i}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
interface Props {
|
||||
grade?: string;
|
||||
score: number;
|
||||
size?: "small" | "medium" | "large";
|
||||
size?: "inline" | "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
let { grade, score, size = "medium" }: Props = $props();
|
||||
|
|
@ -36,7 +36,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
function getSizeClass(size: "small" | "medium" | "large"): string {
|
||||
function getSizeClass(size: "inline" | "small" | "medium" | "large"): string {
|
||||
if (size === "inline") return "fw-bold";
|
||||
if (size === "small") return "fs-4";
|
||||
if (size === "large") return "display-1";
|
||||
return "fs-2";
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
</script>
|
||||
|
||||
{#if ptrRecords && ptrRecords.length > 0}
|
||||
<div class="card mb-4">
|
||||
<div class="card mb-4" id="dns-ptr">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-0">
|
||||
<i
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
</script>
|
||||
|
||||
{#if spfRecords && spfRecords.length > 0}
|
||||
<div class="card mb-4">
|
||||
<div class="card mb-4" id="dns-spf">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="text-muted mb-2">
|
||||
<i
|
||||
|
|
|
|||
401
web/src/lib/components/SummaryCard.svelte
Normal file
401
web/src/lib/components/SummaryCard.svelte
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
<script lang="ts">
|
||||
import type { Report } from "$lib/api/types.gen";
|
||||
import GradeDisplay from "./GradeDisplay.svelte";
|
||||
|
||||
interface TextSegment {
|
||||
text: string;
|
||||
highlight?: {
|
||||
color: "good" | "warning" | "danger";
|
||||
bold?: boolean;
|
||||
};
|
||||
link?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
report: Report;
|
||||
}
|
||||
|
||||
let { report }: Props = $props();
|
||||
|
||||
function buildSummary(): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
|
||||
// Email sender information
|
||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||
const hasDkim = report.authentication?.dkim && report.authentication.dkim.length > 0;
|
||||
const dkimPassed = hasDkim && report.authentication.dkim.some(d => d.result === "pass");
|
||||
|
||||
segments.push({ text: "Received a " });
|
||||
segments.push({
|
||||
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
||||
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
||||
link: "#authentication-dkim"
|
||||
});
|
||||
segments.push({ text: " email from " });
|
||||
segments.push({
|
||||
text: mailFrom,
|
||||
highlight: { emphasis: true }
|
||||
});
|
||||
|
||||
// Server information and hops
|
||||
const receivedChain = report.header_analysis?.received_chain;
|
||||
if (receivedChain && receivedChain.length > 0) {
|
||||
const firstHop = receivedChain[0];
|
||||
const serverName = firstHop.from || firstHop.ip || "an unknown server";
|
||||
const hopCount = receivedChain.length;
|
||||
segments.push({ text: ", sent by " });
|
||||
segments.push({
|
||||
text: serverName,
|
||||
highlight: { monospace: true },
|
||||
link: "#header-details"
|
||||
});
|
||||
segments.push({ text: " after " });
|
||||
segments.push({
|
||||
text: `${hopCount-1} hop${hopCount-1 !== 1 ? "s" : ""}`,
|
||||
link: "#email-path"
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication status
|
||||
const spfResult = report.authentication?.spf?.result;
|
||||
const dmarcResult = report.authentication?.dmarc?.result;
|
||||
|
||||
segments.push({ text: " which is " });
|
||||
if (spfResult === "pass" || dmarcResult === "pass") {
|
||||
segments.push({
|
||||
text: "authenticated",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#authentication-details"
|
||||
});
|
||||
segments.push({ text: " to send email on behalf of " });
|
||||
segments.push({ text: report.header_analysis?.domain_alignment?.from_domain, highlight: {monospace: true} });
|
||||
} else if (spfResult && spfResult !== "none") {
|
||||
segments.push({
|
||||
text: "not authenticated",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#authentication-spf"
|
||||
});
|
||||
segments.push({ text: " (failed authentication checks)" });
|
||||
} else {
|
||||
segments.push({
|
||||
text: "not authenticated",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#authentication-details"
|
||||
});
|
||||
segments.push({ text: " (lacks proper authentication)" });
|
||||
}
|
||||
|
||||
// IP Reverse DNS (iprev) check
|
||||
const iprevResult = report.authentication?.iprev;
|
||||
if (iprevResult) {
|
||||
segments.push({ text: ". Its reverse IP " });
|
||||
if (iprevResult.result === "pass") {
|
||||
segments.push({ text: "looks " });
|
||||
segments.push({
|
||||
text: "good",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#dns-ptr"
|
||||
});
|
||||
} else if (iprevResult.result === "fail") {
|
||||
segments.push({
|
||||
text: "failed",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#dns-ptr"
|
||||
});
|
||||
segments.push({ text: " to pass the test" });
|
||||
} else {
|
||||
segments.push({ text: "returned " });
|
||||
segments.push({
|
||||
text: iprevResult.result,
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-ptr"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Blacklist status
|
||||
const blacklists = report.blacklists;
|
||||
if (blacklists && Object.keys(blacklists).length > 0) {
|
||||
const allChecks = Object.values(blacklists).flat();
|
||||
const listedCount = allChecks.filter(check => check.listed).length;
|
||||
|
||||
segments.push({ text: ". Your server is " });
|
||||
if (listedCount > 0) {
|
||||
segments.push({
|
||||
text: `blacklisted on ${listedCount} list${listedCount !== 1 ? "s" : ""}`,
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#rbl-details"
|
||||
});
|
||||
} else {
|
||||
segments.push({
|
||||
text: "not blacklisted",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#rbl-details"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Domain alignment
|
||||
const domainAlignment = report.header_analysis?.domain_alignment;
|
||||
if (domainAlignment) {
|
||||
segments.push({ text: ". Domain alignment is " });
|
||||
if (domainAlignment.aligned || domainAlignment.relaxed_aligned) {
|
||||
segments.push({
|
||||
text: "good",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#domain-alignment"
|
||||
});
|
||||
if (!domainAlignment.aligned) {
|
||||
segments.push({ text: " using organizational domain" });
|
||||
}
|
||||
} else {
|
||||
segments.push({
|
||||
text: "misaligned",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#domain-alignment"
|
||||
});
|
||||
segments.push({ text: ": " });
|
||||
segments.push({ text: "Return-Path", highlight: { monospace: true } });
|
||||
segments.push({ text: " is set to an address of " });
|
||||
segments.push({ text: report.header_analysis?.domain_alignment?.return_path_domain, highlight: { monospace: true } });
|
||||
segments.push({ text: ", you should " });
|
||||
segments.push({
|
||||
text: "update it",
|
||||
highlight: { bold: true },
|
||||
link: "#domain-alignment"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC policy check
|
||||
const dmarcRecord = report.dns_results?.dmarc_record;
|
||||
if (dmarcRecord) {
|
||||
if (!dmarcRecord.record) {
|
||||
segments.push({ text: ". You " });
|
||||
segments.push({
|
||||
text: "don't have",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#dns-dmarc"
|
||||
});
|
||||
segments.push({ text: " a DMARC record, " });
|
||||
segments.push({ text: "consider adding at least a record with the '", highlight: { bold : true } });
|
||||
segments.push({ text: "none", highlight: { monospace: true, bold: true } });
|
||||
segments.push({ text: "' policy", highlight: { bold : true } });
|
||||
} else if (!dmarcRecord.valid) {
|
||||
segments.push({ text: ". Your DMARC record has " });
|
||||
segments.push({
|
||||
text: "issues",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#dns-dmarc"
|
||||
});
|
||||
} else if (dmarcRecord.policy === "none") {
|
||||
segments.push({ text: ". Your DMARC policy is " });
|
||||
segments.push({
|
||||
text: "set to 'none'",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-dmarc"
|
||||
});
|
||||
segments.push({ text: ", which provides monitoring but no protection" });
|
||||
} else if (dmarcRecord.policy === "quarantine" || dmarcRecord.policy === "reject") {
|
||||
segments.push({ text: ". Your DMARC policy is '" });
|
||||
segments.push({
|
||||
text: dmarcRecord.policy,
|
||||
highlight: { color: "good", bold: true, monospace: true },
|
||||
link: "#dns-dmarc"
|
||||
});
|
||||
segments.push({ text: "'" });
|
||||
if (dmarcRecord.policy === "reject") {
|
||||
segments.push({ text: ", which is great" });
|
||||
} else {
|
||||
segments.push({ text: ", consider switching to reject" });
|
||||
}
|
||||
}
|
||||
} else if (dmarcResult && dmarcResult.result === "fail") {
|
||||
segments.push({ text: ". DMARC check " });
|
||||
segments.push({
|
||||
text: "failed",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#authentication-dmarc"
|
||||
});
|
||||
}
|
||||
|
||||
// BIMI
|
||||
if (dmarcRecord.valid && dmarcRecord.policy != "none") {
|
||||
const bimiResult = report.authentication?.bimi;
|
||||
const bimiRecord = report.dns_results?.bimi_record;
|
||||
if (bimiRecord?.valid) {
|
||||
segments.push({ text: ". Your domain includes " });
|
||||
segments.push({
|
||||
text: "BIMI",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#dns-bimi"
|
||||
});
|
||||
segments.push({ text: " for brand indicator display" });
|
||||
} else if (bimiResult && bimiResult.details.indexOf("(No BIMI records found)") >= 0) {
|
||||
segments.push({ text: ". Your domain has no " });
|
||||
segments.push({
|
||||
text: "BIMI record",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-bimi"
|
||||
});
|
||||
} else if (bimiResult || bimiRecord) {
|
||||
segments.push({ text: ". Your domain has " });
|
||||
segments.push({
|
||||
text: "BIMI configured with issues",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#dns-bimi"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ARC
|
||||
const arcResult = report.authentication?.arc;
|
||||
if (arcResult && arcResult.result !== "none") {
|
||||
segments.push({ text: ". " });
|
||||
segments.push({
|
||||
text: "ARC chain validation",
|
||||
link: "#authentication-arc"
|
||||
});
|
||||
segments.push({ text: " " });
|
||||
if (arcResult.chain_valid) {
|
||||
segments.push({
|
||||
text: "passed",
|
||||
highlight: { color: "good", bold: true }
|
||||
});
|
||||
segments.push({ text: ` with ${arcResult.chain_length} set${arcResult.chain_length !== 1 ? "s" : ""}, indicating proper email forwarding` });
|
||||
} else {
|
||||
segments.push({
|
||||
text: "failed",
|
||||
highlight: { color: "danger", bold: true }
|
||||
});
|
||||
segments.push({ text: ", which may indicate issues with email forwarding" });
|
||||
}
|
||||
}
|
||||
|
||||
// Newsletter/marketing headers check
|
||||
const headers = report.header_analysis?.headers;
|
||||
const listUnsubscribe = headers?.["list-unsubscribe"];
|
||||
const listUnsubscribePost = headers?.["list-unsubscribe-post"];
|
||||
|
||||
const hasNewsletterHeaders = (listUnsubscribe?.importance === "newsletter" && listUnsubscribe?.present) ||
|
||||
(listUnsubscribePost?.importance === "newsletter" && listUnsubscribePost?.present);
|
||||
|
||||
if (!hasNewsletterHeaders && (listUnsubscribe?.importance === "newsletter" || listUnsubscribePost?.importance === "newsletter")) {
|
||||
segments.push({ text: ". This email is " });
|
||||
segments.push({
|
||||
text: "missing unsubscribe headers",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#header-details"
|
||||
});
|
||||
segments.push({ text: " and is " });
|
||||
segments.push({
|
||||
text: "not suitable for marketing campaigns",
|
||||
highlight: { bold: true }
|
||||
});
|
||||
}
|
||||
|
||||
// Content/spam assessment
|
||||
const spamAssassin = report.spamassassin;
|
||||
const contentScore = report.summary?.content_score || 0;
|
||||
|
||||
segments.push({ text: ". " });
|
||||
if (spamAssassin?.is_spam) {
|
||||
segments.push({ text: "Content is " });
|
||||
segments.push({
|
||||
text: "flagged as spam",
|
||||
highlight: { color: "danger", bold: true },
|
||||
link: "#spam-details"
|
||||
});
|
||||
segments.push({ text: " and needs review" });
|
||||
} else if (contentScore < 50) {
|
||||
segments.push({ text: "Content quality " });
|
||||
segments.push({
|
||||
text: "needs improvement",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#content-details"
|
||||
});
|
||||
} else if (contentScore >= 100) {
|
||||
segments.push({ text: "Content " });
|
||||
segments.push({
|
||||
text: "looks great",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#content-details"
|
||||
});
|
||||
} else if (contentScore >= 80) {
|
||||
segments.push({ text: "Content " });
|
||||
segments.push({
|
||||
text: "looks good",
|
||||
highlight: { color: "good", bold: true },
|
||||
link: "#content-details"
|
||||
});
|
||||
} else {
|
||||
segments.push({ text: "Content " });
|
||||
segments.push({
|
||||
text: "should be reviewed",
|
||||
highlight: { color: "warning", bold: true },
|
||||
link: "#content-details"
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({ text: "." });
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function getColorClass(color: "good" | "warning" | "danger"): string {
|
||||
switch (color) {
|
||||
case "good":
|
||||
return "text-success";
|
||||
case "warning":
|
||||
return "text-warning";
|
||||
case "danger":
|
||||
return "text-danger";
|
||||
}
|
||||
}
|
||||
|
||||
const summarySegments = $derived(buildSummary());
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.summary-link {
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title mb-3">
|
||||
<i class="bi bi-card-text me-2"></i>
|
||||
Summary
|
||||
</h5>
|
||||
<p class="card-text text-muted mb-0" style="line-height: 1.8;">
|
||||
{#each summarySegments as segment}
|
||||
{#if segment.link}
|
||||
<a
|
||||
href={segment.link}
|
||||
class="summary-link {segment.highlight ? getColorClass(segment.highlight.color) : ''} {segment.highlight?.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}"
|
||||
>
|
||||
{segment.text}
|
||||
</a>
|
||||
{:else if segment.highlight}
|
||||
<span class="{getColorClass(segment.highlight.color)} {segment.highlight.bold ? 'highlighted' : ''} {segment.highlight?.emphasis ? 'fst-italic' : ''} {segment.highlight?.monospace ? 'font-monospace' : ''}">
|
||||
{segment.text}
|
||||
</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
Overall, your email received a grade <GradeDisplay grade={report.grade} score={report.score} size="inline" />{#if report.grade == "A" || report.grade == "A+"}, well done 🎉{/if}! Check the details below 🔽
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
export { default as FeatureCard } from "./FeatureCard.svelte";
|
||||
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
|
||||
export { default as ScoreCard } from "./ScoreCard.svelte";
|
||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
|
||||
export { default as PendingState } from "./PendingState.svelte";
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import type { Test, Report } from "$lib/api/types.gen";
|
||||
import {
|
||||
ScoreCard,
|
||||
SummaryCard,
|
||||
SpamAssassinCard,
|
||||
PendingState,
|
||||
AuthenticationCard,
|
||||
|
|
@ -138,6 +139,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<SummaryCard {report} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DNS Records -->
|
||||
{#if report.dns_results}
|
||||
<div class="row mb-4" id="dns">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue