Add ses hook parser to capture all the events

This commit is contained in:
KMKoushik
2024-04-16 14:04:30 +10:00
parent 293277ed32
commit 9465960f0a
22 changed files with 712 additions and 105 deletions

View File

@@ -40,6 +40,7 @@
"prisma": "^5.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"recharts": "^2.12.5",
"server-only": "^0.0.1",
"superjson": "^2.2.1",
"tldts": "^6.1.16",

View File

@@ -137,14 +137,27 @@ model ApiKey {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
enum EmailStatus {
SENT
OPENED
CLICKED
BOUNCED
COMPLAINED
DELIVERED
REJECTED
RENDERING_FAILURE
DELIVERY_DELAYED
}
model Email {
id String @id
id String @id @default(cuid())
sesEmailId String? @unique
to String
from String
subject String
text String?
html String?
latestStatus String?
latestStatus EmailStatus @default(SENT)
teamId Int
domainId Int?
createdAt DateTime @default(now())
@@ -155,10 +168,10 @@ model Email {
model EmailEvent {
emailId String
status String
status EmailStatus
data Json?
createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
email Email @relation(fields: [emailId], references: [id], onDelete: Cascade)
@@unique([emailId, status])
}

View File

@@ -15,12 +15,15 @@ import {
import { api } from "~/trpc/react";
import { useState } from "react";
import { CheckIcon, ClipboardCopy, Plus } from "lucide-react";
import { toast } from "@unsend/ui/src/toaster";
export default function AddApiKey() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState("");
const addDomainMutation = api.apiKey.createToken.useMutation();
const [isCopied, setIsCopied] = useState(false);
const utils = api.useUtils();
@@ -41,9 +44,18 @@ export default function AddApiKey() {
function handleCopy() {
navigator.clipboard.writeText(apiKey);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
}
function copyAndClose() {
handleCopy();
setApiKey("");
setName("");
setOpen(false);
toast.success("API key copied to clipboard");
}
return (
@@ -52,18 +64,34 @@ export default function AddApiKey() {
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>Add API Key</Button>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add API Key
</Button>
</DialogTrigger>
{apiKey ? (
<DialogContent>
<DialogHeader>
<DialogTitle>Copy API key</DialogTitle>
</DialogHeader>
<div className="py-2 bg-gray-200 rounded-lg">{apiKey}</div>
<div className="py-2 bg-muted rounded-lg px-2 flex items-center justify-between">
<p>{apiKey}</p>
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</Button>
</div>
<DialogFooter>
<Button
type="submit"
onClick={handleCopy}
onClick={copyAndClose}
disabled={addDomainMutation.isPending}
>
Close

View File

@@ -1,5 +1,14 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { api } from "~/trpc/react";
@@ -8,18 +17,35 @@ export default function ApiList() {
return (
<div className="mt-10">
<div className="flex flex-col gap-2">
{!apiKeysQuery.isLoading && apiKeysQuery.data?.length ? (
apiKeysQuery.data?.map((apiKey) => (
<div className="p-2 px-4 border rounded-lg flex justify-between">
<p>{apiKey.name}</p>
<p>{apiKey.permission}</p>
<p>{apiKey.partialToken}</p>
</div>
))
) : (
<div>No API keys added</div>
)}
<div className="border rounded-xl">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Token</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Last used</TableHead>
<TableHead className="rounded-tr-xl">Created at</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeysQuery.data?.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell>{apiKey.name}</TableCell>
<TableCell>{apiKey.partialToken}</TableCell>
<TableCell>{apiKey.permission}</TableCell>
<TableCell>
{apiKey.lastUsed
? formatDistanceToNow(apiKey.lastUsed)
: "Never"}
</TableCell>
<TableCell>
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);

View File

@@ -0,0 +1,92 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
const data = [
{
name: "Page A",
uv: 4000,
pv: 2400,
amt: 2400,
},
{
name: "Page B",
uv: 3000,
pv: 1398,
amt: 2210,
},
{
name: "Page C",
uv: 2000,
pv: 9800,
amt: 2290,
},
{
name: "Page D",
uv: 2780,
pv: 3908,
amt: 2000,
},
{
name: "Page E",
uv: 1890,
pv: 4800,
amt: 2181,
},
{
name: "Page F",
uv: 2390,
pv: 3800,
amt: 2500,
},
{
name: "Page G",
uv: 3490,
pv: 4300,
amt: 2100,
},
];
export default function DashboardChart() {
return (
<AreaChart
width={900}
height={250}
data={data}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="uv"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
/>
<Area
type="monotone"
dataKey="pv"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorPv)"
/>
</AreaChart>
);
}

View File

@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import {
Bell,
@@ -23,7 +25,15 @@ import {
} from "@unsend/ui/src/dropdown-menu";
import { Input } from "@unsend/ui/src/input";
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
import DashboardChart from "./dashboard-chart";
export default function Dashboard() {
return <div>Hello</div>;
return (
<div>
Dashboard
<div className="mx-auto flex justify-center item-center mt-[30vh]">
<DashboardChart />
</div>
</div>
);
}

View File

@@ -49,7 +49,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="destructive" className="w-[200px]">
<Button variant="destructive" className="w-[150px]" size="sm">
Delete domain
</Button>
</DialogTrigger>

View File

@@ -287,7 +287,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
</div>
<div className="flex flex-col gap-2">
<p className="font-semibold text-xl mt-2 text-destructive">Danger</p>
<p className="font-semibold text-lg text-destructive">Danger</p>
<p className="text-destructive text-sm font-semibold">
Deleting a domain will stop sending emails with this domain.

View File

@@ -15,6 +15,7 @@ import {
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
export default function AddDomain() {
const [open, setOpen] = useState(false);
@@ -43,7 +44,10 @@ export default function AddDomain() {
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>Add domain</Button>
<Button>
<Plus className="h-4 w-4 mr-1" />
Add domain
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>

View File

@@ -20,7 +20,7 @@ export default function DomainsList() {
<DomainItem key={domain.id} domain={domain} />
))
) : (
<div>No domains</div>
<div className="text-center mt-20">No domains Added</div>
)}
</div>
</div>

View File

@@ -21,6 +21,7 @@ import {
MailX,
} from "lucide-react";
import { formatDistance, formatDistanceToNow } from "date-fns";
import { EmailStatus } from "@prisma/client";
export default function EmailsList() {
const emailsQuery = api.email.emails.useQuery();
@@ -65,40 +66,40 @@ export default function EmailsList() {
);
}
const EmailIcon: React.FC<{ status: string }> = ({ status }) => {
const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
switch (status) {
case "Send":
case "SENT":
return (
// <div className="border border-gray-400/60 p-2 rounded-lg bg-gray-400/10">
<Mail className="w-6 h-6 text-gray-500 " />
// </div>
);
case "Delivery":
case "Delayed":
case "DELIVERED":
return (
// <div className="border border-emerald-600/60 p-2 rounded-lg bg-emerald-500/10">
<MailCheck className="w-6 h-6 text-emerald-800" />
// </div>
);
case "Bounced":
case "BOUNCED":
return (
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
<MailX className="w-6 h-6 text-red-900" />
// </div>
);
case "Clicked":
case "CLICKED":
return (
// <div className="border border-cyan-600/60 p-2 rounded-lg bg-cyan-500/10">
<MailSearch className="w-6 h-6 text-cyan-700" />
// </div>
);
case "Opened":
case "OPENED":
return (
// <div className="border border-indigo-600/60 p-2 rounded-lg bg-indigo-500/10">
<MailOpen className="w-6 h-6 text-indigo-700" />
// </div>
);
case "Complained":
case "DELIVERY_DELAYED":
case "COMPLAINED":
return (
// <div className="border border-yellow-600/60 p-2 rounded-lg bg-yellow-500/10">
<MailWarning className="w-6 h-6 text-yellow-700" />
@@ -113,26 +114,27 @@ const EmailIcon: React.FC<{ status: string }> = ({ status }) => {
}
};
const EmailStatusBadge: React.FC<{ status: string }> = ({ 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 "Send":
case "SENT":
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case "Delivery":
case "Delayed":
case "DELIVERED":
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case "Bounced":
case "BOUNCED":
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
break;
case "Clicked":
case "CLICKED":
badgeColor = "bg-cyan-500/10 text-cyan-600 border-cyan-600/10";
break;
case "Opened":
case "OPENED":
badgeColor = "bg-indigo-500/10 text-indigo-600 border-indigo-600/10";
break;
case "Complained":
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:
@@ -141,9 +143,9 @@ const EmailStatusBadge: React.FC<{ status: string }> = ({ status }) => {
return (
<div
className={`border text-center w-[120px] rounded-full py-0.5 ${badgeColor}`}
className={` text-center w-[130px] rounded capitalize py-0.5 text-xs ${badgeColor}`}
>
{status}
{status.toLowerCase().split("_").join(" ")}
</div>
);
};

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import {
Bell,
BellRing,
CircleUser,
Globe,
Home,
@@ -10,6 +11,7 @@ import {
LineChart,
Mail,
Menu,
MessageSquareMore,
Package,
Package2,
Search,
@@ -85,6 +87,16 @@ export default async function AuthenticatedDashboardLayout({
Domains
</NavButton>
<NavButton href="/sms" comingSoon>
<MessageSquareMore className="h-4 w-4" />
SMS
</NavButton>
<NavButton href="/sms" comingSoon>
<BellRing className="h-4 w-4" />
Push notification
</NavButton>
<NavButton href="/api-keys">
<KeyRound className="h-4 w-4" />
API keys

View File

@@ -7,15 +7,31 @@ import React from "react";
export const NavButton: React.FC<{
href: string;
children: React.ReactNode;
}> = ({ href, children }) => {
comingSoon?: boolean;
}> = ({ href, children, comingSoon }) => {
const pathname = usePathname();
const isActive = pathname?.startsWith(href);
if (comingSoon) {
return (
<div className="flex items-center justify-between hover:text-primary cursor-not-allowed mt-1">
<div
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
>
{children}
</div>
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
soon
</div>
</div>
);
}
return (
<Link
href={href}
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
className={`flex items-center mt-1 gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
>
{children}
</Link>

View File

@@ -1,6 +1,7 @@
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) {
console.log("GET", req);
@@ -10,67 +11,26 @@ export async function GET(req: Request) {
export async function POST(req: Request) {
const data = await req.json();
console.log(data, data.Message);
if (data.Type === "SubscriptionConfirmation") {
return handleSubscription(data);
}
console.log(data, data.Message);
let message = null;
try {
message = JSON.parse(data.Message || "{}");
const status = await parseSesHook(message);
if (!status) {
return Response.json({ data: "Error is parsing hook" }, { status: 400 });
}
return Response.json({ data: "Success" });
} catch (e) {
console.log(e);
console.error(e);
return Response.json({ data: "Error is parsing hook" }, { status: 400 });
}
const emailId = message?.mail.messageId;
console.log(emailId, message);
if (!emailId) {
return Response.json({ data: "Email not found" });
}
const email = await db.email.findUnique({
where: {
id: emailId,
},
});
if (!email || !message.mail) {
return Response.json({ data: "Email not found" });
}
console.log("FOund email", email);
await db.email.update({
where: {
id: email.id,
},
data: {
latestStatus: message.eventType,
},
});
await db.emailEvent.upsert({
where: {
emailId_status: {
emailId,
status: message.eventType,
},
},
update: {
data: message[message.eventType.toLowerCase()],
},
create: {
emailId,
status: message.eventType,
data: message[message.eventType.toLowerCase()],
},
});
return Response.json({ data: "Hello" });
}
async function handleSubscription(message: any) {
@@ -78,5 +38,5 @@ async function handleSubscription(message: any) {
method: "GET",
});
return Response.json({ data: "Hello" });
return Response.json({ data: "Success" });
}

View File

@@ -34,6 +34,7 @@ export const apiRouter = createTRPCRouter({
permission: true,
partialToken: true,
lastUsed: true,
createdAt: true,
},
});

View File

@@ -4,7 +4,7 @@ import { APP_SETTINGS } from "~/utils/constants";
import { createTopic, subscribeEndpoint } from "./sns";
import { env } from "~/env";
import { AppSettingsService } from "~/server/service/app-settings-service";
import { addWebhookConfiguration } from "../ses";
import { addWebhookConfiguration } from "./ses";
import { EventType } from "@aws-sdk/client-sesv2";
const GENERAL_EVENTS: EventType[] = [

View File

@@ -1,7 +1,7 @@
import dns from "dns";
import util from "util";
import * as tldts from "tldts";
import * as ses from "~/server/ses";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
const dnsResolveTxt = util.promisify(dns.resolveTxt);

View File

@@ -1,6 +1,6 @@
import { EmailContent } from "~/types";
import { db } from "../db";
import { sendEmailThroughSes } from "../ses";
import { sendEmailThroughSes } from "../aws/ses";
import { APP_SETTINGS } from "~/utils/constants";
export async function sendEmail(
@@ -45,7 +45,7 @@ export async function sendEmail(
subject,
text,
html,
id: messageId,
sesEmailId: messageId,
teamId,
domainId: domain.id,
},

View File

@@ -0,0 +1,91 @@
import { EmailStatus } from "@prisma/client";
import { SesEvent, SesEventDataKey, SesEventType } from "~/types/aws-types";
import { db } from "../db";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
if (!mailStatus) {
console.error("Unknown email status", data);
return false;
}
const sesEmailId = data.mail.messageId;
const mailData = getEmailData(data);
const email = await db.email.findUnique({
where: {
sesEmailId,
},
});
if (!email) {
console.error("Email not found", data);
return false;
}
await db.email.update({
where: {
id: email.id,
},
data: {
latestStatus: mailStatus,
},
});
await db.emailEvent.upsert({
where: {
emailId_status: {
emailId: email.id,
status: mailStatus,
},
},
update: {
data: mailData as any,
},
create: {
emailId: email.id,
status: mailStatus,
data: mailData as any,
},
});
return true;
}
function getEmailStatus(data: SesEvent) {
const { eventType } = data;
if (eventType === "Send") {
return EmailStatus.SENT;
} else if (eventType === "Delivery") {
return EmailStatus.DELIVERED;
} else if (eventType === "Bounce") {
return EmailStatus.BOUNCED;
} else if (eventType === "Complaint") {
return EmailStatus.COMPLAINED;
} else if (eventType === "Reject") {
return EmailStatus.REJECTED;
} else if (eventType === "Open") {
return EmailStatus.OPENED;
} else if (eventType === "Click") {
return EmailStatus.CLICKED;
} else if (eventType === "Rendering Failure") {
return EmailStatus.RENDERING_FAILURE;
} else if (eventType === "DeliveryDelay") {
return EmailStatus.DELIVERY_DELAYED;
}
}
function getEmailData(data: SesEvent) {
const { eventType } = data;
if (eventType === "Rendering Failure") {
return data.renderingFailure;
} else if (eventType === "DeliveryDelay") {
return data.deliveryDelay;
} else {
return data[eventType.toLowerCase() as SesEventDataKey];
}
}

View File

@@ -0,0 +1,138 @@
export interface SnsNotificationMessage {
Type: string;
MessageId: string;
TopicArn: string;
Subject?: string;
Message: string; // This is a JSON string that needs to be parsed into one of the SES event types below
Timestamp: string;
SignatureVersion: string;
Signature: string;
SigningCertURL: string;
UnsubscribeURL: string;
}
export interface SesMail {
timestamp: string;
source: string;
messageId: string;
destination: string[];
headersTruncated: boolean;
headers: Array<{ name: string; value: string }>;
commonHeaders: {
from: string[];
to: string[];
messageId: string;
subject?: string;
};
tags: { [key: string]: string[] };
}
export interface SesBounce {
bounceType: string;
bounceSubType: string;
bouncedRecipients: Array<{
emailAddress: string;
action: string;
status: string;
diagnosticCode?: string;
}>;
timestamp: string;
feedbackId: string;
reportingMTA: string;
}
export interface SesComplaint {
complainedRecipients: Array<{
emailAddress: string;
}>;
timestamp: string;
feedbackId: string;
complaintFeedbackType: string;
userAgent: string;
complaintSubType?: string;
arrivedDate?: string;
}
export interface SesDelivery {
timestamp: string;
processingTimeMillis: number;
recipients: string[];
smtpResponse: string;
reportingMTA: string;
remoteMtaIp?: string;
}
export interface SesSend {
timestamp: string;
smtpResponse: string;
reportingMTA: string;
recipients: string[];
}
export interface SesReject {
reason: string;
timestamp: string;
}
export interface SesOpen {
ipAddress: string;
timestamp: string;
userAgent: string;
}
export interface SesClick {
ipAddress: string;
timestamp: string;
userAgent: string;
link: string;
linkTags: { [key: string]: string };
}
export interface SesRenderingFailure {
errorMessage: string;
templateName: string;
}
export interface SesDeliveryDelay {
delayType: string;
expirationTime: string;
delayedRecipients: string[];
timestamp: string;
}
export type SesEventType =
| "Bounce"
| "Complaint"
| "Delivery"
| "Send"
| "Reject"
| "Open"
| "Click"
| "Rendering Failure"
| "DeliveryDelay";
export type SesEventDataKey =
| "bounce"
| "complaint"
| "delivery"
| "send"
| "reject"
| "open"
| "click"
| "renderingFailure"
| "deliveryDelay";
export interface SesEvent {
eventType: SesEventType;
mail: SesMail;
bounce?: SesBounce;
complaint?: SesComplaint;
delivery?: SesDelivery;
send?: SesSend;
reject?: SesReject;
open?: SesOpen;
click?: SesClick;
renderingFailure?: SesRenderingFailure;
deliveryDelay?: SesDeliveryDelay;
// Additional fields for other event types can be added here
}