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:
KM Koushik
2024-06-24 08:21:37 +10:00
committed by GitHub
parent 8a2769621c
commit f77a8829be
67 changed files with 1771 additions and 688 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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");
},
}
);

View File

@@ -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">

View File

@@ -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"

View File

@@ -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">

View File

@@ -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(),
};
};

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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" });
}

View File

@@ -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;
}

View File

@@ -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}`}>

View File

@@ -0,0 +1,184 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@unsend/ui/src/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/trpc/react";
import { Input } from "@unsend/ui/src/input";
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { toast } from "@unsend/ui/src/toaster";
const FormSchema = z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
});
type SesSettingsProps = {
onSuccess?: () => void;
};
export const AddSesSettings: React.FC<SesSettingsProps> = ({ onSuccess }) => {
return (
<div className="flex items-center justify-center min-h-screen ">
<div className=" w-[400px] flex flex-col gap-8">
<div>
<h1 className="text-2xl font-semibold text-center">
Add SES Settings
</h1>
</div>
<AddSesSettingsForm onSuccess={onSuccess} />
</div>
</div>
);
};
export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
onSuccess,
}) => {
const addSesSettings = api.admin.addSesSettings.useMutation();
const utils = api.useUtils();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
region: "",
unsendUrl: "",
sendRate: 1,
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
if (!data.unsendUrl.startsWith("https://")) {
form.setError("unsendUrl", {
message: "URL must start with https://",
});
return;
}
if (data.unsendUrl.includes("localhost")) {
form.setError("unsendUrl", {
message: "URL must be a valid url",
});
return;
}
addSesSettings.mutate(data, {
onSuccess: () => {
utils.admin.invalidate();
onSuccess?.();
},
onError: (e) => {
toast.error("Failed to create", {
description: e.message,
});
},
});
}
const onRegionInputOutOfFocus = async () => {
const region = form.getValues("region");
const quota = await utils.admin.getQuotaForRegion.fetch({ region });
form.setValue("sendRate", quota ?? 1);
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className=" flex flex-col gap-8 w-full"
>
<FormField
control={form.control}
name="region"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<FormControl>
<Input
placeholder="us-east-1"
className="w-full"
{...field}
onBlur={() => {
onRegionInputOutOfFocus();
field.onBlur();
}}
/>
</FormControl>
{formState.errors.region ? (
<FormMessage />
) : (
<FormDescription>The region of the SES account</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="unsendUrl"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Callback URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
className="w-full"
{...field}
/>
</FormControl>
{formState.errors.unsendUrl ? (
<FormMessage />
) : (
<FormDescription>
This url should be accessible from the internet. Will be
called from SES
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendRate"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Send Rate</FormLabel>
<FormControl>
<Input placeholder="1" className="w-full" {...field} />
</FormControl>
{formState.errors.sendRate ? (
<FormMessage />
) : (
<FormDescription>
The number of emails to send per second.
</FormDescription>
)}
</FormItem>
)}
/>
<Button
type="submit"
disabled={addSesSettings.isPending}
className="w-[200px] mx-auto"
>
{addSesSettings.isPending ? (
<Spinner className="w-5 h-5" />
) : (
"Create"
)}
</Button>
</form>
</Form>
);
};

View File

@@ -1,3 +1,4 @@
import { EmailStatus } from "@prisma/client";
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
@@ -32,21 +33,19 @@ export const env = createEnv({
GITHUB_SECRET: z.string(),
AWS_ACCESS_KEY: z.string(),
AWS_SECRET_KEY: z.string(),
APP_URL: z.string().optional(),
SNS_TOPIC: z.string(),
UNSEND_API_KEY: z.string().optional(),
UNSEND_URL: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
SES_QUEUE_LIMIT: z.string().transform((str) => parseInt(str, 10)),
AWS_DEFAULT_REGION: z.string().default("us-east-1"),
API_RATE_LIMIT: z
.string()
.transform((str) => parseInt(str, 10))
.default(2),
.default(1),
FROM_EMAIL: z.string().optional(),
ADMIN_EMAIL: z.string().optional(),
DISCORD_WEBHOOK_URL: z.string().optional(),
REDIS_URL: z.string(),
},
/**
@@ -72,18 +71,16 @@ export const env = createEnv({
GITHUB_SECRET: process.env.GITHUB_SECRET,
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
AWS_SECRET_KEY: process.env.AWS_SECRET_KEY,
APP_URL: process.env.APP_URL,
SNS_TOPIC: process.env.SNS_TOPIC,
UNSEND_API_KEY: process.env.UNSEND_API_KEY,
UNSEND_URL: process.env.UNSEND_URL,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
SES_QUEUE_LIMIT: process.env.SES_QUEUE_LIMIT,
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION,
API_RATE_LIMIT: process.env.API_RATE_LIMIT,
NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD,
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL,
REDIS_URL: process.env.REDIS_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View File

@@ -0,0 +1,19 @@
let initialized = false;
/**
* Add things here to be executed during server startup.
*
* more details here: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
export async function register() {
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (process.env.NEXT_RUNTIME === "nodejs" && !initialized) {
console.log("Registering instrumentation");
const { EmailQueueService } = await import(
"~/server/service/email-queue-service"
);
await EmailQueueService.init();
initialized = true;
}
}

View File

@@ -0,0 +1,133 @@
import { CodeBlock } from "@unsend/ui/src/code";
export const getSendTestEmailCode = ({
from,
to,
subject,
body,
bodyHtml,
}: {
from: string;
to: string;
subject: string;
body: string;
bodyHtml: string;
}): Array<CodeBlock> => {
return [
{
language: "js",
title: "Node.js",
code: `import { Unsend } from "unsend";
const unsend = new Unsend({ apiKey: "us_12345" });
unsend.emails.send({
to: "${to}",
from: "${from}",
subject: "${subject}",
html: "${bodyHtml}",
text: "${body}",
});
`,
},
{
language: "python",
title: "Python",
code: `import requests
url = "https://app.unsend.dev/api/v1/emails"
payload = {
"to": "${to}",
"from": "${from}",
"subject": "${subject}",
"text": "${body}",
"html": "${bodyHtml}",
}
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer us_12345"
}
response = requests.request("POST", url, json=payload, headers=headers)
`,
},
{
language: "php",
title: "PHP",
code: `<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.unsend.dev/api/v1/emails",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "{\\n \\"to\\": \\"${to}\\",\\n \\"from\\": \\"${from}\\",\\n \\"subject\\": \\"${subject}\\",\\n \\"replyTo\\": \\"${from}\\",\\n \\"text\\": \\"${body}\\",\\n \\"html\\": \\"${bodyHtml}\\"\\n}",
CURLOPT_HTTPHEADER => [
"Authorization: Bearer us_12345",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
`,
},
{
language: "ruby",
title: "Ruby",
code: `require 'net/http'
require 'uri'
require 'json'
url = URI("https://app.unsend.dev/api/v1/emails")
payload = {
"to" => "${to}",
"from" => "${from}",
"subject" => "${subject}",
"text" => "${body}",
"html" => "${bodyHtml}"
}.to_json
headers = {
"Content-Type" => "application/json",
"Authorization" => "Bearer us_12345"
}
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url, headers)
request.body = payload
response = http.request(request)
puts response.body
`,
},
{
language: "curl",
title: "cURL",
code: `curl -X POST https://app.unsend.dev/api/v1/emails \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer us_12345" \\
-d '{"to": "${to}", "from": "${from}", "subject": "${subject}", "text": "${body}", "html": "${bodyHtml}"}'`,
},
];
};

View File

@@ -44,3 +44,14 @@ export const BOUNCE_ERROR_MESSAGES = {
"Unsend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
},
};
export const COMPLAINT_ERROR_MESSAGES = {
abuse: "Indicates unsolicited email or some other kind of email abuse.",
"auth-failure": "Email authentication failure report.",
fraud: "Indicates some kind of fraud or phishing activity.",
"not-spam":
"Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.",
other:
"Indicates any other feedback that does not fit into other registered types.",
virus: "Reports that a virus is found in the originating message.",
};

View File

@@ -1,7 +1,10 @@
"use client";
import { useSession } from "next-auth/react";
import { FullScreenLoading } from "~/components/FullScreenLoading";
import { AddSesSettings } from "~/components/settings/AddSesSettings";
import CreateTeam from "~/components/team/CreateTeam";
import { env } from "~/env";
import { api } from "~/trpc/react";
export const DashboardProvider = ({
@@ -9,12 +12,27 @@ export const DashboardProvider = ({
}: {
children: React.ReactNode;
}) => {
const { data: session } = useSession();
const { data: teams, status } = api.team.getTeams.useQuery();
const { data: settings, status: settingsStatus } =
api.admin.getSesSettings.useQuery(undefined, {
enabled: !env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin,
});
if (status === "pending") {
if (
status === "pending" ||
(settingsStatus === "pending" && !env.NEXT_PUBLIC_IS_CLOUD)
) {
return <FullScreenLoading />;
}
if (
settings?.length === 0 &&
(!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin)
) {
return <AddSesSettings />;
}
if (!teams || teams.length === 0) {
return <CreateTeam />;
}

View File

@@ -3,6 +3,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
import { apiRouter } from "./routers/api";
import { emailRouter } from "./routers/email";
import { teamRouter } from "./routers/team";
import { adminRouter } from "./routers/admin";
/**
* This is the primary router for your server.
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
apiKey: apiRouter,
email: emailRouter,
team: teamRouter,
admin: adminRouter,
});
// export type definition of API

View File

@@ -3,12 +3,24 @@ import { env } from "~/env";
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses";
export const adminRouter = createTRPCRouter({
getSesSettings: adminProcedure.query(async () => {
return SesSettingsService.getAllSettings();
}),
getQuotaForRegion: adminProcedure
.input(
z.object({
region: z.string(),
})
)
.query(async ({ input }) => {
const acc = await getAccount(input.region);
return acc.SendQuota?.MaxSendRate;
}),
addSesSettings: adminProcedure
.input(
z.object({

View File

@@ -1,6 +1,10 @@
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import {
createTRPCRouter,
teamProcedure,
protectedProcedure,
} from "~/server/api/trpc";
import { db } from "~/server/db";
import {
createDomain,
@@ -9,12 +13,18 @@ import {
updateDomain,
} from "~/server/service/domain-service";
import { sendEmail } from "~/server/service/email-service";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export const domainRouter = createTRPCRouter({
getAvailableRegions: protectedProcedure.query(async () => {
const settings = await SesSettingsService.getAllSettings();
return settings.map((setting) => setting.region);
}),
createDomain: teamProcedure
.input(z.object({ name: z.string() }))
.input(z.object({ name: z.string(), region: z.string() }))
.mutation(async ({ ctx, input }) => {
return createDomain(ctx.team.id, input.name);
return createDomain(ctx.team.id, input.name, input.region);
}),
startVerification: teamProcedure
@@ -93,9 +103,9 @@ export const domainRouter = createTRPCRouter({
teamId: team.id,
to: user.email,
from: `hello@${domain.name}`,
subject: "Test mail",
text: "Hello this is a test mail",
html: "<p>Hello this is a test mail</p>",
subject: "Unsend test email",
text: "hello,\n\nUnsend is the best open source sending platform\n\ncheck out https://unsend.dev",
html: "<p>hello,</p><p>Unsend is the best open source sending platform<p><p>check out <a href='https://unsend.dev'>unsend.dev</a>",
});
}
),

View File

@@ -25,6 +25,7 @@ declare module "next-auth" {
user: {
id: number;
isBetaUser: boolean;
isAdmin: boolean;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
@@ -34,6 +35,7 @@ declare module "next-auth" {
interface User {
id: number;
isBetaUser: boolean;
isAdmin: boolean;
}
}
@@ -86,6 +88,7 @@ export const authOptions: NextAuthOptions = {
...session.user,
id: user.id,
isBetaUser: user.isBetaUser,
isAdmin: user.email === env.ADMIN_EMAIL,
},
}),
},

View File

@@ -8,14 +8,14 @@ import {
CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand,
EventType,
GetAccountCommand,
} from "@aws-sdk/client-sesv2";
import { generateKeyPairSync } from "crypto";
import mime from "mime-types";
import { env } from "~/env";
import { EmailContent } from "~/types";
import { APP_SETTINGS } from "~/utils/constants";
function getSesClient(region = "us-east-1") {
function getSesClient(region: string) {
return new SESv2Client({
region: region,
credentials: {
@@ -51,7 +51,7 @@ function generateKeyPair() {
return { privateKey: base64PrivateKey, publicKey: base64PublicKey };
}
export async function addDomain(domain: string, region = "us-east-1") {
export async function addDomain(domain: string, region: string) {
const sesClient = getSesClient(region);
const { privateKey, publicKey } = generateKeyPair();
@@ -61,7 +61,6 @@ export async function addDomain(domain: string, region = "us-east-1") {
DomainSigningSelector: "unsend",
DomainSigningPrivateKey: privateKey,
},
ConfigurationSetName: APP_SETTINGS.SES_CONFIGURATION_GENERAL,
});
const response = await sesClient.send(command);
@@ -84,7 +83,7 @@ export async function addDomain(domain: string, region = "us-east-1") {
return publicKey;
}
export async function deleteDomain(domain: string, region = "us-east-1") {
export async function deleteDomain(domain: string, region: string) {
const sesClient = getSesClient(region);
const command = new DeleteEmailIdentityCommand({
EmailIdentity: domain,
@@ -93,7 +92,7 @@ export async function deleteDomain(domain: string, region = "us-east-1") {
return response.$metadata.httpStatusCode === 200;
}
export async function getDomainIdentity(domain: string, region = "us-east-1") {
export async function getDomainIdentity(domain: string, region: string) {
const sesClient = getSesClient(region);
const command = new GetEmailIdentityCommand({
EmailIdentity: domain,
@@ -106,21 +105,29 @@ export async function sendEmailThroughSes({
to,
from,
subject,
cc,
bcc,
text,
html,
replyTo,
region = "us-east-1",
region,
configurationSetName,
}: EmailContent & {
region?: string;
}: Partial<EmailContent> & {
region: string;
configurationSetName: string;
cc?: string[];
bcc?: string[];
replyTo?: string[];
to?: string[];
}) {
const sesClient = getSesClient(region);
const command = new SendEmailCommand({
FromEmailAddress: from,
ReplyToAddresses: replyTo ? [replyTo] : undefined,
ReplyToAddresses: replyTo ? replyTo : undefined,
Destination: {
ToAddresses: [to],
ToAddresses: to,
CcAddresses: cc,
BccAddresses: bcc,
},
Content: {
// EmailContent
@@ -153,7 +160,7 @@ export async function sendEmailThroughSes({
return response.MessageId;
} catch (error) {
console.error("Failed to send email", error);
throw new Error("Failed to send email");
throw error;
}
}
@@ -163,21 +170,29 @@ export async function sendEmailWithAttachments({
from,
subject,
replyTo,
cc,
bcc,
// eslint-disable-next-line no-unused-vars
text,
html,
attachments,
region = "us-east-1",
region,
configurationSetName,
}: EmailContent & {
region?: string;
}: Partial<EmailContent> & {
region: string;
configurationSetName: string;
attachments: { filename: string; content: string }[];
cc?: string[];
bcc?: string[];
replyTo?: string[];
to?: string[];
}) {
const sesClient = getSesClient(region);
const boundary = "NextPart";
let rawEmail = `From: ${from}\n`;
rawEmail += `To: ${to}\n`;
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
rawEmail += `Cc: ${cc ? cc.join(", ") : ""}\n`;
rawEmail += `Bcc: ${bcc ? bcc.join(", ") : ""}\n`;
rawEmail += `Reply-To: ${replyTo}\n`;
rawEmail += `Subject: ${subject}\n`;
rawEmail += `MIME-Version: 1.0\n`;
@@ -217,11 +232,18 @@ export async function sendEmailWithAttachments({
}
}
export async function getAccount(region: string) {
const client = getSesClient(region);
const input = new GetAccountCommand({});
const response = await client.send(input);
return response;
}
export async function addWebhookConfiguration(
configName: string,
topicArn: string,
eventTypes: EventType[],
region = "us-east-1"
region: string
) {
const sesClient = getSesClient(region);

View File

@@ -1,99 +0,0 @@
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 { EventType } from "@aws-sdk/client-sesv2";
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
"COMPLAINT",
"DELIVERY",
"DELIVERY_DELAY",
"REJECT",
"RENDERING_FAILURE",
"SEND",
"SUBSCRIPTION",
];
export async function setupAws() {
AppSettingsService.initializeCache();
let snsTopicArn = await AppSettingsService.getSetting(
APP_SETTINGS.SNS_TOPIC_ARN
);
console.log("Setting up AWS");
if (!snsTopicArn) {
console.log("SNS topic not present, creating...");
snsTopicArn = await createUnsendSNSTopic();
}
await setupSESConfiguration();
}
async function createUnsendSNSTopic() {
const topicArn = await createTopic(env.SNS_TOPIC);
if (!topicArn) {
console.error("Failed to create SNS topic");
return;
}
await subscribeEndpoint(
topicArn,
`${env.APP_URL ?? env.NEXTAUTH_URL}/api/ses_callback`
);
return await AppSettingsService.setSetting(
APP_SETTINGS.SNS_TOPIC_ARN,
topicArn
);
}
async function setupSESConfiguration() {
const topicArn = (
await AppSettingsService.getSetting(APP_SETTINGS.SNS_TOPIC_ARN)
)?.toString();
if (!topicArn) {
return;
}
console.log("Setting up SES webhook configuration");
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_GENERAL,
topicArn,
GENERAL_EVENTS
);
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING,
topicArn,
[...GENERAL_EVENTS, "CLICK"]
);
await setWebhookConfiguration(
APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING,
topicArn,
[...GENERAL_EVENTS, "OPEN"]
);
await setWebhookConfiguration(APP_SETTINGS.SES_CONFIGURATION_FULL, topicArn, [
...GENERAL_EVENTS,
"CLICK",
"OPEN",
]);
}
async function setWebhookConfiguration(
setting: string,
topicArn: string,
eventTypes: EventType[]
) {
const sesConfigurationGeneral = await AppSettingsService.getSetting(setting);
if (!sesConfigurationGeneral) {
console.log(`Setting up SES webhook configuration for ${setting}`);
const status = await addWebhookConfiguration(setting, topicArn, eventTypes);
await AppSettingsService.setSetting(setting, status.toString());
}
}

View File

@@ -5,7 +5,7 @@ import {
} from "@aws-sdk/client-sns";
import { env } from "~/env";
function getSnsClient(region = "us-east-1") {
function getSnsClient(region: string) {
return new SNSClient({
region: region,
credentials: {
@@ -15,8 +15,8 @@ function getSnsClient(region = "us-east-1") {
});
}
export async function createTopic(topic: string) {
const client = getSnsClient();
export async function createTopic(topic: string, region: string) {
const client = getSnsClient(region);
const command = new CreateTopicCommand({
Name: topic,
});
@@ -25,13 +25,17 @@ export async function createTopic(topic: string) {
return data.TopicArn;
}
export async function subscribeEndpoint(topicArn: string, endpointUrl: string) {
export async function subscribeEndpoint(
topicArn: string,
endpointUrl: string,
region: string
) {
const subscribeCommand = new SubscribeCommand({
Protocol: "https",
TopicArn: topicArn,
Endpoint: endpointUrl,
});
const client = getSnsClient();
const client = getSnsClient(region);
const data = await client.send(subscribeCommand);
console.log(data.SubscriptionArn);

View File

@@ -1,7 +1,6 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendEmail } from "~/server/service/email-service";
import { db } from "~/server/db";
import { EmailStatus } from "@prisma/client";
import { UnsendApiError } from "../../api-error";
@@ -30,7 +29,10 @@ const route = createRoute({
schema: z.object({
id: z.string(),
teamId: z.number(),
to: z.string(),
to: z.string().or(z.array(z.string())),
replyTo: z.string().or(z.array(z.string())).optional(),
cc: z.string().or(z.array(z.string())).optional(),
bcc: z.string().or(z.array(z.string())).optional(),
from: z.string(),
subject: z.string(),
html: z.string().nullable(),

View File

@@ -12,10 +12,12 @@ const route = createRoute({
content: {
"application/json": {
schema: z.object({
to: z.string().email(),
from: z.string().email(),
to: z.string().or(z.array(z.string())),
from: z.string(),
subject: z.string(),
replyTo: z.string().optional(),
replyTo: z.string().or(z.array(z.string())).optional(),
cc: z.string().or(z.array(z.string())).optional(),
bcc: z.string().or(z.array(z.string())).optional(),
text: z.string().optional(),
html: z.string().optional(),
attachments: z

View File

@@ -15,7 +15,7 @@ export function getApp() {
version: "1.0.0",
title: "Unsend API",
},
servers: [{ url: `${env.APP_URL}/api` }],
servers: [{ url: `${env.NEXTAUTH_URL}/api` }],
}));
app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", {

View File

@@ -0,0 +1,11 @@
import IORedis from "ioredis";
import { env } from "~/env";
export let connection: IORedis | null = null;
export const getRedis = () => {
if (!connection) {
connection = new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null });
}
return connection;
};

View File

@@ -1,38 +0,0 @@
import { db } from "../db";
import { JsonValue } from "@prisma/client/runtime/library";
export class AppSettingsService {
private static cache: Record<string, JsonValue> = {};
public static async getSetting(key: string) {
if (!this.cache[key]) {
const setting = await db.appSetting.findUnique({
where: { key },
});
if (setting) {
this.cache[key] = setting.value;
} else {
return null;
}
}
return this.cache[key];
}
public static async setSetting(key: string, value: string) {
await db.appSetting.upsert({
where: { key },
update: { value },
create: { key, value },
});
this.cache[key] = value;
return value;
}
public static async initializeCache(): Promise<void> {
const settings = await db.appSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.key] = setting.value;
});
}
}

View File

@@ -3,18 +3,29 @@ import util from "util";
import * as tldts from "tldts";
import * as ses from "~/server/aws/ses";
import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt);
export async function createDomain(teamId: number, name: string) {
export async function createDomain(
teamId: number,
name: string,
region: string
) {
const domainStr = tldts.getDomain(name);
if (!domainStr) {
throw new Error("Invalid domain");
}
const setting = await SesSettingsService.getSetting(region);
if (!setting) {
throw new Error("Ses setting not found");
}
const subdomain = tldts.getSubdomain(name);
const publicKey = await ses.addDomain(name);
const publicKey = await ses.addDomain(name, region);
const domain = await db.domain.create({
data: {
@@ -22,6 +33,7 @@ export async function createDomain(teamId: number, name: string) {
publicKey,
teamId,
subdomain,
region,
},
});

View File

@@ -0,0 +1,135 @@
import { Job, Queue, Worker } from "bullmq";
import { env } from "~/env";
import { EmailAttachment } from "~/types";
import { getConfigurationSetName } from "~/utils/ses-utils";
import { db } from "../db";
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
import { getRedis } from "../redis";
export class EmailQueueService {
private static initialized = false;
private static regionQueue = new Map<string, Queue>();
private static regionWorker = new Map<string, Worker>();
public static initializeQueue(region: string, quota: number) {
const connection = getRedis();
console.log(`[EmailQueueService]: Initializing queue for region ${region}`);
const queueName = `${region}-transaction`;
const queue = new Queue(queueName, { connection });
const worker = new Worker(queueName, executeEmail, {
limiter: {
max: quota,
duration: 1000,
},
concurrency: quota,
connection,
});
this.regionQueue.set(region, queue);
this.regionWorker.set(region, worker);
}
public static async queueEmail(emailId: string, region: string) {
if (!this.initialized) {
await this.init();
}
const queue = this.regionQueue.get(region);
if (!queue) {
throw new Error(`Queue for region ${region} not found`);
}
queue.add("send-email", { emailId, timestamp: Date.now() });
}
public static async init() {
const sesSettings = await db.sesSetting.findMany();
for (const sesSetting of sesSettings) {
this.initializeQueue(sesSetting.region, sesSetting.sesEmailRateLimit);
}
this.initialized = true;
}
}
async function executeEmail(job: Job<{ emailId: string; timestamp: number }>) {
console.log(
`[EmailQueueService]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
);
const email = await db.email.findUnique({
where: { id: job.data.emailId },
});
const domain = email?.domainId
? await db.domain.findUnique({
where: { id: email?.domainId },
})
: null;
if (!email) {
console.log(`[EmailQueueService]: Email not found, skipping`);
return;
}
const attachments: Array<EmailAttachment> = email.attachments
? JSON.parse(email.attachments)
: [];
const configurationSetName = await getConfigurationSetName(
domain?.clickTracking ?? false,
domain?.openTracking ?? false,
domain?.region ?? env.AWS_DEFAULT_REGION
);
if (!configurationSetName) {
console.log(`[EmailQueueService]: Configuration set not found, skipping`);
return;
}
console.log(`[EmailQueueService]: Sending email ${email.id}`);
try {
const messageId = attachments.length
? await sendEmailWithAttachments({
to: email.to,
from: email.from,
subject: email.subject,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
attachments,
})
: await sendEmailThroughSes({
to: email.to,
from: email.from,
subject: email.subject,
replyTo: email.replyTo ?? undefined,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName,
attachments,
});
// Delete attachments after sending the email
await db.email.update({
where: { id: email.id },
data: { sesEmailId: messageId, attachments: undefined },
});
} catch (error: any) {
await db.emailEvent.create({
data: {
emailId: email.id,
status: "FAILED",
data: {
error: error.toString(),
},
},
});
await db.email.update({
where: { id: email.id },
data: { latestStatus: "FAILED" },
});
}
}

