Add attachment support
This commit is contained in:
103
apps/web/src/app/(dashboard)/emails/email-details.tsx
Normal file
103
apps/web/src/app/(dashboard)/emails/email-details.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Separator } from "@unsend/ui/src/separator";
|
||||
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
|
||||
import { formatDate } from "date-fns";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { SesDeliveryDelay } from "~/types/aws-types";
|
||||
import { DELIVERY_DELAY_ERRORS } from "~/lib/constants/ses-errors";
|
||||
|
||||
export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-4 items-center">
|
||||
<h1 className="font-bold text-lg">{emailQuery.data?.to}</h1>
|
||||
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? "SENT"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 mt-10 items-start">
|
||||
<div className="p-2 rounded-lg border flex flex-col gap-4 ">
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">From</span>
|
||||
<span>{emailQuery.data?.from}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">To</span>
|
||||
<span>{emailQuery.data?.to}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
||||
<span>{emailQuery.data?.subject}</span>
|
||||
</div>
|
||||
<div className=" dark:bg-slate-200 h-[350px] overflow-auto text-black rounded">
|
||||
<div
|
||||
className="px-4 py-4 overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" border rounded-lg w-full ">
|
||||
<div className=" p-4 flex flex-col gap-8">
|
||||
<div className="font-medium">Events History</div>
|
||||
<div className="flex items-stretch px-4">
|
||||
<div className="border-r border-dashed" />
|
||||
<div className="flex flex-col gap-12">
|
||||
{emailQuery.data?.emailEvents.map((evt) => (
|
||||
<div key={evt.status} className="flex gap-5 items-start">
|
||||
<div className=" -ml-2.5">
|
||||
<EmailStatusIcon status={evt.status} />
|
||||
</div>
|
||||
<div className="-mt-1">
|
||||
<div className=" capitalize font-medium">
|
||||
<EmailStatusBadge status={evt.status} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{formatDate(evt.createdAt, "MMM dd, hh:mm a")}
|
||||
</div>
|
||||
<div className="mt-1 text-primary/70">
|
||||
<EmailStatusText status={evt.status} data={evt.data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>;
|
||||
}
|
||||
return <div>{status}</div>;
|
||||
};
|
@@ -3,7 +3,6 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Table,
|
||||
TableCaption,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
@@ -20,15 +19,85 @@ import {
|
||||
MailWarning,
|
||||
MailX,
|
||||
} from "lucide-react";
|
||||
import { formatDistance, formatDistanceToNow } from "date-fns";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { EmailStatusBadge } from "./email-status-badge";
|
||||
import { useState } from "react";
|
||||
import EmailDetails from "./email-details";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation"; // Adjust the import based on your project setup
|
||||
import dynamic from "next/dynamic";
|
||||
import { useUrlState } from "~/hooks/useUrlState";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@unsend/ui/src/select";
|
||||
|
||||
/* Stupid hydrating error. And I so stupid to understand the stupid NextJS docs. Because they stupid change it everyday */
|
||||
const DynamicSheetWithNoSSR = dynamic(
|
||||
() => import("@unsend/ui/src/sheet").then((mod) => mod.Sheet),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const DynamicSheetContentWithNoSSR = dynamic(
|
||||
() => import("@unsend/ui/src/sheet").then((mod) => mod.SheetContent),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function EmailsList() {
|
||||
const emailsQuery = api.email.emails.useQuery();
|
||||
const [selectedEmail, setSelectedEmail] = useUrlState("emailId");
|
||||
const [page, setPage] = useUrlState("page", "1");
|
||||
const [status, setStatus] = useUrlState("status");
|
||||
|
||||
const pageNumber = Number(page);
|
||||
|
||||
const emailsQuery = api.email.emails.useQuery({
|
||||
page: pageNumber,
|
||||
status: status?.toUpperCase() as EmailStatus,
|
||||
});
|
||||
|
||||
const handleSelectEmail = (emailId: string) => {
|
||||
setSelectedEmail(emailId);
|
||||
};
|
||||
|
||||
const handleSheetChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setSelectedEmail(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="flex rounded-xl border shadow">
|
||||
<div className="mt-10 flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Select
|
||||
value={status ?? "All statuses"}
|
||||
onValueChange={(val) =>
|
||||
setStatus(val === "All statuses" ? null : val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] capitalize">
|
||||
{status ? status.toLowerCase().replace("_", " ") : "All statuses"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All statuses" className=" capitalize">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
{Object.values(EmailStatus).map((status) => (
|
||||
<SelectItem value={status} className=" capitalize">
|
||||
{status.toLowerCase().replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* <SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border shadow">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
@@ -41,26 +110,62 @@ export default function EmailsList() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{emailsQuery.data?.map((email) => (
|
||||
<TableRow key={email.id}>
|
||||
<TableCell className="font-medium flex gap-4 items-center">
|
||||
<EmailIcon status={email.latestStatus ?? "Send"} />
|
||||
<p>{email.to}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
|
||||
{/* <Badge className="w-[100px] flex py-1 justify-center text-emerald-400 hover:bg-emerald-500/10 bg-emerald-500/10 rounded">
|
||||
{email.latestStatus ?? "Sent"}
|
||||
</Badge> */}
|
||||
</TableCell>
|
||||
<TableCell>{email.subject}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistanceToNow(email.createdAt, { addSuffix: true })}
|
||||
{emailsQuery.data?.emails.length ? (
|
||||
emailsQuery.data?.emails.map((email) => (
|
||||
<TableRow
|
||||
key={email.id}
|
||||
onClick={() => handleSelectEmail(email.id)}
|
||||
className=" cursor-pointer"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex gap-4 items-center">
|
||||
<EmailIcon status={email.latestStatus ?? "Send"} />
|
||||
<p>{email.to}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
|
||||
</TableCell>
|
||||
<TableCell>{email.subject}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatDistanceToNow(email.createdAt, { addSuffix: true })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
No emails found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<DynamicSheetWithNoSSR
|
||||
open={!!selectedEmail}
|
||||
onOpenChange={handleSheetChange}
|
||||
>
|
||||
<DynamicSheetContentWithNoSSR className=" sm:max-w-3xl">
|
||||
{selectedEmail ? <EmailDetails emailId={selectedEmail} /> : null}
|
||||
</DynamicSheetContentWithNoSSR>
|
||||
</DynamicSheetWithNoSSR>
|
||||
</div>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber - 1).toString())}
|
||||
disabled={pageNumber === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPage((pageNumber + 1).toString())}
|
||||
disabled={pageNumber >= (emailsQuery.data?.totalPage ?? 0)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -113,39 +218,3 @@ const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({ status }) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case "SENT":
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
break;
|
||||
case "DELIVERED":
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
|
||||
break;
|
||||
case "CLICKED":
|
||||
badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10";
|
||||
break;
|
||||
case "OPENED":
|
||||
badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10";
|
||||
break;
|
||||
case "DELIVERY_DELAYED":
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
case "COMPLAINED":
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center w-[130px] rounded capitalize py-0.5 text-xs ${badgeColor}`}
|
||||
>
|
||||
{status.toLowerCase().split("_").join(" ")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
83
apps/web/src/app/(dashboard)/emails/email-status-badge.tsx
Normal file
83
apps/web/src/app/(dashboard)/emails/email-status-badge.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
|
||||
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10"; // Default color
|
||||
switch (status) {
|
||||
case "SENT":
|
||||
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
|
||||
break;
|
||||
case "DELIVERED":
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
|
||||
break;
|
||||
case "CLICKED":
|
||||
badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10";
|
||||
break;
|
||||
case "OPENED":
|
||||
badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10";
|
||||
break;
|
||||
case "DELIVERY_DELAYED":
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
case "COMPLAINED":
|
||||
badgeColor = "bg-yellow-500/10 text-yellow-600 border-yellow-600/10";
|
||||
break;
|
||||
default:
|
||||
badgeColor = "bg-gray-400/10 text-gray-400 border-gray-400/10";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
|
||||
>
|
||||
{status.toLowerCase().split("_").join(" ")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||
status,
|
||||
}) => {
|
||||
let outsideColor = "bg-gray-600";
|
||||
let insideColor = "bg-gray-600/50";
|
||||
|
||||
switch (status) {
|
||||
case "DELIVERED":
|
||||
outsideColor = "bg-emerald-500/40";
|
||||
insideColor = "bg-emerald-500";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
outsideColor = "bg-red-500/40";
|
||||
insideColor = "bg-red-500";
|
||||
break;
|
||||
case "CLICKED":
|
||||
outsideColor = "bg-cyan-500/40";
|
||||
insideColor = "bg-cyan-500";
|
||||
break;
|
||||
case "OPENED":
|
||||
outsideColor = "bg-indigo-500/40";
|
||||
insideColor = "bg-indigo-500";
|
||||
break;
|
||||
case "DELIVERY_DELAYED":
|
||||
outsideColor = "bg-yellow-500/40";
|
||||
insideColor = "bg-yellow-500";
|
||||
case "COMPLAINED":
|
||||
outsideColor = "bg-yellow-500/40";
|
||||
insideColor = "bg-yellow-500";
|
||||
break;
|
||||
default:
|
||||
outsideColor = "bg-gray-600/40";
|
||||
insideColor = "bg-gray-600";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
|
||||
>
|
||||
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,13 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import EmailList from "./email-list";
|
||||
import { Suspense } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const EmailList = dynamic(
|
||||
() => import("./email-list").then((mod) => mod.default),
|
||||
{ ssr: false }
|
||||
);
|
||||
export default async function EmailsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Emails</h1>
|
||||
</div>
|
||||
{/* <Suspense fallback={<div>Loading...</div>}> */}
|
||||
<EmailList />
|
||||
{/* </Suspense> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -3,7 +3,9 @@ import { redirect } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
BellRing,
|
||||
BookUser,
|
||||
CircleUser,
|
||||
Code,
|
||||
Globe,
|
||||
Home,
|
||||
KeyRound,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
|
||||
@@ -61,12 +64,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||
<div className="flex h-14 items-center px-4 lg:h-[60px] lg:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<Image
|
||||
src="/unsend_white_new.png"
|
||||
alt="Unsend"
|
||||
width={25}
|
||||
height={25}
|
||||
/>
|
||||
<Image src="/Logo-1.png" alt="Unsend" width={40} height={40} />
|
||||
|
||||
<span className=" ">Unsend</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -87,6 +86,16 @@ export default async function AuthenticatedDashboardLayout({
|
||||
Domains
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/contacts" comingSoon>
|
||||
<BookUser className="h-4 w-4" />
|
||||
Contacts
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/contacts" comingSoon>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
Marketing
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/sms" comingSoon>
|
||||
<MessageSquareMore className="h-4 w-4" />
|
||||
SMS
|
||||
@@ -98,8 +107,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/api-keys">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
API keys
|
||||
<Code className="h-4 w-4" />
|
||||
Developer settings
|
||||
</NavButton>
|
||||
</nav>
|
||||
</div>
|
||||
|
@@ -1,6 +1,3 @@
|
||||
import { headers } from "next/headers";
|
||||
import { hashToken } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
import { parseSesHook } from "~/server/service/ses-hook-parser";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
@@ -22,14 +19,15 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
message = JSON.parse(data.Message || "{}");
|
||||
const status = await parseSesHook(message);
|
||||
console.log("Error is parsing hook", status);
|
||||
if (!status) {
|
||||
return Response.json({ data: "Error is parsing hook" }, { status: 400 });
|
||||
return Response.json({ data: "Error is parsing hook" });
|
||||
}
|
||||
|
||||
return Response.json({ data: "Success" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Response.json({ data: "Error is parsing hook" }, { status: 400 });
|
||||
return Response.json({ data: "Error is parsing hook" });
|
||||
}
|
||||
}
|
||||
|
||||
|
36
apps/web/src/hooks/useUrlState.ts
Normal file
36
apps/web/src/hooks/useUrlState.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import qs from "query-string";
|
||||
|
||||
/**
|
||||
* A custom hook to use URL as state
|
||||
* @param key The query parameter key.
|
||||
*/
|
||||
export function useUrlState(key: string, defaultValue: string | null = null) {
|
||||
const [state, setState] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const queryValue = qs.parse(window.location.search)[key];
|
||||
if (queryValue !== undefined) {
|
||||
return (Array.isArray(queryValue) ? queryValue[0] : queryValue) ?? null;
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
// Update URL when state changes
|
||||
const setUrlState = useCallback(
|
||||
(newValue: string | null) => {
|
||||
setState(newValue);
|
||||
const newQuery = {
|
||||
...qs.parse(window.location.search),
|
||||
[key]: newValue,
|
||||
};
|
||||
const newUrl = qs.stringifyUrl({
|
||||
url: window.location.href,
|
||||
query: newQuery,
|
||||
});
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
return [state, setUrlState] as const;
|
||||
}
|
46
apps/web/src/lib/constants/ses-errors.ts
Normal file
46
apps/web/src/lib/constants/ses-errors.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const DELIVERY_DELAY_ERRORS = {
|
||||
InternalFailure: "An internal Unsend issue caused the message to be delayed.",
|
||||
General: "A generic failure occurred during the SMTP conversation.",
|
||||
MailboxFull:
|
||||
"The recipient's mailbox is full and is unable to receive additional messages.",
|
||||
SpamDetected:
|
||||
"The recipient's mail server has detected a large amount of unsolicited email from your account.",
|
||||
RecipientServerError:
|
||||
"A temporary issue with the recipient's email server is preventing the delivery of the message.",
|
||||
IPFailure:
|
||||
"The IP address that's sending the message is being blocked or throttled by the recipient's email provider.",
|
||||
TransientCommunicationFailure:
|
||||
"There was a temporary communication failure during the SMTP conversation with the recipient's email provider.",
|
||||
BYOIPHostNameLookupUnavailable:
|
||||
"Unsend was unable to look up the DNS hostname for your IP addresses. This type of delay only occurs when you use Bring Your Own IP.",
|
||||
Undetermined:
|
||||
"Unsend wasn't able to determine the reason for the delivery delay.",
|
||||
SendingDeferral:
|
||||
"Unsend has deemed it appropriate to internally defer the message.",
|
||||
};
|
||||
|
||||
export const BOUNCE_ERROR_MESSAGES = {
|
||||
Undetermined: "Unsend was unable to determine a specific bounce reason.",
|
||||
Permanent: {
|
||||
General:
|
||||
"Unsend received a general hard bounce. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
|
||||
NoEmail:
|
||||
"Unsend received a permanent hard bounce because the target email address does not exist. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
|
||||
Suppressed:
|
||||
"Unsend has suppressed sending to this address because it has a recent history of bouncing as an invalid address. To override the global suppression list, see Using the Unsend account-level suppression list.",
|
||||
OnAccountSuppressionList:
|
||||
"Unsend has suppressed sending to this address because it is on the account-level suppression list. This does not count toward your bounce rate metric.",
|
||||
},
|
||||
Transient: {
|
||||
General:
|
||||
"Unsend received a general bounce. You may be able to successfully send to this recipient in the future.",
|
||||
MailboxFull:
|
||||
"Unsend received a mailbox full bounce. You may be able to successfully send to this recipient in the future.",
|
||||
MessageTooLarge:
|
||||
"Unsend received a message too large bounce. You may be able to successfully send to this recipient if you reduce the size of the message.",
|
||||
ContentRejected:
|
||||
"Unsend received a content rejected bounce. You may be able to successfully send to this recipient if you change the content of the message.",
|
||||
AttachmentRejected:
|
||||
"Unsend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
|
||||
},
|
||||
};
|
@@ -1,24 +1,55 @@
|
||||
import { EmailStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
teamProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
|
||||
import { db } from "~/server/db";
|
||||
import { createDomain, getDomain } from "~/server/service/domain-service";
|
||||
|
||||
const statuses = Object.values(EmailStatus) as [EmailStatus];
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
|
||||
export const emailRouter = createTRPCRouter({
|
||||
emails: teamProcedure.query(async ({ ctx }) => {
|
||||
const emails = await db.email.findMany({
|
||||
where: {
|
||||
teamId: ctx.team.id,
|
||||
},
|
||||
});
|
||||
emails: teamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
status: z.enum(statuses).optional().nullable(),
|
||||
domain: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const page = input.page || 1;
|
||||
const limit = DEFAULT_LIMIT;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return emails;
|
||||
}),
|
||||
const whereConditions = {
|
||||
teamId: ctx.team.id,
|
||||
...(input.status ? { latestStatus: input.status } : {}),
|
||||
...(input.domain ? { domainId: input.domain } : {}),
|
||||
};
|
||||
|
||||
const countP = db.email.count({ where: whereConditions });
|
||||
|
||||
const emailsP = db.email.findMany({
|
||||
where: whereConditions,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
latestStatus: true,
|
||||
subject: true,
|
||||
to: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const [emails, count] = await Promise.all([emailsP, countP]);
|
||||
|
||||
return { emails, totalPage: Math.ceil(count / limit) };
|
||||
}),
|
||||
|
||||
getEmail: teamProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
@@ -30,7 +61,7 @@ export const emailRouter = createTRPCRouter({
|
||||
include: {
|
||||
emailEvents: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
createdAt: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
EventType,
|
||||
} from "@aws-sdk/client-sesv2";
|
||||
import { generateKeyPairSync } from "crypto";
|
||||
import mime from "mime-types";
|
||||
import { env } from "~/env";
|
||||
import { EmailContent } from "~/types";
|
||||
import { APP_SETTINGS } from "~/utils/constants";
|
||||
@@ -154,6 +155,63 @@ export async function sendEmailThroughSes({
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailWithAttachments({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments,
|
||||
region = "us-east-1",
|
||||
configurationSetName,
|
||||
}: EmailContent & {
|
||||
region?: string;
|
||||
configurationSetName: string;
|
||||
attachments: { filename: string; content: string }[];
|
||||
}) {
|
||||
const sesClient = getSesClient(region);
|
||||
const boundary = "NextPart";
|
||||
let rawEmail = `From: ${from}\n`;
|
||||
rawEmail += `To: ${to}\n`;
|
||||
rawEmail += `Subject: ${subject}\n`;
|
||||
rawEmail += `MIME-Version: 1.0\n`;
|
||||
rawEmail += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
|
||||
rawEmail += `--${boundary}\n`;
|
||||
rawEmail += `Content-Type: text/html; charset="UTF-8"\n\n`;
|
||||
rawEmail += `${html}\n\n`;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const content = attachment.content; // Convert buffer to base64
|
||||
const mimeType =
|
||||
mime.lookup(attachment.filename) || "application/octet-stream";
|
||||
rawEmail += `--${boundary}\n`;
|
||||
rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`;
|
||||
rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
|
||||
rawEmail += `Content-Transfer-Encoding: base64\n\n`;
|
||||
rawEmail += `${content}\n\n`;
|
||||
}
|
||||
|
||||
rawEmail += `--${boundary}--`;
|
||||
|
||||
const command = new SendEmailCommand({
|
||||
Content: {
|
||||
Raw: {
|
||||
Data: Buffer.from(rawEmail),
|
||||
},
|
||||
},
|
||||
ConfigurationSetName: configurationSetName,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await sesClient.send(command);
|
||||
console.log("Email with attachments sent! Message ID:", response.MessageId);
|
||||
return response.MessageId;
|
||||
} catch (error) {
|
||||
console.error("Failed to send email with attachments", error);
|
||||
throw new Error("Failed to send email with attachments");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addWebhookConfiguration(
|
||||
configName: string,
|
||||
topicArn: string,
|
||||
|
@@ -19,6 +19,14 @@ const route = createRoute({
|
||||
subject: z.string(),
|
||||
text: z.string().optional(),
|
||||
html: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { EmailContent } from "~/types";
|
||||
import { db } from "../db";
|
||||
import { sendEmailThroughSes } from "../aws/ses";
|
||||
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
|
||||
import { APP_SETTINGS } from "~/utils/constants";
|
||||
|
||||
export async function sendEmail(
|
||||
emailContent: EmailContent & { teamId: number }
|
||||
) {
|
||||
const { to, from, subject, text, html, teamId } = emailContent;
|
||||
const { to, from, subject, text, html, teamId, attachments } = emailContent;
|
||||
|
||||
const fromDomain = from.split("@")[1];
|
||||
|
||||
@@ -24,18 +24,33 @@ export async function sendEmail(
|
||||
throw new Error("Domain is not verified");
|
||||
}
|
||||
|
||||
const messageId = await sendEmailThroughSes({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
region: domain.region,
|
||||
configurationSetName: getConfigurationSetName(
|
||||
domain.clickTracking,
|
||||
domain.openTracking
|
||||
),
|
||||
});
|
||||
const messageId = attachments
|
||||
? await sendEmailWithAttachments({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
region: domain.region,
|
||||
configurationSetName: getConfigurationSetName(
|
||||
domain.clickTracking,
|
||||
domain.openTracking
|
||||
),
|
||||
attachments,
|
||||
})
|
||||
: await sendEmailThroughSes({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
region: domain.region,
|
||||
configurationSetName: getConfigurationSetName(
|
||||
domain.clickTracking,
|
||||
domain.openTracking
|
||||
),
|
||||
attachments,
|
||||
});
|
||||
|
||||
if (messageId) {
|
||||
return await db.email.create({
|
||||
|
@@ -28,8 +28,16 @@ export interface SesMail {
|
||||
}
|
||||
|
||||
export interface SesBounce {
|
||||
bounceType: string;
|
||||
bounceSubType: string;
|
||||
bounceType: "Transient" | "Permanent" | "Undetermined";
|
||||
bounceSubType:
|
||||
| "General"
|
||||
| "NoEmail"
|
||||
| "Suppressed"
|
||||
| "OnAccountSuppressionList "
|
||||
| "MailboxFull"
|
||||
| "MessageTooLarge"
|
||||
| "ContentRejected"
|
||||
| "AttachmentRejected";
|
||||
bouncedRecipients: Array<{
|
||||
emailAddress: string;
|
||||
action: string;
|
||||
@@ -94,7 +102,17 @@ export interface SesRenderingFailure {
|
||||
}
|
||||
|
||||
export interface SesDeliveryDelay {
|
||||
delayType: string;
|
||||
delayType:
|
||||
| "InternalFailure"
|
||||
| "General"
|
||||
| "MailboxFull"
|
||||
| "SpamDetected"
|
||||
| "RecipientServerError"
|
||||
| "IPFailure"
|
||||
| "TransientCommunicationFailure"
|
||||
| "BYOIPHostNameLookupUnavailable"
|
||||
| "Undetermined"
|
||||
| "SendingDeferral";
|
||||
expirationTime: string;
|
||||
delayedRecipients: string[];
|
||||
timestamp: string;
|
||||
|
@@ -4,4 +4,8 @@ export type EmailContent = {
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content: string;
|
||||
}[];
|
||||
};
|
||||
|
Reference in New Issue
Block a user