Update domain page

This commit is contained in:
KMKoushik
2024-04-12 15:27:16 +10:00
parent ffad4050de
commit 3217387373
21 changed files with 794 additions and 107 deletions

View File

@@ -42,6 +42,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"tldts": "^6.1.16",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -111,6 +111,9 @@ model Domain {
publicKey String publicKey String
dkimStatus String? dkimStatus String?
spfDetails String? spfDetails String?
dmarcAdded Boolean @default(false)
errorMessage String?
subdomain String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)

View File

@@ -0,0 +1,94 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import { Label } from "@unsend/ui/src/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { Domain } from "@prisma/client";
import { useRouter } from "next/navigation";
import { toast } from "@unsend/ui/src/toaster";
export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
const utils = api.useUtils();
const router = useRouter();
function handleSave() {
deleteDomainMutation.mutate(
{
id: domain.id,
},
{
onSuccess: () => {
utils.domain.domains.invalidate();
setOpen(false);
toast.success(`Domain ${domain.name} deleted`);
router.replace("/domains");
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="destructive" className="w-[200px]">
Delete domain
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete domain</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-primary">{domain.name}</span>?
You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Label htmlFor="name" className="text-right">
Type <span className="text-primary">{domain.name}</span> to confirm
</Label>
<Input
id="name"
defaultValue=""
className="mt-2"
onChange={(e) => setDomainName(e.target.value)}
value={domainName}
/>
</div>
<DialogFooter>
<Button
type="submit"
variant="destructive"
onClick={handleSave}
disabled={
deleteDomainMutation.isPending || domainName !== domain.name
}
>
{deleteDomainMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default DeleteDomain;

View File

@@ -2,6 +2,32 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { Domain, DomainStatus } from "@prisma/client";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@unsend/ui/src/breadcrumb";
import { DomainStatusBadge } from "../domain-badge";
import { formatDistanceToNow } from "date-fns";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unsend/ui/src/table";
import { Button } from "@unsend/ui/src/button";
import { CheckIcon, ClipboardCopy } from "lucide-react";
import React from "react";
import { Switch } from "@unsend/ui/src/switch";
import DeleteDomain from "./delete-domain";
import { DkimStatus } from "@aws-sdk/client-sesv2";
import SendTestMail from "./send-test-mail";
export default function DomainItemPage({ export default function DomainItemPage({
params, params,
@@ -17,46 +43,289 @@ export default function DomainItemPage({
{domainQuery.isLoading ? ( {domainQuery.isLoading ? (
<p>Loading...</p> <p>Loading...</p>
) : ( ) : (
<> <div className="flex flex-col gap-8">
<div className="flex flex-col gap-2"> <div className="flex justify-between items-center">
<div className="flex justify-between items-center"> <div className="flex items-center gap-4">
<h1 className="font-bold text-lg">{domainQuery.data?.name}</h1> {/* <div className="flex items-center gap-4">
</div> <h1 className="font-medium text-2xl">{domainQuery.data?.name}</h1>
<span className="text-xs capitalize bg-gray-200 rounded px-2 py-1 w-[80px] text-center"> </div> */}
{domainQuery.data?.status.toLowerCase()} <Breadcrumb>
</span> <BreadcrumbList>
</div> <BreadcrumbItem>
<div className="mt-8 border rounded p-4"> <BreadcrumbLink href="/domains" className="text-lg">
<p className="font-semibold">DNS records</p> Domains
<div className="flex flex-col gap-4 mt-8"> </BreadcrumbLink>
<div className="flex justify-between"> </BreadcrumbItem>
<p>TXT</p> <BreadcrumbSeparator className="text-lg" />
<p>{`unsend._domainkey.${domainQuery.data?.name}`}</p> <BreadcrumbItem>
<p className=" w-[200px] overflow-hidden text-ellipsis">{`p=${domainQuery.data?.publicKey}`}</p> <BreadcrumbPage className="text-lg ">
<p className=" capitalize"> {domainQuery.data?.name}
{domainQuery.data?.dkimStatus?.toLowerCase()} </BreadcrumbPage>
</p> </BreadcrumbItem>
</div> </BreadcrumbList>
<div className="flex justify-between"> </Breadcrumb>
<p>TXT</p>
<p>{`send.${domainQuery.data?.name}`}</p> <div className="">
<p className=" w-[200px] overflow-hidden text-ellipsis text-nowrap">{`"v=spf1 include:amazonses.com ~all"`}</p> <DomainStatusBadge
<p className=" capitalize"> status={domainQuery.data?.status || DomainStatus.NOT_STARTED}
{domainQuery.data?.spfDetails?.toLowerCase()} />
</p>
</div>
<div className="flex justify-between">
<p>MX</p>
<p>{`send.${domainQuery.data?.name}`}</p>
<p className=" w-[200px] overflow-hidden text-ellipsis text-nowrap">{`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}</p>
<p className=" capitalize">
{domainQuery.data?.spfDetails?.toLowerCase()}
</p>
</div> </div>
</div> </div>
{domainQuery.data ? (
<SendTestMail domain={domainQuery.data} />
) : null}
</div> </div>
</>
<div className=" border rounded-lg p-4">
<p className="font-semibold text-xl">DNS records</p>
<Table className="mt-2">
<TableHeader className="">
<TableRow className="">
<TableHead className="rounded-tl-xl">Type</TableHead>
<TableHead>Value</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">TTL</TableHead>
<TableHead className="">Priority</TableHead>
<TableHead className="rounded-tr-xl">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="">MX</TableCell>
<TableCell>
<InputWithCopyButton
value={`mail.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
/>
</TableCell>
<TableCell className="">
<InputWithCopyButton
value={`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}
className="w-[200px] overflow-hidden text-ellipsis text-nowrap"
/>
{/* <div className="w-[200px] overflow-hidden text-ellipsis text-nowrap">
{`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}
</div> */}
</TableCell>
<TableCell className="">Auto</TableCell>
<TableCell className="">10</TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="">TXT</TableCell>
<TableCell>
<InputWithCopyButton
value={`unsend._domainkey.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
/>
</TableCell>
<TableCell className="">
<InputWithCopyButton
value={`p=${domainQuery.data?.publicKey}`}
className="w-[200px] overflow-hidden text-ellipsis"
/>
</TableCell>
<TableCell className="">Auto</TableCell>
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.dkimStatus ?? "NOT_STARTED"}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="">TXT</TableCell>
<TableCell>
<InputWithCopyButton
value={`mail.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
/>
</TableCell>
<TableCell className="">
<InputWithCopyButton
value={`v=spf1 include:amazonses.com ~all`}
className="w-[200px] overflow-hidden text-ellipsis text-nowrap"
/>
</TableCell>
<TableCell className="">Auto</TableCell>
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="">TXT</TableCell>
<TableCell>
<InputWithCopyButton
value={`_dmarc.${domainQuery.data?.subdomain || domainQuery.data?.name}`}
/>
</TableCell>
<TableCell className="">
<InputWithCopyButton
value={`v=DMARC1; p=none;`}
className="w-[200px] overflow-hidden text-ellipsis text-nowrap"
/>
</TableCell>
<TableCell className="">Auto</TableCell>
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={
domainQuery.data?.dmarcAdded ? "SUCCESS" : "NOT_STARTED"
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{domainQuery.data ? (
<DomainSettings domain={domainQuery.data} />
) : null}
</div>
)} )}
</div> </div>
); );
} }
const InputWithCopyButton: React.FC<{ value: string; className?: string }> = ({
value,
className,
}) => {
const [isCopied, setIsCopied] = React.useState(false);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(value);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000); // Reset isCopied to false after 2 seconds
} catch (err) {
console.error("Failed to copy: ", err);
}
};
return (
<div className={"flex gap-2 items-center group"}>
<div className={className}>{value}</div>
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer text-muted-foreground opacity-0 group-hover:opacity-100"
onClick={copyToClipboard}
>
{isCopied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</Button>
</div>
);
};
const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
const updateDomain = api.domain.updateDomain.useMutation();
const utils = api.useUtils();
const [clickTracking, setClickTracking] = React.useState(
domain.clickTracking
);
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
function handleClickTrackingChange() {
setClickTracking(!clickTracking);
updateDomain.mutate(
{ id: domain.id, clickTracking: !clickTracking },
{
onSuccess: () => {
utils.domain.domains.invalidate();
},
}
);
}
function handleOpenTrackingChange() {
setOpenTracking(!openTracking);
updateDomain.mutate(
{ id: domain.id, openTracking: !openTracking },
{
onSuccess: () => {
utils.domain.domains.invalidate();
},
}
);
}
return (
<div className="rounded-lg p-4 border flex flex-col gap-6">
<p className="font-semibold text-xl">Settings</p>
<div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div>
<p className=" text-muted-foreground text-sm">
Track any links in your emails content.{" "}
</p>
<Switch
checked={clickTracking}
onCheckedChange={handleClickTrackingChange}
className="data-[state=checked]:bg-emerald-500"
/>
</div>
<div className="flex flex-col gap-1">
<div className="font-semibold">Open tracking</div>
<p className=" text-muted-foreground text-sm">
Unsend adds a tracking pixel to every email you send. This allows you
to see how many people open your emails. This will affect the delivery
rate of your emails.
</p>
<Switch
checked={openTracking}
onCheckedChange={handleOpenTrackingChange}
className="data-[state=checked]:bg-emerald-500"
/>
</div>
<div className="flex flex-col gap-2">
<p className="font-semibold text-xl mt-2 text-destructive">Danger</p>
<p className="text-destructive text-sm font-semibold">
Deleting a domain will remove all of its DNS records and stop sending
emails.
</p>
<DeleteDomain domain={domain} />
</div>
</div>
);
};
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
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 min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
>
<span className="text-xs">
{status.split("_").join(" ").toLowerCase()}
</span>
</div>
);
};

View File

@@ -0,0 +1,67 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { Input } from "@unsend/ui/src/input";
import { Label } from "@unsend/ui/src/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@unsend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { Domain } from "@prisma/client";
import { useRouter } from "next/navigation";
import { toast } from "@unsend/ui/src/toaster";
import { Send, SendHorizonal } from "lucide-react";
export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
const utils = api.useUtils();
const router = useRouter();
function handleSave() {
deleteDomainMutation.mutate(
{
id: domain.id,
},
{
onSuccess: () => {
utils.domain.domains.invalidate();
setOpen(false);
toast.success(`Domain ${domain.name} deleted`);
router.replace("/domains");
},
}
);
}
return (
<Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button>
<SendHorizonal className="h-4 w-4 mr-2" />
Send test email
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Send test email</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
);
};
export default SendTestMail;

View File

@@ -0,0 +1,34 @@
import { DomainStatus } from "@prisma/client";
export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500/10 text-red-600 border-red-500/20";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
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-[120px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
>
<span className="text-xs">
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
</span>
</div>
);
};

View File

@@ -6,6 +6,8 @@ import Link from "next/link";
import { Switch } from "@unsend/ui/src/switch"; import { Switch } from "@unsend/ui/src/switch";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React from "react"; import React from "react";
import { StatusIndicator } from "./status-indicator";
import { DomainStatusBadge } from "./domain-badge";
export default function DomainsList() { export default function DomainsList() {
const domainsQuery = api.domain.domains.useQuery(); const domainsQuery = api.domain.domains.useQuery();
@@ -72,6 +74,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
</Link> </Link>
<DomainStatusBadge status={domain.status} /> <DomainStatusBadge status={domain.status} />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<p className="text-sm text-muted-foreground">Created at</p> <p className="text-sm text-muted-foreground">Created at</p>
@@ -112,55 +115,3 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
</div> </div>
); );
}; };
const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({ status }) => {
let badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400/10 text-gray-500 border-gray-400/10";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500/10 text-red-800 border-red-600/10";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
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={`border text-center w-[120px] text-sm capitalize rounded-full py-0.5 ${badgeColor}`}
>
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
</div>
);
};
const StatusIndicator: React.FC<{ status: DomainStatus }> = ({ status }) => {
let badgeColor = "bg-gray-400"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow-500";
break;
default:
badgeColor = "bg-gray-400";
}
return <div className={` w-[1px] ${badgeColor} my-1.5 rounded-full`}></div>;
};

