happyDeliver/web/src/routes/test/[test]/+page.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>