=> {
+ 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: ` "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}"}'`,
+ },
+ ];
+};
diff --git a/apps/web/src/lib/constants/ses-errors.ts b/apps/web/src/lib/constants/ses-errors.ts
index 1aced89..fbd1b94 100644
--- a/apps/web/src/lib/constants/ses-errors.ts
+++ b/apps/web/src/lib/constants/ses-errors.ts
@@ -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.",
+};
diff --git a/apps/web/src/providers/dashboard-provider.tsx b/apps/web/src/providers/dashboard-provider.tsx
index e0395d1..00f93cd 100644
--- a/apps/web/src/providers/dashboard-provider.tsx
+++ b/apps/web/src/providers/dashboard-provider.tsx
@@ -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 ;
}
+ if (
+ settings?.length === 0 &&
+ (!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin)
+ ) {
+ return ;
+ }
+
if (!teams || teams.length === 0) {
return ;
}
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index 4ea876d..693effa 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -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
diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts
index 4ee4a08..d1b77d8 100644
--- a/apps/web/src/server/api/routers/admin.ts
+++ b/apps/web/src/server/api/routers/admin.ts
@@ -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({
diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts
index 4b55312..8f5b0e2 100644
--- a/apps/web/src/server/api/routers/domain.ts
+++ b/apps/web/src/server/api/routers/domain.ts
@@ -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: "Hello this is a test mail
",
+ subject: "Unsend test email",
+ text: "hello,\n\nUnsend is the best open source sending platform\n\ncheck out https://unsend.dev",
+ html: "hello,
Unsend is the best open source sending platform
check out unsend.dev",
});
}
),
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index b297c42..0ebee85 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -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,
},
}),
},
diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts
index ba04da7..406a9df 100644
--- a/apps/web/src/server/aws/ses.ts
+++ b/apps/web/src/server/aws/ses.ts
@@ -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 & {
+ 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 & {
+ 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);
diff --git a/apps/web/src/server/aws/setup.ts b/apps/web/src/server/aws/setup.ts
deleted file mode 100644
index dea9de3..0000000
--- a/apps/web/src/server/aws/setup.ts
+++ /dev/null
@@ -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());
- }
-}
diff --git a/apps/web/src/server/aws/sns.ts b/apps/web/src/server/aws/sns.ts
index ebd8959..851a3b5 100644
--- a/apps/web/src/server/aws/sns.ts
+++ b/apps/web/src/server/aws/sns.ts
@@ -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);
diff --git a/apps/web/src/server/public-api/api/emails/get-email.ts b/apps/web/src/server/public-api/api/emails/get-email.ts
index dacff34..6d6c650 100644
--- a/apps/web/src/server/public-api/api/emails/get-email.ts
+++ b/apps/web/src/server/public-api/api/emails/get-email.ts
@@ -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(),
diff --git a/apps/web/src/server/public-api/api/emails/send-email.ts b/apps/web/src/server/public-api/api/emails/send-email.ts
index 6ce1484..07b7571 100644
--- a/apps/web/src/server/public-api/api/emails/send-email.ts
+++ b/apps/web/src/server/public-api/api/emails/send-email.ts
@@ -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
diff --git a/apps/web/src/server/public-api/hono.ts b/apps/web/src/server/public-api/hono.ts
index a23af05..b7905ea 100644
--- a/apps/web/src/server/public-api/hono.ts
+++ b/apps/web/src/server/public-api/hono.ts
@@ -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", {
diff --git a/apps/web/src/server/redis.ts b/apps/web/src/server/redis.ts
new file mode 100644
index 0000000..767b8c9
--- /dev/null
+++ b/apps/web/src/server/redis.ts
@@ -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;
+};
diff --git a/apps/web/src/server/service/app-settings-service.ts b/apps/web/src/server/service/app-settings-service.ts
deleted file mode 100644
index 5d8ac64..0000000
--- a/apps/web/src/server/service/app-settings-service.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { db } from "../db";
-import { JsonValue } from "@prisma/client/runtime/library";
-
-export class AppSettingsService {
- private static cache: Record = {};
-
- 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 {
- const settings = await db.appSetting.findMany();
- settings.forEach((setting) => {
- this.cache[setting.key] = setting.value;
- });
- }
-}
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index 8a41d84..587bb1d 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -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,
},
});
diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts
new file mode 100644
index 0000000..9199ef6
--- /dev/null
+++ b/apps/web/src/server/service/email-queue-service.ts
@@ -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();
+ private static regionWorker = new Map();
+
+ 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 = 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" },
+ });
+ }
+}
diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts
index 2d772ed..613dff9 100644
--- a/apps/web/src/server/service/email-service.ts
+++ b/apps/web/src/server/service/email-service.ts
@@ -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;
}
diff --git a/apps/web/src/server/service/job-service.ts b/apps/web/src/server/service/job-service.ts
deleted file mode 100644
index 4c1b5bb..0000000
--- a/apps/web/src/server/service/job-service.ts
+++ /dev/null
@@ -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 = 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 },
- });
-}
diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts
index 42eb4aa..4e6b3f6 100644
--- a/apps/web/src/server/service/ses-hook-parser.ts
+++ b/apps/web/src/server/service/ses-hook-parser.ts
@@ -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;
+}
diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts
index 2090214..ca72949 100644
--- a/apps/web/src/server/service/ses-settings-service.ts
+++ b/apps/web/src/server/service/ses-settings-service.ts
@@ -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 = {};
+ private static topicArns: Array = [];
+ private static initialized = false;
- public static getSetting(region = env.AWS_DEFAULT_REGION): SesSetting | null {
+ public static async getSetting(
+ region = env.AWS_DEFAULT_REGION
+ ): Promise {
+ 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,
diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts
index 361d509..bdead57 100644
--- a/apps/web/src/types/index.ts
+++ b/apps/web/src/types/index.ts
@@ -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;
};
diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts
deleted file mode 100644
index b4768d3..0000000
--- a/apps/web/src/utils/constants.ts
+++ /dev/null
@@ -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}`,
-};
diff --git a/apps/web/src/utils/ses-utils.ts b/apps/web/src/utils/ses-utils.ts
index 41d2c08..c589a5b 100644
--- a/apps/web/src/utils/ses-utils.ts
+++ b/apps/web/src/utils/ses-utils.ts
@@ -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;
}
diff --git a/Dockerfile.prod b/docker/Dockerfile
similarity index 97%
rename from Dockerfile.prod
rename to docker/Dockerfile
index 117dac3..52f88af 100644
--- a/Dockerfile.prod
+++ b/docker/Dockerfile
@@ -4,6 +4,7 @@ ENV PATH="$PNPM_HOME:$PATH"
ENV SKIP_ENV_VALIDATION="true"
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
+ENV NEXT_PUBLIC_IS_CLOUD="false"
RUN corepack enable
@@ -14,7 +15,7 @@ RUN apk update
WORKDIR /app
# Replace with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2
-COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json start.sh ./
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY ./apps/web ./apps/web
COPY ./packages ./packages
RUN pnpm add turbo@^1.12.5 -g
@@ -71,6 +72,6 @@ RUN ln -s /app/node_modules/prisma/build/index.js ./node_modules/.bin/prisma
# set this so it throws error where starting server
ENV SKIP_ENV_VALIDATION="false"
-COPY start.sh ./
+COPY ./docker/start.sh ./start.sh
CMD ["sh", "start.sh"]
\ No newline at end of file
diff --git a/docker/build.sh b/docker/build.sh
new file mode 100644
index 0000000..ac1ee24
--- /dev/null
+++ b/docker/build.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+
+command -v docker >/dev/null 2>&1 || {
+ echo "Docker is not running. Please start Docker and try again."
+ exit 1
+}
+
+SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
+MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
+
+APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
+GIT_SHA="$(git rev-parse HEAD)"
+
+echo "Building docker image for monorepo at $MONOREPO_ROOT"
+echo "App version: $APP_VERSION"
+echo "Git SHA: $GIT_SHA"
+
+docker build -f "$SCRIPT_DIR/Dockerfile" \
+ --progress=plain \
+ -t "unsend/unsend:latest" \
+ -t "unsend/unsend:$GIT_SHA" \
+ -t "unsend/unsend:$APP_VERSION" \
+ -t "ghcr.io/unsend/unsend:latest" \
+ -t "ghcr.io/unsend/unsend:$GIT_SHA" \
+ -t "ghcr.io/unsend/unsend:$APP_VERSION" \
+ "$MONOREPO_ROOT"
\ No newline at end of file
diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml
new file mode 100644
index 0000000..d8277da
--- /dev/null
+++ b/docker/dev/compose.yml
@@ -0,0 +1,29 @@
+name: unsend-dev
+
+services:
+ postgres:
+ image: postgres:16
+ container_name: unsend-db-dev
+ restart: always
+ environment:
+ - POSTGRES_USER=unsend
+ - POSTGRES_PASSWORD=password
+ - POSTGRES_DB=unsend
+ volumes:
+ - database:/var/lib/postgresql/data
+ ports:
+ - "54320:5432"
+
+ redis:
+ image: redis:7
+ container_name: unsend-redis-dev
+ restart: always
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis:/data
+ command: ["redis-server", "--maxmemory-policy", "noeviction"]
+
+volumes:
+ database:
+ redis:
diff --git a/docker-compose.yml b/docker/prod/compose.yml
similarity index 65%
rename from docker-compose.yml
rename to docker/prod/compose.yml
index 7306dff..fbb1426 100644
--- a/docker-compose.yml
+++ b/docker/prod/compose.yml
@@ -3,7 +3,7 @@ name: unsend-prod
services:
postgres:
image: postgres:16
- container_name: postgres
+ container_name: unsend-db-prod
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER:?err}
@@ -14,19 +14,23 @@ services:
interval: 10s
timeout: 5s
retries: 5
+ # ports:
+ # - "5432:5432"
volumes:
- database:/var/lib/postgresql/data
- # You don't need to expose this port to the host since, docker compose creates an internal network
- # through which both of these containers could talk to each other using their container_name as hostname
- # But if you want to connect this to a querying tool to debug you can definitely uncomment this
+ redis:
+ image: redis:7
+ container_name: unsend-redis-prod
+ restart: always
# ports:
- # - "5432:5432"
+ # - "6379:6379"
+ volumes:
+ - cache:/data
+ command: ["redis-server", "--maxmemory-policy", "noeviction"]
unsend:
- build:
- dockerfile: Dockerfile
- image: unsend
+ image: unsend/unsend:latest
container_name: unsend
restart: always
ports:
@@ -41,16 +45,15 @@ services:
- AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?err}
- GITHUB_ID=${GITHUB_ID:?err}
- GITHUB_SECRET=${GITHUB_SECRET:?err}
- - APP_URL=${APP_URL:-${NEXTAUTH_URL}}
- - SNS_TOPIC=${SNS_TOPIC:?err}
+ - REDIS_URL=${REDIS_URL:?err}
- NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false}
- - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- - SES_QUEUE_LIMIT=${SES_QUEUE_LIMIT:-1}
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
depends_on:
postgres:
condition: service_healthy
+ redis:
+ condition: service_started
volumes:
database:
+ cache:
diff --git a/start.sh b/docker/start.sh
similarity index 100%
rename from start.sh
rename to docker/start.sh
diff --git a/package.json b/package.json
index a1d3a2c..e8d59e7 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,14 @@
"db:push": "pnpm db db:push",
"db:migrate-dev": "pnpm db db:migrate-dev",
"db:migrate-deploy": "pnpm db db:migrate-deploy",
+ "db:migrate-reset": "pnpm db db:migrate-reset",
"db:studio": "pnpm db db:studio",
"db": "pnpm load-env -- pnpm --filter=web",
- "load-env": "dotenv -e .env"
+ "load-env": "dotenv -e .env",
+ "d": "pnpm dx && pnpm dev",
+ "dx": "pnpm i && pnpm dx:up && pnpm db:migrate-dev",
+ "dx:up": "docker compose -f docker/dev/compose.yml up -d",
+ "dx:down": "docker compose -f docker/dev/compose.yml down"
},
"devDependencies": {
"@unsend/eslint-config": "workspace:*",
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 6b02f15..b12d7d7 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -9,7 +9,8 @@
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --max-warnings 0",
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
- "publish-sdk": "pnpm run build && pnpm publish"
+ "publish-sdk": "pnpm run build && pnpm publish",
+ "openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts"
},
"keywords": [],
"author": "",
diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts
index 91e7d98..05851e1 100644
--- a/packages/sdk/types/schema.d.ts
+++ b/packages/sdk/types/schema.d.ts
@@ -60,7 +60,10 @@ export interface paths {
"application/json": {
id: string;
teamId: number;
- to: string;
+ to: string | string[];
+ replyTo?: string | string[];
+ cc?: string | string[];
+ bcc?: string | string[];
from: string;
subject: string;
html: string | null;
@@ -85,12 +88,13 @@ export interface paths {
requestBody: {
content: {
"application/json": {
- /** Format: email */
- to: string;
+ to: string | string[];
/** Format: email */
from: string;
subject: string;
- replyTo?: string;
+ replyTo?: string | string[];
+ cc?: string | string[];
+ bcc?: string | string[];
text?: string;
html?: string;
attachments?: {
diff --git a/packages/ui/src/code.tsx b/packages/ui/src/code.tsx
index 5922087..0b472b4 100644
--- a/packages/ui/src/code.tsx
+++ b/packages/ui/src/code.tsx
@@ -11,13 +11,16 @@ import { ClipboardCopy, Check } from "lucide-react";
import { useState } from "react";
import { cn } from "../lib/utils";
-type Language = "js" | "ruby" | "php" | "python" | "curl";
+export type Language = "js" | "ruby" | "php" | "python" | "curl";
+
+export type CodeBlock = {
+ language: Language;
+ title?: string;
+ code: string;
+};
type CodeProps = {
- codeBlocks: {
- language: Language;
- code: string;
- }[];
+ codeBlocks: CodeBlock[];
codeClassName?: string;
};
@@ -57,7 +60,7 @@ export const Code: React.FC = ({ codeBlocks, codeClassName }) => {
value={block.language}
className="data-[state=active]:bg-accent py-0.5 px-4 "
>
- {block.language}
+ {block.title || block.language}
))}