View File

@@ -0,0 +1,26 @@
import { DomainStatus } from "@prisma/client";
export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray-400"; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray-400";
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-emerald-500";
break;
case DomainStatus.FAILED:
badgeColor = "bg-red-500";
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow-500";
break;
default:
badgeColor = "bg-gray-400";
}
return <div className={` w-[1px] ${badgeColor} my-1.5 rounded-full`}></div>;
};

View File

@@ -10,7 +10,7 @@ export const NavButton: React.FC<{
}> = ({ href, children }) => { }> = ({ href, children }) => {
const pathname = usePathname(); const pathname = usePathname();
const isActive = pathname === href; const isActive = pathname?.startsWith(href);
return ( return (
<Link <Link

View File

@@ -2,6 +2,7 @@ import "@unsend/ui/styles/globals.css";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { ThemeProvider } from "@unsend/ui/theme-provider"; import { ThemeProvider } from "@unsend/ui/theme-provider";
import { Toaster } from "@unsend/ui/src/toaster";
import { TRPCReactProvider } from "~/trpc/react"; import { TRPCReactProvider } from "~/trpc/react";
@@ -25,6 +26,7 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<body className={`font-sans ${inter.variable}`}> <body className={`font-sans ${inter.variable}`}>
<ThemeProvider attribute="class" defaultTheme="dark"> <ThemeProvider attribute="class" defaultTheme="dark">
<Toaster />
<TRPCReactProvider>{children}</TRPCReactProvider> <TRPCReactProvider>{children}</TRPCReactProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@@ -9,6 +9,7 @@ import {
import { db } from "~/server/db"; import { db } from "~/server/db";
import { import {
createDomain, createDomain,
deleteDomain,
getDomain, getDomain,
updateDomain, updateDomain,
} from "~/server/service/domain-service"; } from "~/server/service/domain-service";
@@ -53,4 +54,11 @@ export const domainRouter = createTRPCRouter({
openTracking: input.openTracking, openTracking: input.openTracking,
}); });
}), }),
deleteDomain: teamProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
await deleteDomain(input.id);
return { success: true };
}),
}); });

