496 lines
17 KiB
Svelte
496 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { page } from "$app/state";
|
|
import { onDestroy } from "svelte";
|
|
|
|
import { getReport, getTest, reanalyzeReport } from "$lib/api";
|
|
import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen";
|
|
import {
|
|
AuthenticationCard,
|
|
BlacklistCard,
|
|
ContentAnalysisCard,
|
|
DnsRecordsCard,
|
|
EmailPathCard,
|
|
ErrorDisplay,
|
|
HeaderAnalysisCard,
|
|
PendingState,
|
|
RspamdCard,
|
|
ScoreCard,
|
|
SpamAssassinCard,
|
|
SummaryCard,
|
|
TinySurvey,
|
|
WhitelistCard,
|
|
} from "$lib/components";
|
|
|
|
type BlacklistRecords = Record<string, BlacklistCheck[]>;
|
|
|
|
let testId = $derived(page.params.test);
|
|
let test = $state<Test | null>(null);
|
|
let report = $state<Report | null>(null);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
let errorStatus = $state<number>(500);
|
|
let reanalyzing = $state(false);
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
let nextfetch = $state(23);
|
|
let nbfetch = $state(0);
|
|
let menuOpen = $state(false);
|
|
let fetching = $state(false);
|
|
|
|
// Helper function to handle API errors
|
|
function handleApiError(apiError: unknown, defaultMessage: string) {
|
|
if (apiError && typeof apiError === "object") {
|
|
if ("message" in apiError) {
|
|
error = String(apiError.message);
|
|
} else {
|
|
error = defaultMessage;
|
|
}
|
|
|
|
// Determine status code based on error type
|
|
if ("error" in apiError) {
|
|
if (apiError.error === "rate_limit_exceeded") {
|
|
errorStatus = 429;
|
|
} else if (apiError.error === "not_found") {
|
|
errorStatus = 404;
|
|
} else {
|
|
errorStatus = 500;
|
|
}
|
|
} else {
|
|
errorStatus = 500;
|
|
}
|
|
} else if (apiError instanceof Error) {
|
|
error = apiError.message;
|
|
errorStatus = 500;
|
|
} else {
|
|
error = defaultMessage;
|
|
errorStatus = 500;
|
|
}
|
|
}
|
|
|
|
async function fetchTest() {
|
|
if (!testId) return;
|
|
|
|
if (nbfetch > 0) {
|
|
nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5));
|
|
}
|
|
nbfetch += 1;
|
|
|
|
// Clear any previous errors
|
|
error = null;
|
|
|
|
// Set fetching state and ensure minimum 500ms display time
|
|
fetching = true;
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const testResponse = await getTest({ path: { id: testId } });
|
|
if (testResponse.data) {
|
|
test = testResponse.data;
|
|
|
|
if (test.status === "analyzed") {
|
|
const reportResponse = await getReport({ path: { id: testId } });
|
|
if (reportResponse.data) {
|
|
report = reportResponse.data;
|
|
stopPolling();
|
|
}
|
|
}
|
|
} else if (testResponse.error) {
|
|
handleApiError(testResponse.error, "Failed to fetch test");
|
|
loading = false;
|
|
stopPolling();
|
|
return;
|
|
}
|
|
loading = false;
|
|
} catch (err) {
|
|
handleApiError(err, "Failed to fetch test");
|
|
loading = false;
|
|
stopPolling();
|
|
} finally {
|
|
// Ensure fetching state is displayed for at least 500ms
|
|
const elapsed = Date.now() - startTime;
|
|
if (elapsed < 500) {
|
|
setTimeout(() => {
|
|
fetching = false;
|
|
}, 500 - elapsed);
|
|
} else {
|
|
fetching = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
pollInterval = setInterval(() => {
|
|
nextfetch -= 1;
|
|
|
|
if (nextfetch <= 0) {
|
|
if (!document.hidden) {
|
|
fetchTest();
|
|
} else {
|
|
nextfetch = 1;
|
|
}
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
}
|
|
|
|
let lastTestId: string | null = null;
|
|
function testChange(newTestId: string) {
|
|
if (lastTestId != newTestId) {
|
|
lastTestId = newTestId;
|
|
test = null;
|
|
fetchTest();
|
|
startPolling();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const newTestId = page.params.test;
|
|
if (newTestId) {
|
|
testChange(newTestId);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
stopPolling();
|
|
});
|
|
|
|
async function handleReanalyze() {
|
|
if (!testId || reanalyzing) return;
|
|
|
|
menuOpen = false;
|
|
reanalyzing = true;
|
|
error = null;
|
|
|
|
try {
|
|
const response = await reanalyzeReport({ path: { id: testId } });
|
|
if (response.data) {
|
|
report = response.data;
|
|
} else if (response.error) {
|
|
handleApiError(response.error, "Failed to reanalyze report");
|
|
}
|
|
} catch (err) {
|
|
handleApiError(err, "Failed to reanalyze report");
|
|
} finally {
|
|
reanalyzing = false;
|
|
}
|
|
}
|
|
|
|
function handleExportJSON() {
|
|
const dataStr = JSON.stringify(report, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `report-${testId}.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
menuOpen = false;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>
|
|
{report
|
|
? `Test${report.dns_results ? ` of ${report.dns_results.from_domain}` : ""} ${report.test_id?.slice(0, 7) || ""}`
|
|
: test
|
|
? `Test ${test.id.slice(0, 7)}`
|
|
: "Loading..."} - happyDeliver
|
|
</title>
|
|
</svelte:head>
|
|
|
|
<div class="container py-5">
|
|
{#if loading}
|
|
<div class="text-center py-5">
|
|
<div
|
|
class="spinner-border text-primary"
|
|
role="status"
|
|
style="width: 3rem; height: 3rem;"
|
|
>
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="mt-3 text-muted">Loading test...</p>
|
|
</div>
|
|
{:else if error}
|
|
<ErrorDisplay status={errorStatus} message={error} showActions={false} />
|
|
{:else if test && test.status !== "analyzed"}
|
|
<!-- Pending State -->
|
|
<PendingState
|
|
{nextfetch}
|
|
{nbfetch}
|
|
{test}
|
|
{fetching}
|
|
on:force-inbox-check={() => fetchTest()}
|
|
/>
|
|
{:else if report}
|
|
<!-- Results State -->
|
|
<div class="fade-in">
|
|
<!-- Score Header -->
|
|
<div class="row mb-4" id="score">
|
|
<div class="col-12">
|
|
<div class="position-relative">
|
|
<div class="position-absolute py-2 px-3" style="z-index: 2; right: 0">
|
|
<div class="menu-container">
|
|
<button
|
|
class="btn btn-outline-secondary"
|
|
type="button"
|
|
onclick={() => (menuOpen = !menuOpen)}
|
|
aria-label="Menu"
|
|
>
|
|
<i class="bi bi-three-dots-vertical"></i>
|
|
</button>
|
|
{#if menuOpen}
|
|
<div class="menu-dropdown">
|
|
<button class="menu-item" onclick={handleExportJSON}>
|
|
<i class="bi bi-download me-2"></i>
|
|
Export JSON Report
|
|
</button>
|
|
<button
|
|
class="menu-item"
|
|
onclick={handleReanalyze}
|
|
disabled={reanalyzing}
|
|
>
|
|
<i class="bi bi-arrow-clockwise me-2"></i>
|
|
Reanalyze with Latest Version
|
|
</button>
|
|
<hr class="menu-divider" />
|
|
<a
|
|
class="menu-item"
|
|
href={`/api/report/${testId}/raw`}
|
|
target="_blank"
|
|
onclick={() => (menuOpen = false)}
|
|
>
|
|
<i class="bi bi-file-earmark-text me-2"></i>
|
|
View Raw Email
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ScoreCard
|
|
grade={report.grade}
|
|
score={report.score}
|
|
summary={report.summary}
|
|
{reanalyzing}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<SummaryCard {report}>
|
|
<div class="d-flex justify-content-end me-lg-5">
|
|
<TinySurvey
|
|
class="bg-primary-subtle rounded-4 p-3 text-center"
|
|
source={report.id}
|
|
/>
|
|
</div>
|
|
</SummaryCard>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Received Chain -->
|
|
{#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0}
|
|
<div class="row mb-4" id="received-chain">
|
|
<div class="col-12">
|
|
<EmailPathCard receivedChain={report.header_analysis.received_chain} />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- DNS Records -->
|
|
{#if report.dns_results}
|
|
<div class="row mb-4" id="dns">
|
|
<div class="col-12">
|
|
<DnsRecordsCard
|
|
domainAlignment={report.header_analysis?.domain_alignment}
|
|
dnsResults={report.dns_results}
|
|
dnsGrade={report.summary?.dns_grade}
|
|
dnsScore={report.summary?.dns_score}
|
|
receivedChain={report.header_analysis?.received_chain}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Authentication Results -->
|
|
{#if report.authentication}
|
|
<div class="row mb-4" id="authentication">
|
|
<div class="col-12">
|
|
<AuthenticationCard
|
|
authentication={report.authentication}
|
|
authenticationGrade={report.summary?.authentication_grade}
|
|
authenticationScore={report.summary?.authentication_score}
|
|
dnsResults={report.dns_results}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Blacklist Checks -->
|
|
{#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)}
|
|
<BlacklistCard
|
|
{blacklists}
|
|
blacklistGrade={report.summary?.blacklist_grade}
|
|
blacklistScore={report.summary?.blacklist_score}
|
|
/>
|
|
{/snippet}
|
|
|
|
<!-- Whitelist Checks -->
|
|
{#snippet whitelistChecks(whitelists: BlacklistRecords)}
|
|
<WhitelistCard {whitelists} />
|
|
{/snippet}
|
|
|
|
<!-- Blacklist & Whitelist Checks -->
|
|
{#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1}
|
|
<div class="row mb-4">
|
|
<div class="col-6" id="blacklist">
|
|
{@render blacklistChecks(report.blacklists, report)}
|
|
</div>
|
|
<div class="col-6" id="whitelist">
|
|
{@render whitelistChecks(report.whitelists)}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{#if report.blacklists && Object.keys(report.blacklists).length > 0}
|
|
<div class="row mb-4" id="blacklist">
|
|
<div class="col-12">
|
|
{@render blacklistChecks(report.blacklists, report)}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if report.whitelists && Object.keys(report.whitelists).length > 0}
|
|
<div class="row mb-4" id="whitelist">
|
|
<div class="col-12">
|
|
{@render whitelistChecks(report.whitelists)}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Header Analysis -->
|
|
{#if report.header_analysis}
|
|
<div class="row mb-4" id="header">
|
|
<div class="col-12">
|
|
<HeaderAnalysisCard
|
|
dmarcRecord={report.dns_results?.dmarc_record}
|
|
headerAnalysis={report.header_analysis}
|
|
headerGrade={report.summary?.header_grade}
|
|
headerScore={report.summary?.header_score}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Spam filter analysis -->
|
|
{#if report.spamassassin || report.rspamd}
|
|
<div class="row mb-4" id="spam">
|
|
{#if report.spamassassin}
|
|
<div class={report.rspamd ? "col col-lg-6 mb-4 mb-lg-0" : "col-12"}>
|
|
<SpamAssassinCard spamassassin={report.spamassassin} />
|
|
</div>
|
|
{/if}
|
|
{#if report.rspamd}
|
|
<div class={report.spamassassin ? "col col-lg-6" : "col-12"}>
|
|
<RspamdCard rspamd={report.rspamd} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Content Analysis -->
|
|
{#if report.content_analysis}
|
|
<div class="row mb-4" id="content">
|
|
<div class="col-12">
|
|
<ContentAnalysisCard
|
|
contentAnalysis={report.content_analysis}
|
|
contentGrade={report.summary?.content_grade}
|
|
contentScore={report.summary?.content_score}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="row">
|
|
<div class="col-12 text-center">
|
|
<a href="/test/" class="btn btn-primary btn-lg">
|
|
<i class="bi bi-arrow-repeat me-2"></i>
|
|
Test Another Email
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.fade-in {
|
|
animation: fadeIn 0.6s ease-out;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.menu-container {
|
|
position: relative;
|
|
}
|
|
|
|
.menu-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 0.25rem;
|
|
background: white;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 0.375rem;
|
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
min-width: 250px;
|
|
z-index: 1000;
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
.menu-item {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.5rem 1rem;
|
|
background: none;
|
|
border: none;
|
|
text-align: left;
|
|
color: #212529;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease-in-out;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.menu-item:hover:not(:disabled) {
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.menu-item:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.menu-divider {
|
|
margin: 0.5rem 0;
|
|
border: 0;
|
|
border-top: 1px solid #dee2e6;
|
|
}
|
|
</style>
|