View File

@@ -1,13 +1,23 @@
import { EmailContent } from "~/types";
import { db } from "../db";
import { UnsendApiError } from "~/server/public-api/api-error";
import { queueEmail } from "./job-service";
import { EmailQueueService } from "./email-queue-service";
export async function sendEmail(
emailContent: EmailContent & { teamId: number }
) {
const { to, from, subject, text, html, teamId, attachments, replyTo } =
emailContent;
const {
to,
from,
subject,
text,
html,
teamId,
attachments,
replyTo,
cc,
bcc,
} = emailContent;
const fromDomain = from.split("@")[1];
@@ -32,10 +42,16 @@ export async function sendEmail(
const email = await db.email.create({
data: {
to,
to: Array.isArray(to) ? to : [to],
from,
subject,
replyTo,
replyTo: replyTo
? Array.isArray(replyTo)
? replyTo
: [replyTo]
: undefined,
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
bcc: bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : undefined,
text,
html,
teamId,
@@ -44,7 +60,24 @@ export async function sendEmail(
},
});
queueEmail(email.id);
try {
await EmailQueueService.queueEmail(email.id, domain.region);
} catch (error: any) {
await db.emailEvent.create({
data: {
emailId: email.id,
status: "FAILED",
data: {
error: error.toString(),
},
},
});
await db.email.update({
where: { id: email.id },
data: { latestStatus: "FAILED" },
});
throw error;
}
return email;
}

View File

@@ -1,109 +0,0 @@
import pgBoss from "pg-boss";
import { env } from "~/env";
import { EmailAttachment } from "~/types";
import { db } from "~/server/db";
import {
sendEmailThroughSes,
sendEmailWithAttachments,
} from "~/server/aws/ses";
import { getConfigurationSetName } from "~/utils/ses-utils";
import { sendToDiscord } from "./notification-service";
const boss = new pgBoss({
connectionString: env.DATABASE_URL,
archiveCompletedAfterSeconds: 60 * 60 * 24, // 24 hours
deleteAfterDays: 7, // 7 days
});
let started = false;
export async function getBoss() {
if (!started) {
await boss.start();
await boss.work(
"send_email",
{
teamConcurrency: env.SES_QUEUE_LIMIT,
teamSize: env.SES_QUEUE_LIMIT,
teamRefill: true,
},
executeEmail
);
boss.on("error", async (error) => {
console.error(error);
sendToDiscord(
`Error in pg-boss: ${error.name} \n ${error.cause}\n ${error.message}\n ${error.stack}`
);
await boss.stop();
started = false;
});
started = true;
}
return boss;
}
export async function queueEmail(emailId: string) {
const boss = await getBoss();
await boss.send("send_email", { emailId, timestamp: Date.now() });
}
async function executeEmail(
job: pgBoss.Job<{ emailId: string; timestamp: number }>
) {
console.log(
`[EmailJob]: Executing email job ${job.data.emailId}, time elapsed: ${Date.now() - job.data.timestamp}ms`
);
const email = await db.email.findUnique({
where: { id: job.data.emailId },
});
const domain = email?.domainId
? await db.domain.findUnique({
where: { id: email?.domainId },
})
: null;
if (!email) {
console.log(`[EmailJob]: Email not found, skipping`);
return;
}
const attachments: Array<EmailAttachment> = email.attachments
? JSON.parse(email.attachments)
: [];
const messageId = attachments.length
? await sendEmailWithAttachments({
to: email.to,
from: email.from,
subject: email.subject,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName: getConfigurationSetName(
domain?.clickTracking ?? false,
domain?.openTracking ?? false
),
attachments,
})
: await sendEmailThroughSes({
to: email.to,
from: email.from,
subject: email.subject,
replyTo: email.replyTo ?? undefined,
text: email.text ?? undefined,
html: email.html ?? undefined,
region: domain?.region ?? env.AWS_DEFAULT_REGION,
configurationSetName: getConfigurationSetName(
domain?.clickTracking ?? false,
domain?.openTracking ?? false
),
attachments,
});
await db.email.update({
where: { id: email.id },
data: { sesEmailId: messageId, attachments: undefined },
});
}

View File

@@ -2,6 +2,8 @@ import { EmailStatus } from "@prisma/client";
import { SesEvent, SesEventDataKey } from "~/types/aws-types";
import { db } from "../db";
const STATUS_LIST = Object.values(EmailStatus);
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -30,21 +32,12 @@ export async function parseSesHook(data: SesEvent) {
id: email.id,
},
data: {
latestStatus: mailStatus,
latestStatus: getLatestStatus(email.latestStatus, mailStatus),
},
});
await db.emailEvent.upsert({
where: {
emailId_status: {
emailId: email.id,
status: mailStatus,
},
},
update: {
data: mailData as any,
},
create: {
await db.emailEvent.create({
data: {
emailId: email.id,
status: mailStatus,
data: mailData as any,
@@ -89,3 +82,12 @@ function getEmailData(data: SesEvent) {
return data[eventType.toLowerCase() as SesEventDataKey];
}
}
function getLatestStatus(
currentEmailStatus: EmailStatus,
incomingStatus: EmailStatus
) {
const index = STATUS_LIST.indexOf(currentEmailStatus);
const incomingIndex = STATUS_LIST.indexOf(incomingStatus);
return STATUS_LIST[Math.max(index, incomingIndex)] ?? incomingStatus;
}

View File

@@ -5,8 +5,9 @@ import { customAlphabet } from "nanoid";
import * as sns from "~/server/aws/sns";
import * as ses from "~/server/aws/ses";
import { EventType } from "@aws-sdk/client-sesv2";
import { EmailQueueService } from "./email-queue-service";
const nanoid = customAlphabet("1234567890abcdef", 10);
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 10);
const GENERAL_EVENTS: EventType[] = [
"BOUNCE",
@@ -21,15 +22,26 @@ const GENERAL_EVENTS: EventType[] = [
export class SesSettingsService {
private static cache: Record<string, SesSetting> = {};
private static topicArns: Array<string> = [];
private static initialized = false;
public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null {
public static async getSetting(
region = env.AWS_DEFAULT_REGION
): Promise<SesSetting | null> {
await this.checkInitialized();
if (this.cache[region]) {
return this.cache[region] as SesSetting;
}
return null;
}
public static getAllSettings() {
public static async getTopicArns() {
await this.checkInitialized();
return this.topicArns;
}
public static async getAllSettings() {
await this.checkInitialized();
return Object.values(this.cache);
}
@@ -46,15 +58,20 @@ export class SesSettingsService {
region: string;
unsendUrl: string;
}) {
await this.checkInitialized();
if (this.cache[region]) {
throw new Error(`SesSetting for region ${region} already exists`);
}
const unsendUrlValidation = await isValidUnsendUrl(unsendUrl);
const parsedUrl = unsendUrl.endsWith("/")
? unsendUrl.substring(0, unsendUrl.length - 1)
: unsendUrl;
const unsendUrlValidation = await isValidUnsendUrl(parsedUrl);
if (!unsendUrlValidation.isValid) {
throw new Error(
`Unsend URL ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} ${unsendUrlValidation.error}`
`Unsend URL: ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} message:${unsendUrlValidation.error}`
);
}
@@ -63,28 +80,35 @@ export class SesSettingsService {
const setting = await db.sesSetting.create({
data: {
region,
callbackUrl: `${unsendUrl}/api/ses_callback`,
callbackUrl: `${parsedUrl}/api/ses_callback`,
topic: `${idPrefix}-${region}-unsend`,
idPrefix,
},
});
await createSettingInAws(setting);
EmailQueueService.initializeQueue(region, setting.sesEmailRateLimit);
this.invalidateCache();
await this.invalidateCache();
}
public static async init() {
public static async checkInitialized() {
if (!this.initialized) {
await this.invalidateCache();
this.initialized = true;
}
}
static async invalidateCache() {
this.cache = {};
const settings = await db.sesSetting.findMany();
settings.forEach((setting) => {
this.cache[setting.region] = setting;
if (setting.topicArn) {
this.topicArns.push(setting.topicArn);
}
});
}
static invalidateCache() {
this.cache = {};
this.init();
}
}
async function createSettingInAws(setting: SesSetting) {
@@ -95,18 +119,13 @@ async function createSettingInAws(setting: SesSetting) {
* Creates a new topic in AWS and subscribes the callback URL to it
*/
async function registerTopicInAws(setting: SesSetting) {
const topicArn = await sns.createTopic(setting.topic);
const topicArn = await sns.createTopic(setting.topic, setting.region);
if (!topicArn) {
throw new Error("Failed to create SNS topic");
}
await sns.subscribeEndpoint(
topicArn,
`${setting.callbackUrl}/api/ses_callback`
);
return await db.sesSetting.update({
const _setting = await db.sesSetting.update({
where: {
id: setting.id,
},
@@ -114,6 +133,17 @@ async function registerTopicInAws(setting: SesSetting) {
topicArn,
},
});
// Invalidate the cache to update the topicArn list
SesSettingsService.invalidateCache();
await sns.subscribeEndpoint(
topicArn,
`${setting.callbackUrl}`,
setting.region
);
return _setting;
}
/**
@@ -133,28 +163,32 @@ async function registerConfigurationSet(setting: SesSetting) {
const generalStatus = await ses.addWebhookConfiguration(
configGeneral,
setting.topicArn,
GENERAL_EVENTS
GENERAL_EVENTS,
setting.region
);
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
const clickStatus = await ses.addWebhookConfiguration(
configClick,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK"]
[...GENERAL_EVENTS, "CLICK"],
setting.region
);
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
const openStatus = await ses.addWebhookConfiguration(
configOpen,
setting.topicArn,
[...GENERAL_EVENTS, "OPEN"]
[...GENERAL_EVENTS, "OPEN"],
setting.region
);
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
const fullStatus = await ses.addWebhookConfiguration(
configFull,
setting.topicArn,
[...GENERAL_EVENTS, "CLICK", "OPEN"]
[...GENERAL_EVENTS, "CLICK", "OPEN"],
setting.region
);
return await db.sesSetting.update({
@@ -175,10 +209,10 @@ async function registerConfigurationSet(setting: SesSetting) {
}
async function isValidUnsendUrl(url: string) {
console.log("Checking if URL is valid", url);
try {
const response = await fetch(`${url}/api/ses_callback`, {
method: "POST",
body: JSON.stringify({ fromUnsend: true }),
method: "GET",
});
return {
isValid: response.status === 200,
@@ -186,6 +220,7 @@ async function isValidUnsendUrl(url: string) {
error: response.statusText,
};
} catch (e) {
console.log("Error checking if URL is valid", e);
return {
isValid: false,
code: 500,

View File

@@ -1,10 +1,12 @@
export type EmailContent = {
to: string;
to: string | string[];
from: string;
subject: string;
text?: string;
html?: string;
replyTo?: string;
replyTo?: string | string[];
cc?: string | string[];
bcc?: string | string[];
attachments?: Array<EmailAttachment>;
};

View File

@@ -1,9 +0,0 @@
import { env } from "~/env";
export const APP_SETTINGS = {
SNS_TOPIC_ARN: "SNS_TOPIC_ARN",
SES_CONFIGURATION_GENERAL: `SES_CONFIGURATION_GENERAL_${env.NODE_ENV}`,
SES_CONFIGURATION_CLICK_TRACKING: `SES_CONFIGURATION_CLICK_TRACKING_${env.NODE_ENV}`,
SES_CONFIGURATION_OPEN_TRACKING: `SES_CONFIGURATION_OPEN_TRACKING_${env.NODE_ENV}`,
SES_CONFIGURATION_FULL: `SES_CONFIGURATION_FULL_${env.NODE_ENV}`,
};

View File

@@ -1,18 +1,25 @@
import { APP_SETTINGS } from "./constants";
import { SesSettingsService } from "~/server/service/ses-settings-service";
export function getConfigurationSetName(
export async function getConfigurationSetName(
clickTracking: boolean,
openTracking: boolean
openTracking: boolean,
region: string
) {
const setting = await SesSettingsService.getSetting(region);
if (!setting) {
throw new Error(`No SES setting found for region: ${region}`);
}
if (clickTracking && openTracking) {
return APP_SETTINGS.SES_CONFIGURATION_FULL;
return setting.configFull;
}
if (clickTracking) {
return APP_SETTINGS.SES_CONFIGURATION_CLICK_TRACKING;
return setting.configClick;
}
if (openTracking) {
return APP_SETTINGS.SES_CONFIGURATION_OPEN_TRACKING;
return setting.configOpen;
}
return APP_SETTINGS.SES_CONFIGURATION_GENERAL;
return setting.configGeneral;
}