View File

@@ -1,15 +1,21 @@
import { addDomain, getDomainIdentity } from "~/server/ses"; import dns from "dns";
import util from "util";
import * as tldts from "tldts";
import * as ses from "~/server/ses";
import { db } from "~/server/db"; import { db } from "~/server/db";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
export async function createDomain(teamId: number, name: string) { export async function createDomain(teamId: number, name: string) {
console.log("Creating domain:", name); const subdomain = tldts.getSubdomain(name);
const publicKey = await addDomain(name); const publicKey = await ses.addDomain(name);
const domain = await db.domain.create({ const domain = await db.domain.create({
data: { data: {
name, name,
publicKey, publicKey,
teamId, teamId,
subdomain,
}, },
}); });
@@ -28,7 +34,10 @@ export async function getDomain(id: number) {
} }
if (domain.status !== "SUCCESS") { if (domain.status !== "SUCCESS") {
const domainIdentity = await getDomainIdentity(domain.name, domain.region); const domainIdentity = await ses.getDomainIdentity(
domain.name,
domain.region
);
const dkimStatus = domainIdentity.DkimAttributes?.Status; const dkimStatus = domainIdentity.DkimAttributes?.Status;
const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus; const spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus;
@@ -36,13 +45,17 @@ export async function getDomain(id: number) {
const verificationStatus = domainIdentity.VerificationStatus; const verificationStatus = domainIdentity.VerificationStatus;
const lastCheckedTime = const lastCheckedTime =
domainIdentity.VerificationInfo?.LastCheckedTimestamp; domainIdentity.VerificationInfo?.LastCheckedTimestamp;
const _dmarcRecord = await getDmarcRecord(domain.name);
const dmarcRecord = _dmarcRecord?.[0]?.[0];
console.log(domainIdentity); console.log(domainIdentity);
console.log(dmarcRecord);
if ( if (
domain.dkimStatus !== dkimStatus || domain.dkimStatus !== dkimStatus ||
domain.spfDetails !== spfDetails || domain.spfDetails !== spfDetails ||
domain.status !== verificationStatus domain.status !== verificationStatus ||
domain.dmarcAdded !== (dmarcRecord ? true : false)
) { ) {
domain = await db.domain.update({ domain = await db.domain.update({
where: { where: {
@@ -52,16 +65,18 @@ export async function getDomain(id: number) {
dkimStatus, dkimStatus,
spfDetails, spfDetails,
status: verificationStatus ?? "NOT_STARTED", status: verificationStatus ?? "NOT_STARTED",
dmarcAdded: dmarcRecord ? true : false,
}, },
}); });
} }
return { return {
...domain, ...domain,
dkimStatus, dkimStatus: dkimStatus?.toString() ?? null,
spfDetails, spfDetails: spfDetails?.toString() ?? null,
verificationError, verificationError: verificationError?.toString() ?? null,
lastCheckedTime, lastCheckedTime,
dmarcAdded: dmarcRecord ? true : false,
}; };
} }
@@ -77,3 +92,33 @@ export async function updateDomain(
data, data,
}); });
} }
export async function deleteDomain(id: number) {
const domain = await db.domain.findUnique({
where: { id },
});
if (!domain) {
throw new Error("Domain not found");
}
const deleted = await ses.deleteDomain(domain.name, domain.region);
if (!deleted) {
throw new Error("Error in deleting domain");
}
return db.domain.delete({
where: { id },
});
}
async function getDmarcRecord(domain: string) {
try {
const dmarcRecord = await dnsResolveTxt(`_dmarc.${domain}`);
return dmarcRecord;
} catch (error) {
console.error("Error fetching DMARC record:", error);
return null; // or handle error as appropriate
}
}

