Files
GibSend/apps/web/src/app/(dashboard)/emails/email-details.tsx
2025-09-26 14:30:57 -05:00

320 lines
10 KiB
TypeScript

'use client';
import { UAParser } from 'ua-parser-js';
import { api } from '~/trpc/react';
import { Separator } from '@usesend/ui/src/separator';
import { EmailStatusBadge, EmailStatusIcon } from './email-status-badge';
import { formatDate } from 'date-fns';
import { motion } from 'framer-motion';
import { EmailStatus } from '@prisma/client';
import { JsonValue } from '@prisma/client/runtime/library';
import {
SesBounce,
SesClick,
SesComplaint,
SesDeliveryDelay,
SesOpen,
} from '~/types/aws-types';
import {
BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
} from '~/lib/constants/ses-errors';
import CancelEmail from './cancel-email';
import { useEffect } from 'react';
import { useState } from 'react';
export default function EmailDetails({ emailId }: { emailId: string }) {
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
return (
<div className="no-scrollbar h-full overflow-auto px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="font-bold">{emailQuery.data?.to}</h1>
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? 'SENT'} />
</div>
</div>
<div className="mt-8 flex flex-col items-start gap-8">
<div className="flex w-full flex-col gap-2 rounded-lg border p-2 shadow">
{/* <div className="flex gap-2">
<span className="w-[100px] text-muted-foreground text-sm">
From
</span>
<span className="text-sm">{emailQuery.data?.from}</span>
</div>
<Separator />
<div className="flex gap-2">
<span className="w-[100px] text-muted-foreground text-sm">To</span>
<span className="text-sm">{emailQuery.data?.to}</span>
</div>
<Separator />
<div className="flex gap-2">
<span className="w-[100px] text-muted-foreground text-sm">
Subject
</span>
<span className="text-sm">{emailQuery.data?.subject}</span>
</div> */}
<div className="flex flex-col gap-1 px-4 py-1">
{/* <div className=" text-[15px] font-medium">
{emailQuery.data?.to}
</div> */}
<div className="text-sm">Subject: {emailQuery.data?.subject}</div>
<div className="text-muted-foreground text-xs">
From: {emailQuery.data?.from}
</div>
</div>
{emailQuery.data?.latestStatus === 'SCHEDULED' &&
emailQuery.data?.scheduledAt ? (
<>
<Separator />
<div className="flex items-center gap-2 px-4">
<span className="text-muted-foreground w-[100px] text-sm">
Scheduled at
</span>
<span className="text-sm">
{formatDate(
emailQuery.data?.scheduledAt,
"MMM dd'th', hh:mm a",
)}
</span>
<div className="ml-4">
<CancelEmail emailId={emailId} />
</div>
</div>
</>
) : null}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.3 }}
>
<EmailPreview html={emailQuery.data?.html ?? ''} />
</motion.div>
</div>
{emailQuery.data?.latestStatus !== 'SCHEDULED' ? (
<div className="mb-2 w-full rounded-lg border shadow">
<div className="flex w-full flex-col gap-8 p-4">
<div className="font-medium">Events History</div>
<div className="flex w-full items-stretch px-4">
<div className="border-r border-dashed border-gray-300 dark:border-gray-700" />
<div className="flex w-full flex-col gap-12">
{emailQuery.data?.emailEvents.map((evt) => (
<div
key={evt.status}
className="flex w-full items-start gap-5"
>
<div className="-ml-2.5">
<EmailStatusIcon status={evt.status} />
</div>
<div className="-mt-[0.125rem] w-full">
<div className="font-medium capitalize">
<EmailStatusBadge status={evt.status} />
</div>
<div className="text-muted-foreground mt-2 text-xs">
{formatDate(evt.createdAt, 'MMM dd, hh:mm a')}
</div>
<div className="text-foreground/80 mt-1">
<EmailStatusText
status={evt.status}
data={evt.data}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
) : null}
</div>
</div>
);
}
const EmailPreview = ({ html }: { html: string }) => {
const [show, setShow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setShow(true);
}, 200);
return () => clearTimeout(timer);
}, []);
if (!show) {
return (
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200"></div>
);
}
return (
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200">
<iframe
className="h-full w-full"
srcDoc={html}
sandbox="allow-same-origin"
/>
</div>
);
};
const EmailStatusText = ({
status,
data,
}: {
status: EmailStatus;
data: JsonValue;
}) => {
if (status === 'SENT') {
return (
<div>
We received your request and sent the email to recipient's server.
</div>
);
} else if (status === 'DELIVERED') {
return <div>Mail is successfully delivered to the recipient.</div>;
} else if (status === 'DELIVERY_DELAYED') {
const _errorData = data as unknown as SesDeliveryDelay;
const errorMessage = DELIVERY_DELAY_ERRORS[_errorData.delayType];
return <div>{errorMessage}</div>;
} else if (status === 'BOUNCED') {
const _errorData = data as unknown as SesBounce;
_errorData.bounceType;
return (
<div className="flex w-full flex-col gap-4">
<p>{getErrorMessage(_errorData)}</p>
<div className="bg-muted/30 flex flex-col gap-4 rounded-xl p-4">
<div className="flex w-full gap-2">
<div className="w-1/2">
<p className="text-muted-foreground text-sm">Type</p>
<p>{_errorData.bounceType}</p>
</div>
<div>
<p className="text-muted-foreground text-sm">Sub Type</p>
<p>{_errorData.bounceSubType}</p>
</div>
</div>
<div>
<p className="text-muted-foreground text-sm">SMTP response</p>
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
</div>
</div>
</div>
);
} else if (status === 'FAILED') {
const _errorData = data as unknown as { error: string };
return <div>{_errorData.error}</div>;
} else if (status === 'OPENED') {
const _data = data as unknown as SesOpen;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="bg-muted/30 mt-4 w-full rounded-xl p-4">
<div className="flex w-full">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-muted-foreground text-sm">OS</p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-muted-foreground text-sm">Browser</p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
</div>
);
} else if (status === 'CLICKED') {
const _data = data as unknown as SesClick;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="bg-muted/30 mt-4 flex w-full flex-col gap-4 rounded-xl p-4">
<div className="flex w-full">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-muted-foreground text-sm">OS </p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-muted-foreground text-sm">Browser </p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
<div className="w-full">
<p className="text-muted-foreground text-sm">URL</p>
<p>{_data.link}</p>
</div>
</div>
);
} else if (status === 'COMPLAINED') {
const _errorData = data as unknown as SesComplaint;
return (
<div className="flex w-full flex-col gap-4">
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
</div>
);
} else if (status === 'CANCELLED') {
return <div>This scheduled email was cancelled</div>;
} else if (status === 'SUPPRESSED') {
return (
<div>
This email was suppressed because this email is previously either
bounced or the recipient complained.
</div>
);
}
return <div className="w-full">{status}</div>;
};
const getErrorMessage = (data: SesBounce) => {
if (data.bounceType === 'Permanent') {
return BOUNCE_ERROR_MESSAGES[data.bounceType][
data.bounceSubType as
| 'General'
| 'NoEmail'
| 'Suppressed'
| 'OnAccountSuppressionList'
];
} else if (data.bounceType === 'Transient') {
return BOUNCE_ERROR_MESSAGES[data.bounceType][
data.bounceSubType as
| 'General'
| 'MailboxFull'
| 'MessageTooLarge'
| 'ContentRejected'
| 'AttachmentRejected'
];
} else if (data.bounceType === 'Undetermined') {
return BOUNCE_ERROR_MESSAGES.Undetermined;
}
};
const getComplaintMessage = (errorType: string) => {
return COMPLAINT_ERROR_MESSAGES[
errorType as keyof typeof COMPLAINT_ERROR_MESSAGES
];
};
const getUserAgent = (userAgent: string) => {
const parser = new UAParser(userAgent);
return {
browser: parser.getBrowser(),
os: parser.getOS(),
device: parser.getDevice(),
};
};