From 32173873732fe562b38a85c308256355caaf008c Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Fri, 12 Apr 2024 15:27:16 +1000 Subject: [PATCH] Update domain page --- apps/web/package.json | 1 + apps/web/prisma/schema.prisma | 3 + .../domains/[domainId]/delete-domain.tsx | 94 +++++ .../(dashboard)/domains/[domainId]/page.tsx | 341 ++++++++++++++++-- .../domains/[domainId]/send-test-mail.tsx | 67 ++++ .../app/(dashboard)/domains/domain-badge.tsx | 34 ++ .../app/(dashboard)/domains/domain-list.tsx | 55 +-- .../(dashboard)/domains/status-indicator.tsx | 26 ++ apps/web/src/app/(dashboard)/nav-button.tsx | 2 +- apps/web/src/app/layout.tsx | 2 + apps/web/src/server/api/routers/domain.ts | 8 + apps/web/src/server/service/domain-service.ts | 61 +++- apps/web/src/server/ses.ts | 16 +- packages/ui/index.ts | 4 +- packages/ui/package.json | 1 + packages/ui/src/breadcrumb.tsx | 115 ++++++ packages/ui/src/button.tsx | 7 +- packages/ui/src/input.tsx | 2 +- packages/ui/src/table.tsx | 2 +- packages/ui/src/toaster.tsx | 31 ++ pnpm-lock.yaml | 29 +- 21 files changed, 794 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/domains/[domainId]/delete-domain.tsx create mode 100644 apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx create mode 100644 apps/web/src/app/(dashboard)/domains/domain-badge.tsx create mode 100644 apps/web/src/app/(dashboard)/domains/status-indicator.tsx create mode 100644 packages/ui/src/breadcrumb.tsx create mode 100644 packages/ui/src/toaster.tsx diff --git a/apps/web/package.json b/apps/web/package.json index b7e18c4..3997852 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "react-dom": "18.2.0", "server-only": "^0.0.1", "superjson": "^2.2.1", + "tldts": "^6.1.16", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index f19661d..d5bbbcf 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -111,6 +111,9 @@ model Domain { publicKey String dkimStatus String? spfDetails String? + dmarcAdded Boolean @default(false) + errorMessage String? + subdomain String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/delete-domain.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/delete-domain.tsx new file mode 100644 index 0000000..b37373a --- /dev/null +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/delete-domain.tsx @@ -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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Delete domain + + Are you sure you want to delete{" "} + {domain.name}? + You can't reverse this. + + +
+ + setDomainName(e.target.value)} + value={domainName} + /> +
+ + + +
+
+ ); +}; + +export default DeleteDomain; diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx index 9e726b9..4340026 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -2,6 +2,32 @@ import type { Metadata } from "next"; 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({ params, @@ -17,46 +43,289 @@ export default function DomainItemPage({ {domainQuery.isLoading ? (

Loading...

) : ( - <> -
-
-

{domainQuery.data?.name}

-
- - {domainQuery.data?.status.toLowerCase()} - -
-
-

DNS records

-
-
-

TXT

-

{`unsend._domainkey.${domainQuery.data?.name}`}

-

{`p=${domainQuery.data?.publicKey}`}

-

- {domainQuery.data?.dkimStatus?.toLowerCase()} -

-
-
-

TXT

-

{`send.${domainQuery.data?.name}`}

-

{`"v=spf1 include:amazonses.com ~all"`}

-

- {domainQuery.data?.spfDetails?.toLowerCase()} -

-
-
-

MX

-

{`send.${domainQuery.data?.name}`}

-

{`feedback-smtp.${domainQuery.data?.region}.amazonses.com`}

-

- {domainQuery.data?.spfDetails?.toLowerCase()} -

+
+
+
+ {/*
+

{domainQuery.data?.name}

+
*/} + + + + + Domains + + + + + + {domainQuery.data?.name} + + + + + +
+
+ {domainQuery.data ? ( + + ) : null}
- + +
+

DNS records

+ + + + Type + Value + Status + TTL + Priority + Status + + + + + MX + + + + + + {/*
+ {`feedback-smtp.${domainQuery.data?.region}.amazonses.com`} +
*/} +
+ Auto + 10 + + + +
+ + TXT + + + + + + + Auto + + + + + + + TXT + + + + + + + Auto + + + + + + + TXT + + + + + + + Auto + + + + + +
+
+
+ {domainQuery.data ? ( + + ) : null} +
)}
); } + +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 ( +
+
{value}
+ +
+ ); +}; + +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 ( +
+

Settings

+
+
Click tracking
+

+ Track any links in your emails content.{" "} +

+ +
+ +
+
Open tracking
+

+ 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. +

+ +
+ +
+

Danger

+ +

+ Deleting a domain will remove all of its DNS records and stop sending + emails. +

+ +
+
+ ); +}; + +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 ( +
+ + {status.split("_").join(" ").toLowerCase()} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx new file mode 100644 index 0000000..24e6006 --- /dev/null +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/send-test-mail.tsx @@ -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 ( + (_open !== open ? setOpen(_open) : null)} + > + + + + + + Send test email + + + + ); +}; + +export default SendTestMail; diff --git a/apps/web/src/app/(dashboard)/domains/domain-badge.tsx b/apps/web/src/app/(dashboard)/domains/domain-badge.tsx new file mode 100644 index 0000000..78e3e67 --- /dev/null +++ b/apps/web/src/app/(dashboard)/domains/domain-badge.tsx @@ -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 ( +
+ + {status === "SUCCESS" ? "Verified" : status.toLowerCase()} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/domains/domain-list.tsx b/apps/web/src/app/(dashboard)/domains/domain-list.tsx index 3552199..f2cc993 100644 --- a/apps/web/src/app/(dashboard)/domains/domain-list.tsx +++ b/apps/web/src/app/(dashboard)/domains/domain-list.tsx @@ -6,6 +6,8 @@ import Link from "next/link"; import { Switch } from "@unsend/ui/src/switch"; import { api } from "~/trpc/react"; import React from "react"; +import { StatusIndicator } from "./status-indicator"; +import { DomainStatusBadge } from "./domain-badge"; export default function DomainsList() { const domainsQuery = api.domain.domains.useQuery(); @@ -72,6 +74,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
+

Created at

@@ -112,55 +115,3 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
); }; - -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 ( -
- {status === "SUCCESS" ? "Verified" : status.toLowerCase()} -
- ); -}; - -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
; -}; diff --git a/apps/web/src/app/(dashboard)/domains/status-indicator.tsx b/apps/web/src/app/(dashboard)/domains/status-indicator.tsx new file mode 100644 index 0000000..3aa8353 --- /dev/null +++ b/apps/web/src/app/(dashboard)/domains/status-indicator.tsx @@ -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
; +}; diff --git a/apps/web/src/app/(dashboard)/nav-button.tsx b/apps/web/src/app/(dashboard)/nav-button.tsx index 938a1bc..3855092 100644 --- a/apps/web/src/app/(dashboard)/nav-button.tsx +++ b/apps/web/src/app/(dashboard)/nav-button.tsx @@ -10,7 +10,7 @@ export const NavButton: React.FC<{ }> = ({ href, children }) => { const pathname = usePathname(); - const isActive = pathname === href; + const isActive = pathname?.startsWith(href); return ( + {children} diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts index b1f73d9..9abdd28 100644 --- a/apps/web/src/server/api/routers/domain.ts +++ b/apps/web/src/server/api/routers/domain.ts @@ -9,6 +9,7 @@ import { import { db } from "~/server/db"; import { createDomain, + deleteDomain, getDomain, updateDomain, } from "~/server/service/domain-service"; @@ -53,4 +54,11 @@ export const domainRouter = createTRPCRouter({ openTracking: input.openTracking, }); }), + + deleteDomain: teamProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ ctx, input }) => { + await deleteDomain(input.id); + return { success: true }; + }), }); diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 852cf89..ead67c5 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -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"; +const dnsResolveTxt = util.promisify(dns.resolveTxt); + export async function createDomain(teamId: number, name: string) { - console.log("Creating domain:", name); - const publicKey = await addDomain(name); + const subdomain = tldts.getSubdomain(name); + const publicKey = await ses.addDomain(name); const domain = await db.domain.create({ data: { name, publicKey, teamId, + subdomain, }, }); @@ -28,7 +34,10 @@ export async function getDomain(id: number) { } 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 spfDetails = domainIdentity.MailFromAttributes?.MailFromDomainStatus; @@ -36,13 +45,17 @@ export async function getDomain(id: number) { const verificationStatus = domainIdentity.VerificationStatus; const lastCheckedTime = domainIdentity.VerificationInfo?.LastCheckedTimestamp; + const _dmarcRecord = await getDmarcRecord(domain.name); + const dmarcRecord = _dmarcRecord?.[0]?.[0]; console.log(domainIdentity); + console.log(dmarcRecord); if ( domain.dkimStatus !== dkimStatus || domain.spfDetails !== spfDetails || - domain.status !== verificationStatus + domain.status !== verificationStatus || + domain.dmarcAdded !== (dmarcRecord ? true : false) ) { domain = await db.domain.update({ where: { @@ -52,16 +65,18 @@ export async function getDomain(id: number) { dkimStatus, spfDetails, status: verificationStatus ?? "NOT_STARTED", + dmarcAdded: dmarcRecord ? true : false, }, }); } return { ...domain, - dkimStatus, - spfDetails, - verificationError, + dkimStatus: dkimStatus?.toString() ?? null, + spfDetails: spfDetails?.toString() ?? null, + verificationError: verificationError?.toString() ?? null, lastCheckedTime, + dmarcAdded: dmarcRecord ? true : false, }; } @@ -77,3 +92,33 @@ export async function updateDomain( 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 + } +} diff --git a/apps/web/src/server/ses.ts b/apps/web/src/server/ses.ts index b0aac9c..8b9e6de 100644 --- a/apps/web/src/server/ses.ts +++ b/apps/web/src/server/ses.ts @@ -66,7 +66,7 @@ export async function addDomain(domain: string, region = "us-east-1") { const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({ EmailIdentity: domain, - MailFromDomain: `send.${domain}`, + MailFromDomain: `mail.${domain}`, }); const emailIdentityResponse = await sesClient.send(emailIdentityCommand); @@ -75,12 +75,23 @@ export async function addDomain(domain: string, region = "us-east-1") { response.$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; } +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") { const sesClient = getSesClient(region); const command = new GetEmailIdentityCommand({ @@ -165,7 +176,6 @@ export async function addWebhookConfiguration( ConfigurationSetName: configName, // required EventDestinationName: "unsend_destination", // required EventDestination: { - // EventDestinationDefinition Enabled: true, MatchingEventTypes: eventTypes, SnsDestination: { diff --git a/packages/ui/index.ts b/packages/ui/index.ts index cb0ff5c..816b2a0 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1 +1,3 @@ -export {}; +import { cn } from "./lib/utils"; + +export { cn }; diff --git a/packages/ui/package.json b/packages/ui/package.json index 82beaa7..89d9776 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,6 +38,7 @@ "lucide-react": "^0.359.0", "next-themes": "^0.3.0", "pnpm": "^8.15.5", + "sonner": "^1.4.41", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7" } diff --git a/packages/ui/src/breadcrumb.tsx b/packages/ui/src/breadcrumb.tsx new file mode 100644 index 0000000..755e8e9 --- /dev/null +++ b/packages/ui/src/breadcrumb.tsx @@ -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) =>