View File

@@ -66,7 +66,7 @@ export async function addDomain(domain: string, region = "us-east-1") {
const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({ const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({
EmailIdentity: domain, EmailIdentity: domain,
MailFromDomain: `send.${domain}`, MailFromDomain: `mail.${domain}`,
}); });
const emailIdentityResponse = await sesClient.send(emailIdentityCommand); const emailIdentityResponse = await sesClient.send(emailIdentityCommand);
@@ -75,12 +75,23 @@ export async function addDomain(domain: string, region = "us-east-1") {
response.$metadata.httpStatusCode !== 200 || response.$metadata.httpStatusCode !== 200 ||
emailIdentityResponse.$metadata.httpStatusCode !== 200 emailIdentityResponse.$metadata.httpStatusCode !== 200
) { ) {
throw new Error("Failed to create email identity"); console.log(response);
console.log(emailIdentityResponse);
throw new Error("Failed to create domain identity");
} }
return publicKey; return publicKey;
} }
export async function deleteDomain(domain: string, region = "us-east-1") {
const sesClient = getSesClient(region);
const command = new DeleteEmailIdentityCommand({
EmailIdentity: domain,
});
const response = await sesClient.send(command);
return response.$metadata.httpStatusCode === 200;
}
export async function getDomainIdentity(domain: string, region = "us-east-1") { export async function getDomainIdentity(domain: string, region = "us-east-1") {
const sesClient = getSesClient(region); const sesClient = getSesClient(region);
const command = new GetEmailIdentityCommand({ const command = new GetEmailIdentityCommand({
@@ -165,7 +176,6 @@ export async function addWebhookConfiguration(
ConfigurationSetName: configName, // required ConfigurationSetName: configName, // required
EventDestinationName: "unsend_destination", // required EventDestinationName: "unsend_destination", // required
EventDestination: { EventDestination: {
// EventDestinationDefinition
Enabled: true, Enabled: true,
MatchingEventTypes: eventTypes, MatchingEventTypes: eventTypes,
SnsDestination: { SnsDestination: {

View File

@@ -1 +1,3 @@
export {}; import { cn } from "./lib/utils";
export { cn };

View File

@@ -38,6 +38,7 @@
"lucide-react": "^0.359.0", "lucide-react": "^0.359.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pnpm": "^8.15.5", "pnpm": "^8.15.5",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7"
} }

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "../lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -17,12 +17,13 @@ const buttonVariants = cva(
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
icon: "bg-transparent hover:bg-transparent hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-9 px-4 ",
sm: "h-9 rounded-md px-3", sm: "h-8 rounded-md px-3",
lg: "h-11 rounded-md px-8", lg: "h-10 rounded-md px-8",
icon: "h-10 w-10", icon: "h-10 w-10",
}, },
}, },

View File

@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}

View File

@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors data-[state=selected]:bg-muted",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,31 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };

29
pnpm-lock.yaml generated
View File

@@ -160,6 +160,9 @@ importers:
superjson: superjson:
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.2.1 version: 2.2.1
tldts:
specifier: ^6.1.16
version: 6.1.16
zod: zod:
specifier: ^3.22.4 specifier: ^3.22.4
version: 3.22.4 version: 3.22.4
@@ -293,6 +296,9 @@ importers:
pnpm: pnpm:
specifier: ^8.15.5 specifier: ^8.15.5
version: 8.15.5 version: 8.15.5
sonner:
specifier: ^1.4.41
version: 1.4.41(react-dom@18.2.0)(react@18.2.0)
tailwind-merge: tailwind-merge:
specifier: ^2.2.2 specifier: ^2.2.2
version: 2.2.2 version: 2.2.2
@@ -4133,7 +4139,7 @@ packages:
enhanced-resolve: 5.16.0 enhanced-resolve: 5.16.0
eslint: 8.57.0 eslint: 8.57.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0)(eslint@8.57.0) eslint-plugin-import: 2.29.1(eslint@8.57.0)
fast-glob: 3.3.2 fast-glob: 3.3.2
get-tsconfig: 4.7.3 get-tsconfig: 4.7.3
is-core-module: 2.13.1 is-core-module: 2.13.1
@@ -6323,6 +6329,16 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: true dev: true
/sonner@1.4.41(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-uG511ggnnsw6gcn/X+YKkWPo5ep9il9wYi3QJxHsYe7yTZ4+cOd1wuodOUmOpFuXL+/RE3R04LczdNCDygTDgQ==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/sort-object-keys@1.1.3: /sort-object-keys@1.1.3:
resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==}
dev: true dev: true
@@ -6591,6 +6607,17 @@ packages:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
/tldts-core@6.1.16:
resolution: {integrity: sha512-rxnuCux+zn3hMF57nBzr1m1qGZH7Od2ErbDZjVm04fk76cEynTg3zqvHjx5BsBl8lvRTjpzIhsEGMHDH/Hr2Vw==}
dev: false
/tldts@6.1.16:
resolution: {integrity: sha512-X6VrQzW4RymhI1kBRvrWzYlRLXTftZpi7/s/9ZlDILA04yM2lNX7mBvkzDib9L4uSymHt8mBbeaielZMdsAkfQ==}
hasBin: true
dependencies:
tldts-core: 6.1.16
dev: false
/to-fast-properties@2.0.0: /to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}