Improve Self host setup (#30)
* Add self host setup * Improve blunders * Move to bull mq * More changes * Add example code for sending test emails
This commit is contained in:
40
apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx
Normal file
40
apps/web/src/app/(dashboard)/admin/add-ses-configuration.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unsend/ui/src/dialog";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
|
||||
|
||||
export default function AddSesConfiguration() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add SES configuration
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new SES configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<AddSesSettingsForm onSuccess={() => setOpen(false)} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
19
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
19
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import AddSesConfiguration from "./add-ses-configuration";
|
||||
import SesConfigurations from "./ses-configurations";
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-lg">Admin</h1>
|
||||
<AddSesConfiguration />
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<p className="font-semibold mb-4">SES Configurations</p>
|
||||
<SesConfigurations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
65
apps/web/src/app/(dashboard)/admin/ses-configurations.tsx
Normal file
65
apps/web/src/app/(dashboard)/admin/ses-configurations.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@unsend/ui/src/table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { api } from "~/trpc/react";
|
||||
import Spinner from "@unsend/ui/src/spinner";
|
||||
|
||||
export default function SesConfigurations() {
|
||||
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="border rounded-xl">
|
||||
<Table className="">
|
||||
<TableHeader className="">
|
||||
<TableRow className=" bg-muted/30">
|
||||
<TableHead className="rounded-tl-xl">Region</TableHead>
|
||||
<TableHead>Callback URL</TableHead>
|
||||
<TableHead>Callback status</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sesSettingsQuery.isLoading ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<Spinner
|
||||
className="w-6 h-6 mx-auto"
|
||||
innerSvgClass="stroke-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sesSettingsQuery.data?.length === 0 ? (
|
||||
<TableRow className="h-32">
|
||||
<TableCell colSpan={6} className="text-center py-4">
|
||||
<p>No SES configurations added</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sesSettingsQuery.data?.map((sesSetting) => (
|
||||
<TableRow key={sesSetting.id}>
|
||||
<TableCell>{sesSetting.region}</TableCell>
|
||||
<TableCell>{sesSetting.callbackUrl}</TableCell>
|
||||
<TableCell>
|
||||
{sesSetting.callbackSuccess ? "Success" : "Failed"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(sesSetting.createdAt)} ago
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
192
apps/web/src/app/(dashboard)/dasboard-layout.tsx
Normal file
192
apps/web/src/app/(dashboard)/dasboard-layout.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { LogoutButton, NavButton } from "./nav-button";
|
||||
import {
|
||||
BookOpenText,
|
||||
BookUser,
|
||||
CircleUser,
|
||||
Code,
|
||||
Globe,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
LineChart,
|
||||
Mail,
|
||||
Menu,
|
||||
Package,
|
||||
Package2,
|
||||
Server,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { env } from "~/env";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@unsend/ui/src/dropdown-menu";
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full h-full">
|
||||
<div className="hidden bg-muted/20 md:block md:w-[280px]">
|
||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||
<span className=" text-lg">Unsend</span>
|
||||
</Link>
|
||||
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
|
||||
Early access
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
|
||||
<div>
|
||||
<NavButton href="/dashboard">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/emails">
|
||||
<Mail className="h-4 w-4" />
|
||||
Emails
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/domains">
|
||||
<Globe className="h-4 w-4" />
|
||||
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="/api-keys">
|
||||
<Code className="h-4 w-4" />
|
||||
Developer settings
|
||||
</NavButton>
|
||||
{!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? (
|
||||
<NavButton href="/admin">
|
||||
<Server className="h-4 w-4" />
|
||||
Admin
|
||||
</NavButton>
|
||||
) : null}
|
||||
</div>
|
||||
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
|
||||
<Link
|
||||
href="https://docs.unsend.dev"
|
||||
target="_blank"
|
||||
className="flex gap-2 items-center hover:text-primary text-muted-foreground"
|
||||
>
|
||||
<BookOpenText className="h-4 w-4" />
|
||||
<span className="">Docs</span>
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mt-auto p-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="flex flex-col">
|
||||
<nav className="grid gap-2 text-lg font-medium">
|
||||
<Link
|
||||
href="#"
|
||||
className="flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Package2 className="h-6 w-6" />
|
||||
<span className="sr-only">Acme Inc</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Package className="h-5 w-5" />
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
Customers
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LineChart className="h-5 w-5" />
|
||||
Analytics
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="mt-auto"></div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
<CircleUser className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle user menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto h-full">
|
||||
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -26,6 +26,7 @@ import DeleteDomain from "./delete-domain";
|
||||
import SendTestMail from "./send-test-mail";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
import Link from "next/link";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
|
||||
export default function DomainItemPage({
|
||||
params,
|
||||
@@ -245,7 +246,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
{ id: domain.id, clickTracking: !clickTracking },
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
utils.domain.invalidate();
|
||||
toast.success("Click tracking updated");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -257,7 +259,8 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
{ id: domain.id, openTracking: !openTracking },
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.domain.domains.invalidate();
|
||||
utils.domain.invalidate();
|
||||
toast.success("Open tracking updated");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
@@ -14,6 +14,8 @@ import { Domain } from "@prisma/client";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
import { Code } from "@unsend/ui/src/code";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { getSendTestEmailCode } from "~/lib/constants/example-codes";
|
||||
|
||||
const jsCode = `const requestOptions = {
|
||||
method: "POST",
|
||||
@@ -112,6 +114,8 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
const sendTestEmailFromDomainMutation =
|
||||
api.domain.sendTestEmailFromDomain.useMutation();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
function handleSendTestEmail() {
|
||||
@@ -145,12 +149,14 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<DialogTitle>Send test email</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Code
|
||||
codeBlocks={[
|
||||
{ language: "js", code: jsCode },
|
||||
{ language: "ruby", code: rubyCode },
|
||||
{ language: "php", code: phpCode },
|
||||
{ language: "python", code: pythonCode },
|
||||
]}
|
||||
codeBlocks={getSendTestEmailCode({
|
||||
from: `hello@${domain.name}`,
|
||||
to: session?.user?.email || "",
|
||||
subject: "Unsend test email",
|
||||
body: "hello,\\n\\nUnsend is the best open source sending platform",
|
||||
bodyHtml:
|
||||
"<p>hello,</p><p>Unsend is the best open source sending platform<p><p>check out <a href='https://unsend.dev'>unsend.dev</a>",
|
||||
})}
|
||||
codeClassName="max-w-[38rem] h-[20rem]"
|
||||
/>
|
||||
<div className="flex justify-end w-full">
|
||||
|
@@ -27,17 +27,37 @@ import * as tldts from "tldts";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@unsend/ui/src/select";
|
||||
import { toast } from "@unsend/ui/src/toaster";
|
||||
|
||||
const domainSchema = z.object({
|
||||
domain: z.string({ required_error: "Domain is required" }),
|
||||
region: z.string({ required_error: "Region is required" }).min(1, {
|
||||
message: "Region is required",
|
||||
}),
|
||||
domain: z.string({ required_error: "Domain is required" }).min(1, {
|
||||
message: "Domain is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export default function AddDomain() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const regionQuery = api.domain.getAvailableRegions.useQuery();
|
||||
|
||||
const addDomainMutation = api.domain.createDomain.useMutation();
|
||||
|
||||
const domainForm = useForm<z.infer<typeof domainSchema>>({
|
||||
resolver: zodResolver(domainSchema),
|
||||
defaultValues: {
|
||||
region: "",
|
||||
domain: "",
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -56,6 +76,7 @@ export default function AddDomain() {
|
||||
addDomainMutation.mutate(
|
||||
{
|
||||
name: values.domain,
|
||||
region: values.region,
|
||||
},
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
@@ -63,6 +84,9 @@ export default function AddDomain() {
|
||||
await router.push(`/domains/${data.id}`);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -108,6 +132,41 @@ export default function AddDomain() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={domainForm.control}
|
||||
name="region"
|
||||
render={({ field, formState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={regionQuery.isLoading}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{regionQuery.data?.map((region) => (
|
||||
<SelectItem value={region} key={region}>
|
||||
{region}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.errors.region ? (
|
||||
<FormMessage />
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select the region from where the email is sent{" "}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className=" w-[100px] bg-white hover:bg-gray-100 focus:bg-gray-100"
|
||||
|
@@ -95,9 +95,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Region</p>
|
||||
|
||||
<p className="text-sm flex items-center gap-2">
|
||||
<span className="text-2xl">🇺🇸</span> {domain.region}
|
||||
</p>
|
||||
<p className="text-sm flex items-center gap-2">{domain.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
@@ -1,14 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { UAParser } from "ua-parser-js";
|
||||
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 { SesBounce, SesDeliveryDelay } from "~/types/aws-types";
|
||||
import {
|
||||
SesBounce,
|
||||
SesClick,
|
||||
SesComplaint,
|
||||
SesDeliveryDelay,
|
||||
SesOpen,
|
||||
} from "~/types/aws-types";
|
||||
import {
|
||||
BOUNCE_ERROR_MESSAGES,
|
||||
COMPLAINT_ERROR_MESSAGES,
|
||||
DELIVERY_DELAY_ERRORS,
|
||||
} from "~/lib/constants/ses-errors";
|
||||
|
||||
@@ -39,7 +47,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
<span className="w-[65px] text-muted-foreground ">Subject</span>
|
||||
<span>{emailQuery.data?.subject}</span>
|
||||
</div>
|
||||
<div className=" dark:bg-slate-200 h-[300px] overflow-auto text-black rounded">
|
||||
<div className=" dark:bg-slate-200 h-[250px] overflow-auto text-black rounded">
|
||||
<div
|
||||
className="px-4 py-4 overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: emailQuery.data?.html ?? "" }}
|
||||
@@ -47,17 +55,20 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className=" border rounded-lg w-full ">
|
||||
<div className=" p-4 flex flex-col gap-8">
|
||||
<div className=" p-4 flex flex-col gap-8 w-full">
|
||||
<div className="font-medium">Events History</div>
|
||||
<div className="flex items-stretch px-4">
|
||||
<div className="flex items-stretch px-4 w-full">
|
||||
<div className="border-r border-dashed" />
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-12 w-full">
|
||||
{emailQuery.data?.emailEvents.map((evt) => (
|
||||
<div key={evt.status} className="flex gap-5 items-start">
|
||||
<div
|
||||
key={evt.status}
|
||||
className="flex gap-5 items-start w-full"
|
||||
>
|
||||
<div className=" -ml-2.5">
|
||||
<EmailStatusIcon status={evt.status} />
|
||||
</div>
|
||||
<div className="-mt-[0.125rem]">
|
||||
<div className="-mt-[0.125rem] w-full">
|
||||
<div className=" capitalize font-medium">
|
||||
<EmailStatusBadge status={evt.status} />
|
||||
</div>
|
||||
@@ -104,26 +115,88 @@ const EmailStatusText = ({
|
||||
_errorData.bounceType;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<p>{getErrorMessage(_errorData)}</p>
|
||||
<div className="flex gap-2 ">
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">Type </p>
|
||||
<p>{_errorData.bounceType}</p>
|
||||
<div className="rounded-xl p-4 bg-muted/20 flex flex-col gap-4">
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">Type</p>
|
||||
<p>{_errorData.bounceType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sub Type</p>
|
||||
<p>{_errorData.bounceSubType}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sub Type </p>
|
||||
<p>{_errorData.bounceSubType}</p>
|
||||
<p className="text-sm text-muted-foreground">SMTP response</p>
|
||||
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMTP response</p>
|
||||
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "FAILED") {
|
||||
const _errorData = data as unknown as { error: string };
|
||||
return <div>{_errorData.error}</div>;
|
||||
} else if (status === "OPENED") {
|
||||
const _data = data as unknown as SesOpen;
|
||||
const userAgent = getUserAgent(_data.userAgent);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl p-4 bg-muted/20 mt-4">
|
||||
<div className="flex w-full ">
|
||||
{userAgent.os.name ? (
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">OS</p>
|
||||
<p>{userAgent.os.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{userAgent.browser.name ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Browser</p>
|
||||
<p>{userAgent.browser.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "CLICKED") {
|
||||
const _data = data as unknown as SesClick;
|
||||
const userAgent = getUserAgent(_data.userAgent);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/20">
|
||||
<div className="flex w-full ">
|
||||
{userAgent.os.name ? (
|
||||
<div className="w-1/2">
|
||||
<p className="text-sm text-muted-foreground">OS </p>
|
||||
<p>{userAgent.os.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{userAgent.browser.name ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Browser </p>
|
||||
<p>{userAgent.browser.name}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-muted-foreground">URL</p>
|
||||
<p>{_data.link}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (status === "COMPLAINED") {
|
||||
const _errorData = data as unknown as SesComplaint;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div>{status}</div>;
|
||||
|
||||
return <div className="w-full">{status}</div>;
|
||||
};
|
||||
|
||||
const getErrorMessage = (data: SesBounce) => {
|
||||
@@ -148,3 +221,18 @@ const getErrorMessage = (data: SesBounce) => {
|
||||
return BOUNCE_ERROR_MESSAGES.Undetermined;
|
||||
}
|
||||
};
|
||||
|
||||
const getComplaintMessage = (errorType: string) => {
|
||||
return COMPLAINT_ERROR_MESSAGES[
|
||||
errorType as keyof typeof COMPLAINT_ERROR_MESSAGES
|
||||
];
|
||||
};
|
||||
|
||||
const getUserAgent = (userAgent: string) => {
|
||||
const parser = new UAParser(userAgent);
|
||||
return {
|
||||
browser: parser.getBrowser(),
|
||||
os: parser.getOS(),
|
||||
device: parser.getDevice(),
|
||||
};
|
||||
};
|
||||
|
@@ -190,6 +190,7 @@ const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
|
||||
// </div>
|
||||
);
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
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" />
|
||||
|
@@ -12,6 +12,7 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-500 border-emerald-600/10";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
badgeColor = "bg-red-500/10 text-red-600 border-red-600/10";
|
||||
break;
|
||||
case "CLICKED":
|
||||
@@ -51,6 +52,7 @@ export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
|
||||
insideColor = "bg-emerald-500";
|
||||
break;
|
||||
case "BOUNCED":
|
||||
case "FAILED":
|
||||
outsideColor = "bg-red-500/30";
|
||||
insideColor = "bg-red-500";
|
||||
break;
|
||||
|
@@ -1,37 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BookOpenText,
|
||||
BookUser,
|
||||
CircleUser,
|
||||
Code,
|
||||
Globe,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
LineChart,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
Package,
|
||||
Package2,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@unsend/ui/src/button";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@unsend/ui/src/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@unsend/ui/src/sheet";
|
||||
|
||||
import { LogoutButton, NavButton } from "./nav-button";
|
||||
import { DashboardProvider } from "~/providers/dashboard-provider";
|
||||
import { NextAuthProvider } from "~/providers/next-auth";
|
||||
import { DashboardLayout } from "./dasboard-layout";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
@@ -43,158 +12,7 @@ export default function AuthenticatedDashboardLayout({
|
||||
return (
|
||||
<NextAuthProvider>
|
||||
<DashboardProvider>
|
||||
<div className="flex min-h-screen w-full h-full">
|
||||
<div className="hidden bg-muted/20 md:block md:w-[280px]">
|
||||
<div className="flex h-full max-h-screen flex-col gap-2">
|
||||
<div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<span className=" text-lg">Unsend</span>
|
||||
</Link>
|
||||
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
|
||||
Early access
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 h-full">
|
||||
<nav className=" flex-1 h-full flex-col justify-between items-center px-2 text-sm font-medium lg:px-4">
|
||||
<div>
|
||||
<NavButton href="/dashboard">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
Dashboard
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/emails">
|
||||
<Mail className="h-4 w-4" />
|
||||
Emails
|
||||
</NavButton>
|
||||
|
||||
<NavButton href="/domains">
|
||||
<Globe className="h-4 w-4" />
|
||||
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="/api-keys">
|
||||
<Code className="h-4 w-4" />
|
||||
Developer settings
|
||||
</NavButton>
|
||||
</div>
|
||||
<div className=" absolute bottom-10 p-4 flex flex-col gap-2">
|
||||
<Link
|
||||
href="https://docs.unsend.dev"
|
||||
target="_blank"
|
||||
className="flex gap-2 items-center hover:text-primary text-muted-foreground"
|
||||
>
|
||||
<BookOpenText className="h-4 w-4" />
|
||||
<span className="">Docs</span>
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mt-auto p-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center gap-4 md:hidden bg-muted/20 px-4 lg:h-[60px] lg:px-6">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="flex flex-col">
|
||||
<nav className="grid gap-2 text-lg font-medium">
|
||||
<Link
|
||||
href="#"
|
||||
className="flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Package2 className="h-6 w-6" />
|
||||
<span className="sr-only">Acme Inc</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Orders
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Package className="h-5 w-5" />
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
Customers
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LineChart className="h-5 w-5" />
|
||||
Analytics
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="mt-auto"></div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<CircleUser className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle user menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto h-full">
|
||||
<div className="flex flex-col gap-4 p-4 w-full lg:max-w-6xl mx-auto lg:gap-6 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardLayout>{children}</DashboardLayout>
|
||||
</DashboardProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
|
@@ -1,8 +1,5 @@
|
||||
import { setupAws } from "~/server/aws/setup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
await setupAws();
|
||||
return Response.json({ data: "Healthy" });
|
||||
}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import { db } from "~/server/db";
|
||||
import { AppSettingsService } from "~/server/service/app-settings-service";
|
||||
import { parseSesHook } from "~/server/service/ses-hook-parser";
|
||||
import { SesSettingsService } from "~/server/service/ses-settings-service";
|
||||
import { SnsNotificationMessage } from "~/types/aws-types";
|
||||
import { APP_SETTINGS } from "~/utils/constants";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
console.log("GET", req);
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ data: "Hello" });
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ export async function POST(req: Request) {
|
||||
|
||||
console.log(data, data.Message);
|
||||
|
||||
if (isFromUnsend(data)) {
|
||||
return Response.json({ data: "success" });
|
||||
}
|
||||
|
||||
const isEventValid = await checkEventValidity(data);
|
||||
|
||||
console.log("isEventValid: ", isEventValid);
|
||||
@@ -72,26 +68,17 @@ async function handleSubscription(message: any) {
|
||||
},
|
||||
});
|
||||
|
||||
SesSettingsService.invalidateCache();
|
||||
|
||||
return Response.json({ data: "Success" });
|
||||
}
|
||||
|
||||
// A simple check to ensure that the event is from the correct topic
|
||||
function isFromUnsend({ fromUnsend }: { fromUnsend: boolean }) {
|
||||
if (fromUnsend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// A simple check to ensure that the event is from the correct topic
|
||||
async function checkEventValidity(message: SnsNotificationMessage) {
|
||||
const { TopicArn } = message;
|
||||
const configuredTopicArn = await AppSettingsService.getSetting(
|
||||
APP_SETTINGS.SNS_TOPIC_ARN
|
||||
);
|
||||
const configuredTopicArn = await SesSettingsService.getTopicArns();
|
||||
|
||||
if (TopicArn !== configuredTopicArn) {
|
||||
if (!configuredTopicArn.includes(TopicArn)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@@ -6,7 +6,6 @@ import { Toaster } from "@unsend/ui/src/toaster";
|
||||
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { Metadata } from "next";
|
||||
import { getBoss } from "~/server/service/job-service";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -24,12 +23,6 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
/**
|
||||
* Because I don't know a better way to call this during server startup.
|
||||
* This is a temporary fix to ensure that the boss is running.
|
||||
*/
|
||||
// await getBoss();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`font-sans ${inter.variable}`}>
|
||||
|
Reference in New Issue
Block a user