rebrand to useSend (#210)
This commit is contained in:
@@ -14,7 +14,7 @@ export const adminRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
region: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const acc = await getAccount(input.region);
|
||||
@@ -25,15 +25,15 @@ export const adminRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
region: z.string(),
|
||||
unsendUrl: z.string().url(),
|
||||
usesendUrl: z.string().url(),
|
||||
sendRate: z.number(),
|
||||
transactionalQuota: z.number(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return SesSettingsService.createSesSetting({
|
||||
region: input.region,
|
||||
unsendUrl: input.unsendUrl,
|
||||
usesendUrl: input.usesendUrl,
|
||||
sendingRateLimit: input.sendRate,
|
||||
transactionalQuota: input.transactionalQuota,
|
||||
});
|
||||
@@ -45,7 +45,7 @@ export const adminRouter = createTRPCRouter({
|
||||
settingsId: z.string(),
|
||||
sendRate: z.number(),
|
||||
transactionalQuota: z.number(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return SesSettingsService.updateSesSetting({
|
||||
@@ -59,11 +59,11 @@ export const adminRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
region: z.string().optional().nullable(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return SesSettingsService.getSetting(
|
||||
input.region ?? env.AWS_DEFAULT_REGION
|
||||
input.region ?? env.AWS_DEFAULT_REGION,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { CampaignStatus, Prisma } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
status: z.enum(statuses).optional().nullable(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx: { db, team }, input }) => {
|
||||
let completeTime = performance.now();
|
||||
@@ -68,14 +68,14 @@ export const campaignRouter = createTRPCRouter({
|
||||
|
||||
campaignsP.then((campaigns) => {
|
||||
logger.info(
|
||||
`Time taken to get campaigns: ${performance.now() - time} milliseconds`
|
||||
`Time taken to get campaigns: ${performance.now() - time} milliseconds`,
|
||||
);
|
||||
});
|
||||
|
||||
const [campaigns, count] = await Promise.all([campaignsP, countP]);
|
||||
logger.info(
|
||||
{ duration: performance.now() - completeTime },
|
||||
`Time taken to complete request`
|
||||
`Time taken to complete request`,
|
||||
);
|
||||
|
||||
return { campaigns, totalPage: Math.ceil(count / limit) };
|
||||
@@ -87,7 +87,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
name: z.string(),
|
||||
from: z.string(),
|
||||
subject: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team }, input }) => {
|
||||
const domain = await validateDomainFromEmail(input.from, team.id);
|
||||
@@ -113,7 +113,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
content: z.string().optional(),
|
||||
contactBookId: z.string().optional(),
|
||||
replyTo: z.string().array().optional(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
|
||||
const { campaignId, ...data } = input;
|
||||
@@ -161,7 +161,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
where: { id: input.campaignId, teamId: team.id },
|
||||
});
|
||||
return campaign;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => {
|
||||
@@ -194,7 +194,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
sendCampaign: campaignProcedure.mutation(
|
||||
async ({ ctx: { db, team }, input }) => {
|
||||
await sendCampaign(input.campaignId);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
reSubscribeContact: publicProcedure
|
||||
@@ -202,7 +202,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
hash: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { db }, input }) => {
|
||||
await subscribeContact(input.id, input.hash);
|
||||
@@ -223,7 +223,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return newCampaign;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
generateImagePresignedUrl: campaignProcedure
|
||||
@@ -231,7 +231,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { team }, input }) => {
|
||||
const extension = input.name.split(".").pop();
|
||||
@@ -239,7 +239,7 @@ export const campaignRouter = createTRPCRouter({
|
||||
|
||||
const url = await getDocumentUploadUrl(
|
||||
`${team.id}/${randomName}`,
|
||||
input.type
|
||||
input.type,
|
||||
);
|
||||
|
||||
const imageUrl = `${env.S3_COMPATIBLE_PUBLIC_URL}/${team.id}/${randomName}`;
|
||||
|
@@ -1,17 +1,17 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env";
|
||||
import {
|
||||
teamProcedure,
|
||||
createTRPCRouter,
|
||||
templateProcedure
|
||||
templateProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { nanoid } from "~/server/nanoid";
|
||||
import {
|
||||
getDocumentUploadUrl,
|
||||
isStorageConfigured
|
||||
isStorageConfigured,
|
||||
} from "~/server/service/storage-service";
|
||||
|
||||
export const templateRouter = createTRPCRouter({
|
||||
@@ -19,19 +19,17 @@ export const templateRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx: { db, team }, input }) => {
|
||||
const page = input.page || 1;
|
||||
const limit = 30;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
|
||||
const whereConditions: Prisma.TemplateFindManyArgs["where"] = {
|
||||
teamId: team.id,
|
||||
};
|
||||
|
||||
|
||||
const countP = db.template.count({ where: whereConditions });
|
||||
|
||||
const templatesP = db.template.findMany({
|
||||
@@ -61,7 +59,7 @@ export const templateRouter = createTRPCRouter({
|
||||
z.object({
|
||||
name: z.string(),
|
||||
subject: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { db, team }, input }) => {
|
||||
const template = await db.template.create({
|
||||
@@ -80,7 +78,7 @@ export const templateRouter = createTRPCRouter({
|
||||
name: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { db }, input }) => {
|
||||
const { templateId, ...data } = input;
|
||||
@@ -109,7 +107,7 @@ export const templateRouter = createTRPCRouter({
|
||||
where: { id: input.templateId, teamId: team.id },
|
||||
});
|
||||
return template;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
getTemplate: templateProcedure.query(async ({ ctx: { db, team }, input }) => {
|
||||
@@ -139,12 +137,12 @@ export const templateRouter = createTRPCRouter({
|
||||
name: `${template.name} (Copy)`,
|
||||
subject: template.subject,
|
||||
content: template.content,
|
||||
teamId: team.id
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
return newTemplate;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
generateImagePresignedUrl: templateProcedure
|
||||
@@ -152,7 +150,7 @@ export const templateRouter = createTRPCRouter({
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx: { team }, input }) => {
|
||||
const extension = input.name.split(".").pop();
|
||||
@@ -160,7 +158,7 @@ export const templateRouter = createTRPCRouter({
|
||||
|
||||
const url = await getDocumentUploadUrl(
|
||||
`${team.id}/${randomName}`,
|
||||
input.type
|
||||
input.type,
|
||||
);
|
||||
|
||||
const imageUrl = `${env.S3_COMPATIBLE_PUBLIC_URL}/${team.id}/${randomName}`;
|
||||
|
@@ -85,7 +85,8 @@ function generateKeyPair() {
|
||||
export async function addDomain(
|
||||
domain: string,
|
||||
region: string,
|
||||
sesTenantId?: string
|
||||
sesTenantId?: string,
|
||||
dkimSelector: string = "usesend",
|
||||
) {
|
||||
const sesClient = getSesClient(region);
|
||||
|
||||
@@ -93,7 +94,7 @@ export async function addDomain(
|
||||
const command = new CreateEmailIdentityCommand({
|
||||
EmailIdentity: domain,
|
||||
DkimSigningAttributes: {
|
||||
DomainSigningSelector: "unsend",
|
||||
DomainSigningSelector: dkimSelector,
|
||||
DomainSigningPrivateKey: privateKey,
|
||||
},
|
||||
});
|
||||
@@ -114,13 +115,13 @@ export async function addDomain(
|
||||
});
|
||||
|
||||
const tenantResourceAssociationResponse = await sesClient.send(
|
||||
tenantResourceAssociationCommand
|
||||
tenantResourceAssociationCommand,
|
||||
);
|
||||
|
||||
if (tenantResourceAssociationResponse.$metadata.httpStatusCode !== 200) {
|
||||
logger.error(
|
||||
{ tenantResourceAssociationResponse },
|
||||
"Failed to associate domain with tenant"
|
||||
"Failed to associate domain with tenant",
|
||||
);
|
||||
throw new Error("Failed to associate domain with tenant");
|
||||
}
|
||||
@@ -132,7 +133,7 @@ export async function addDomain(
|
||||
) {
|
||||
logger.error(
|
||||
{ response, emailIdentityResponse },
|
||||
"Failed to create domain identity"
|
||||
"Failed to create domain identity",
|
||||
);
|
||||
throw new Error("Failed to create domain identity");
|
||||
}
|
||||
@@ -143,7 +144,7 @@ export async function addDomain(
|
||||
export async function deleteDomain(
|
||||
domain: string,
|
||||
region: string,
|
||||
sesTenantId?: string
|
||||
sesTenantId?: string,
|
||||
) {
|
||||
const sesClient = getSesClient(region);
|
||||
|
||||
@@ -155,13 +156,13 @@ export async function deleteDomain(
|
||||
});
|
||||
|
||||
const tenantResourceAssociationResponse = await sesClient.send(
|
||||
tenantResourceAssociationCommand
|
||||
tenantResourceAssociationCommand,
|
||||
);
|
||||
|
||||
if (tenantResourceAssociationResponse.$metadata.httpStatusCode !== 200) {
|
||||
logger.error(
|
||||
{ tenantResourceAssociationResponse },
|
||||
"Failed to delete tenant resource association"
|
||||
"Failed to delete tenant resource association",
|
||||
);
|
||||
throw new Error("Failed to delete tenant resource association");
|
||||
}
|
||||
@@ -233,7 +234,9 @@ export async function sendRawEmail({
|
||||
bcc,
|
||||
headers: {
|
||||
"X-Entity-Ref-ID": nanoid(),
|
||||
...(emailId ? { "X-Unsend-Email-ID": emailId } : {}),
|
||||
...(emailId
|
||||
? { "X-Usesend-Email-ID": emailId, "X-Unsend-Email-ID": emailId }
|
||||
: {}),
|
||||
...(unsubUrl
|
||||
? {
|
||||
"List-Unsubscribe": `<${unsubUrl}>`,
|
||||
@@ -289,7 +292,7 @@ export async function addWebhookConfiguration(
|
||||
configName: string,
|
||||
topicArn: string,
|
||||
eventTypes: EventType[],
|
||||
region: string
|
||||
region: string,
|
||||
) {
|
||||
const sesClient = getSesClient(region);
|
||||
|
||||
@@ -305,7 +308,7 @@ export async function addWebhookConfiguration(
|
||||
|
||||
const command = new CreateConfigurationSetEventDestinationCommand({
|
||||
ConfigurationSetName: configName, // required
|
||||
EventDestinationName: "unsend_destination", // required
|
||||
EventDestinationName: "usesend_destination", // required
|
||||
EventDestination: {
|
||||
Enabled: true,
|
||||
MatchingEventTypes: eventTypes,
|
||||
|
@@ -16,7 +16,7 @@ interface OtpEmailProps {
|
||||
export function OtpEmail({
|
||||
otpCode,
|
||||
loginUrl,
|
||||
hostName = "Unsend",
|
||||
hostName = "useSend",
|
||||
logoUrl,
|
||||
}: OtpEmailProps) {
|
||||
return (
|
||||
@@ -45,7 +45,7 @@ export function OtpEmail({
|
||||
textAlign: "left" as const,
|
||||
}}
|
||||
>
|
||||
Use the verification code below to sign in to your Unsend account:
|
||||
Use the verification code below to sign in to your useSend account:
|
||||
</Text>
|
||||
|
||||
<Container
|
||||
|
@@ -22,7 +22,7 @@ export function TeamInviteEmail({
|
||||
role = "member",
|
||||
}: TeamInviteEmailProps) {
|
||||
return (
|
||||
<EmailLayout preview={`You've been invited to join ${teamName} on Unsend`}>
|
||||
<EmailLayout preview={`You've been invited to join ${teamName} on useSend`}>
|
||||
<EmailHeader logoUrl={logoUrl} title="You're invited!" />
|
||||
|
||||
<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
|
||||
@@ -50,7 +50,7 @@ export function TeamInviteEmail({
|
||||
{inviterName
|
||||
? `${inviterName} has invited you to join `
|
||||
: "You have been invited to join "}
|
||||
<strong style={{ color: "#000000" }}>{teamName}</strong> on Unsend
|
||||
<strong style={{ color: "#000000" }}>{teamName}</strong> on useSend
|
||||
{role && role !== "member" && (
|
||||
<span>
|
||||
{" "}
|
||||
|
@@ -7,8 +7,8 @@ interface EmailFooterProps {
|
||||
}
|
||||
|
||||
export function EmailFooter({
|
||||
companyName = "Unsend",
|
||||
supportUrl = "https://unsend.dev"
|
||||
companyName = "useSend",
|
||||
supportUrl = "https://usesend.com"
|
||||
}: EmailFooterProps) {
|
||||
return (
|
||||
<Container
|
||||
@@ -40,4 +40,4 @@ export function EmailFooter({
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ export function EmailHeader({ logoUrl, title }: EmailHeaderProps) {
|
||||
{logoUrl && (
|
||||
<Img
|
||||
src={logoUrl}
|
||||
alt="Unsend"
|
||||
alt="useSend"
|
||||
style={{
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
@@ -42,4 +42,4 @@ export function EmailHeader({ logoUrl, title }: EmailHeaderProps) {
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -7,8 +7,8 @@ async function testEmailTemplates() {
|
||||
// Test OTP email
|
||||
const otpHtml = await renderOtpEmail({
|
||||
otpCode: 'ABC123',
|
||||
loginUrl: 'https://app.unsend.dev/login?token=abc123',
|
||||
hostName: 'Unsend',
|
||||
loginUrl: 'https://app.usesend.com/login?token=abc123',
|
||||
hostName: 'useSend',
|
||||
});
|
||||
|
||||
console.log('✅ OTP Email rendered successfully');
|
||||
@@ -17,7 +17,7 @@ async function testEmailTemplates() {
|
||||
// Test Team Invite email
|
||||
const inviteHtml = await renderTeamInviteEmail({
|
||||
teamName: 'My Awesome Team',
|
||||
inviteUrl: 'https://app.unsend.dev/join-team?inviteId=123',
|
||||
inviteUrl: 'https://app.usesend.com/join-team?inviteId=123',
|
||||
});
|
||||
|
||||
console.log('✅ Team Invite Email rendered successfully');
|
||||
@@ -33,4 +33,4 @@ async function testEmailTemplates() {
|
||||
|
||||
if (require.main === module) {
|
||||
testEmailTemplates();
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { env } from "~/env";
|
||||
import { Unsend } from "unsend";
|
||||
import { UseSend } from "usesend";
|
||||
import { isSelfHosted } from "~/utils/common";
|
||||
import { db } from "./db";
|
||||
import { getDomains } from "./service/domain-service";
|
||||
@@ -7,13 +7,13 @@ import { sendEmail } from "./service/email-service";
|
||||
import { logger } from "./logger/log";
|
||||
import { renderOtpEmail, renderTeamInviteEmail } from "./email-templates";
|
||||
|
||||
let unsend: Unsend | undefined;
|
||||
let usesend: UseSend | undefined;
|
||||
|
||||
const getClient = () => {
|
||||
if (!unsend) {
|
||||
unsend = new Unsend(env.UNSEND_API_KEY);
|
||||
if (!usesend) {
|
||||
usesend = new UseSend(env.USESEND_API_KEY ?? env.UNSEND_API_KEY);
|
||||
}
|
||||
return unsend;
|
||||
return usesend;
|
||||
};
|
||||
|
||||
export async function sendSignUpEmail(
|
||||
@@ -28,7 +28,7 @@ export async function sendSignUpEmail(
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = "Sign in to Unsend";
|
||||
const subject = "Sign in to useSend";
|
||||
|
||||
// Use jsx-email template for beautiful HTML
|
||||
const html = await renderOtpEmail({
|
||||
@@ -38,7 +38,7 @@ export async function sendSignUpEmail(
|
||||
});
|
||||
|
||||
// Fallback text version
|
||||
const text = `Hey,\n\nYou can sign in to Unsend by clicking the below URL:\n${url}\n\nYou can also use this OTP: ${token}\n\nThanks,\nUnsend Team`;
|
||||
const text = `Hey,\n\nYou can sign in to useSend by clicking the below URL:\n${url}\n\nYou can also use this OTP: ${token}\n\nThanks,\nuseSend Team`;
|
||||
|
||||
await sendMail(email, subject, text, html);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export async function sendTeamInviteEmail(
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = "You have been invited to join Unsend";
|
||||
const subject = "You have been invited to join useSend";
|
||||
|
||||
// Use jsx-email template for beautiful HTML
|
||||
const html = await renderTeamInviteEmail({
|
||||
@@ -64,7 +64,7 @@ export async function sendTeamInviteEmail(
|
||||
});
|
||||
|
||||
// Fallback text version
|
||||
const text = `Hey,\n\nYou have been invited to join the team ${teamName} on Unsend.\n\nYou can accept the invitation by clicking the below URL:\n${url}\n\nThanks,\nUnsend Team`;
|
||||
const text = `Hey,\n\nYou have been invited to join the team ${teamName} on useSend.\n\nYou can accept the invitation by clicking the below URL:\n${url}\n\nThanks,\nuseSend Team`;
|
||||
|
||||
await sendMail(email, subject, text, html);
|
||||
}
|
||||
@@ -118,15 +118,15 @@ async function sendMail(
|
||||
});
|
||||
|
||||
if (resp.data) {
|
||||
logger.info("Email sent using unsend");
|
||||
logger.info("Email sent using usesend");
|
||||
return;
|
||||
} else {
|
||||
logger.error(
|
||||
{ code: resp.error?.code, message: resp.error?.message },
|
||||
"Error sending email using unsend, so fallback to resend"
|
||||
"Error sending email using usesend, so fallback to resend"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error("UNSEND_API_KEY not found");
|
||||
throw new Error("USESEND_API_KEY/UNSEND_API_KEY not found");
|
||||
}
|
||||
}
|
||||
|
@@ -118,7 +118,7 @@ export function getApp() {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
version: "1.0.0",
|
||||
title: "Unsend API",
|
||||
title: "useSend API",
|
||||
},
|
||||
servers: [{ url: `${env.NEXTAUTH_URL}/api` }],
|
||||
}));
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
|
||||
import { db } from "../db";
|
||||
import { createHash } from "crypto";
|
||||
import { env } from "~/env";
|
||||
@@ -263,7 +263,10 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
const renderer = new EmailRenderer(jsonContent);
|
||||
|
||||
const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId);
|
||||
const oneClickUnsubUrl = createOneClickUnsubUrl(contact.id, emailConfig.campaignId);
|
||||
const oneClickUnsubUrl = createOneClickUnsubUrl(
|
||||
contact.id,
|
||||
emailConfig.campaignId
|
||||
);
|
||||
|
||||
// Check for suppressed emails before processing
|
||||
const toEmails = [contact.email];
|
||||
@@ -303,6 +306,7 @@ async function processContactEmail(jobData: CampaignEmailJob) {
|
||||
},
|
||||
linkValues: {
|
||||
"{{unsend_unsubscribe_url}}": unsubscribeUrl,
|
||||
"{{usesend_unsubscribe_url}}": unsubscribeUrl,
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -41,7 +41,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
|
||||
if (!domain) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Domain: ${fromDomain} of from email is wrong. Use the domain verified by unsend`,
|
||||
message: `Domain: ${fromDomain} of from email is wrong. Use the domain verified by useSend`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ export async function createDomain(
|
||||
}
|
||||
|
||||
const subdomain = tldts.getSubdomain(name);
|
||||
const publicKey = await ses.addDomain(name, region, sesTenantId);
|
||||
const dkimSelector = "usesend";
|
||||
const publicKey = await ses.addDomain(name, region, sesTenantId, dkimSelector);
|
||||
|
||||
const domain = await db.domain.create({
|
||||
data: {
|
||||
@@ -96,6 +97,7 @@ export async function createDomain(
|
||||
subdomain,
|
||||
region,
|
||||
sesTenantId,
|
||||
dkimSelector,
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -3,7 +3,7 @@ import { db } from "../db";
|
||||
import { UnsendApiError } from "~/server/public-api/api-error";
|
||||
import { EmailQueueService } from "./email-queue-service";
|
||||
import { validateDomainFromEmail } from "./domain-service";
|
||||
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
|
||||
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
|
||||
import { logger } from "../logger/log";
|
||||
import { SuppressionService } from "./suppression-service";
|
||||
|
||||
@@ -35,7 +35,7 @@ async function checkIfValidEmail(emailId: string) {
|
||||
|
||||
export const replaceVariables = (
|
||||
text: string,
|
||||
variables: Record<string, string>
|
||||
variables: Record<string, string>,
|
||||
) => {
|
||||
return Object.keys(variables).reduce((accum, key) => {
|
||||
const re = new RegExp(`{{${key}}}`, "g");
|
||||
@@ -48,7 +48,7 @@ export const replaceVariables = (
|
||||
Send transactional email
|
||||
*/
|
||||
export async function sendEmail(
|
||||
emailContent: EmailContent & { teamId: number; apiKeyId?: number }
|
||||
emailContent: EmailContent & { teamId: number; apiKeyId?: number },
|
||||
) {
|
||||
const {
|
||||
to,
|
||||
@@ -84,18 +84,18 @@ export async function sendEmail(
|
||||
|
||||
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
||||
allEmailsToCheck,
|
||||
teamId
|
||||
teamId,
|
||||
);
|
||||
|
||||
// Filter each field separately
|
||||
const filteredToEmails = toEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
const filteredCcEmails = ccEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
const filteredBccEmails = bccEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
|
||||
// Only block the email if all TO recipients are suppressed
|
||||
@@ -105,7 +105,7 @@ export async function sendEmail(
|
||||
to,
|
||||
teamId,
|
||||
},
|
||||
"All TO recipients are suppressed. No emails to send."
|
||||
"All TO recipients are suppressed. No emails to send.",
|
||||
);
|
||||
|
||||
const email = await db.email.create({
|
||||
@@ -147,7 +147,7 @@ export async function sendEmail(
|
||||
filteredCc: filteredCcEmails,
|
||||
teamId,
|
||||
},
|
||||
"Some CC recipients were suppressed and filtered out."
|
||||
"Some CC recipients were suppressed and filtered out.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function sendEmail(
|
||||
filteredBcc: filteredBccEmails,
|
||||
teamId,
|
||||
},
|
||||
"Some BCC recipients were suppressed and filtered out."
|
||||
"Some BCC recipients were suppressed and filtered out.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ export async function sendEmail(
|
||||
acc[`{{${key}}}`] = variables?.[key] || "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -251,7 +251,7 @@ export async function sendEmail(
|
||||
domain.region,
|
||||
true,
|
||||
undefined,
|
||||
delay
|
||||
delay,
|
||||
);
|
||||
} catch (error: any) {
|
||||
await db.emailEvent.create({
|
||||
@@ -280,7 +280,7 @@ export async function updateEmail(
|
||||
scheduledAt,
|
||||
}: {
|
||||
scheduledAt?: string;
|
||||
}
|
||||
},
|
||||
) {
|
||||
const { email, domain } = await checkIfValidEmail(emailId);
|
||||
|
||||
@@ -344,7 +344,7 @@ export async function sendBulkEmails(
|
||||
teamId: number;
|
||||
apiKeyId?: number;
|
||||
}
|
||||
>
|
||||
>,
|
||||
) {
|
||||
if (emailContents.length === 0) {
|
||||
throw new UnsendApiError({
|
||||
@@ -382,18 +382,18 @@ export async function sendBulkEmails(
|
||||
|
||||
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
||||
allEmailsToCheck,
|
||||
content.teamId
|
||||
content.teamId,
|
||||
);
|
||||
|
||||
// Filter each field separately
|
||||
const filteredToEmails = toEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
const filteredCcEmails = ccEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
const filteredBccEmails = bccEmails.filter(
|
||||
(email) => !suppressionResults[email]
|
||||
(email) => !suppressionResults[email],
|
||||
);
|
||||
|
||||
// Only consider it suppressed if all TO recipients are suppressed
|
||||
@@ -410,13 +410,13 @@ export async function sendBulkEmails(
|
||||
suppressed: hasSuppressedToEmails,
|
||||
suppressedEmails: toEmails.filter((email) => suppressionResults[email]),
|
||||
suppressedCcEmails: ccEmails.filter(
|
||||
(email) => suppressionResults[email]
|
||||
(email) => suppressionResults[email],
|
||||
),
|
||||
suppressedBccEmails: bccEmails.filter(
|
||||
(email) => suppressionResults[email]
|
||||
(email) => suppressionResults[email],
|
||||
),
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const validEmails = emailChecks.filter((check) => !check.suppressed);
|
||||
@@ -433,7 +433,7 @@ export async function sendBulkEmails(
|
||||
suppressedAddresses: info.suppressedEmails,
|
||||
})),
|
||||
},
|
||||
"Filtered suppressed emails from bulk send"
|
||||
"Filtered suppressed emails from bulk send",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -490,7 +490,7 @@ export async function sendBulkEmails(
|
||||
acc[`{{${key}}}`] = variables?.[key] || "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -647,7 +647,7 @@ export async function sendBulkEmails(
|
||||
acc[`{{${key}}}`] = variables?.[key] || "";
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -709,7 +709,7 @@ export async function sendBulkEmails(
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
{ err: error, to },
|
||||
`Failed to create email record for recipient`
|
||||
`Failed to create email record for recipient`,
|
||||
);
|
||||
// Continue processing other emails
|
||||
}
|
||||
@@ -744,7 +744,7 @@ export async function sendBulkEmails(
|
||||
where: { id: email.email.id },
|
||||
data: { latestStatus: "FAILED" },
|
||||
});
|
||||
})
|
||||
}),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
@@ -53,7 +53,7 @@ export async function parseSesHook(data: SesEvent) {
|
||||
// Handle race condition: If email not found by sesEmailId, try to find by custom header
|
||||
if (!email) {
|
||||
const emailIdHeader = data.mail.headers.find(
|
||||
(h) => h.name === "X-Unsend-Email-ID"
|
||||
(h) => h.name === "X-Usesend-Email-ID" || h.name === "X-Unsend-Email-ID",
|
||||
);
|
||||
|
||||
if (emailIdHeader?.value) {
|
||||
@@ -71,7 +71,7 @@ export async function parseSesHook(data: SesEvent) {
|
||||
});
|
||||
logger.info(
|
||||
{ emailId: email.id, sesEmailId },
|
||||
"Updated email with sesEmailId from webhook (race condition resolved)"
|
||||
"Updated email with sesEmailId from webhook (race condition resolved)",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,8 +131,8 @@ export async function parseSesHook(data: SesEvent) {
|
||||
? SuppressionReason.HARD_BOUNCE
|
||||
: SuppressionReason.COMPLAINT,
|
||||
source: email.id,
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
@@ -141,7 +141,7 @@ export async function parseSesHook(data: SesEvent) {
|
||||
recipients: recipientEmails,
|
||||
reason: isHardBounced ? "HARD_BOUNCE" : "COMPLAINT",
|
||||
},
|
||||
"Added emails to suppression list due to bounce/complaint"
|
||||
"Added emails to suppression list due to bounce/complaint",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -150,7 +150,7 @@ export async function parseSesHook(data: SesEvent) {
|
||||
recipients: recipientEmails,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
"Failed to add emails to suppression list"
|
||||
"Failed to add emails to suppression list",
|
||||
);
|
||||
// Don't throw error - continue processing the webhook
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export async function parseSesHook(data: SesEvent) {
|
||||
await updateCampaignAnalytics(
|
||||
email.campaignId,
|
||||
mailStatus,
|
||||
isHardBounced
|
||||
isHardBounced,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ async function checkUnsubscribe({
|
||||
event === EmailStatus.BOUNCED
|
||||
? UnsubscribeReason.BOUNCED
|
||||
: UnsubscribeReason.COMPLAINED,
|
||||
})
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -390,13 +390,13 @@ export class SesHookParser {
|
||||
}),
|
||||
async () => {
|
||||
await this.execute(job.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
{
|
||||
connection: getRedis(),
|
||||
concurrency: 50,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
private static async execute(event: SesEvent) {
|
||||
@@ -412,7 +412,7 @@ export class SesHookParser {
|
||||
return await this.sesHookQueue.add(
|
||||
data.messageId,
|
||||
data.event,
|
||||
DEFAULT_QUEUE_OPTIONS
|
||||
DEFAULT_QUEUE_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ export class SesSettingsService {
|
||||
private static initialized = false;
|
||||
|
||||
public static async getSetting(
|
||||
region = env.AWS_DEFAULT_REGION
|
||||
region = env.AWS_DEFAULT_REGION,
|
||||
): Promise<SesSetting | null> {
|
||||
await this.checkInitialized();
|
||||
|
||||
@@ -46,19 +46,19 @@ export class SesSettingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new setting in AWS for the given region and unsendUrl
|
||||
* Creates a new setting in AWS for the given region and usesendUrl
|
||||
*
|
||||
* @param region
|
||||
* @param unsendUrl
|
||||
* @param usesendUrl
|
||||
*/
|
||||
public static async createSesSetting({
|
||||
region,
|
||||
unsendUrl,
|
||||
usesendUrl,
|
||||
sendingRateLimit,
|
||||
transactionalQuota,
|
||||
}: {
|
||||
region: string;
|
||||
unsendUrl: string;
|
||||
usesendUrl: string;
|
||||
sendingRateLimit: number;
|
||||
transactionalQuota: number;
|
||||
}) {
|
||||
@@ -67,15 +67,15 @@ export class SesSettingsService {
|
||||
throw new Error(`SesSetting for region ${region} already exists`);
|
||||
}
|
||||
|
||||
const parsedUrl = unsendUrl.endsWith("/")
|
||||
? unsendUrl.substring(0, unsendUrl.length - 1)
|
||||
: unsendUrl;
|
||||
const parsedUrl = usesendUrl.endsWith("/")
|
||||
? usesendUrl.substring(0, usesendUrl.length - 1)
|
||||
: usesendUrl;
|
||||
|
||||
const unsendUrlValidation = await isValidUnsendUrl(parsedUrl);
|
||||
const usesendUrlValidation = await isValidUsesendUrl(parsedUrl);
|
||||
|
||||
if (!unsendUrlValidation.isValid) {
|
||||
if (!usesendUrlValidation.isValid) {
|
||||
throw new Error(
|
||||
`Unsend URL: ${unsendUrl} is not valid, status: ${unsendUrlValidation.code} message:${unsendUrlValidation.error}`
|
||||
`Callback URL: ${usesendUrl} is not valid, status: ${usesendUrlValidation.code} message:${usesendUrlValidation.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export class SesSettingsService {
|
||||
await sns.subscribeEndpoint(
|
||||
topicArn!,
|
||||
`${setting.callbackUrl}`,
|
||||
setting.region
|
||||
setting.region,
|
||||
);
|
||||
|
||||
return setting;
|
||||
@@ -120,14 +120,14 @@ export class SesSettingsService {
|
||||
EmailQueueService.initializeQueue(
|
||||
region,
|
||||
setting.sesEmailRateLimit,
|
||||
setting.transactionalQuota
|
||||
setting.transactionalQuota,
|
||||
);
|
||||
logger.info(
|
||||
{
|
||||
transactionalQueue: EmailQueueService.transactionalQueue,
|
||||
marketingQueue: EmailQueueService.marketingQueue,
|
||||
},
|
||||
"Email queues initialized"
|
||||
"Email queues initialized",
|
||||
);
|
||||
|
||||
await this.invalidateCache();
|
||||
@@ -138,7 +138,7 @@ export class SesSettingsService {
|
||||
} catch (deleteError) {
|
||||
logger.error(
|
||||
{ err: deleteError },
|
||||
"Failed to delete SNS topic after error"
|
||||
"Failed to delete SNS topic after error",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -172,13 +172,13 @@ export class SesSettingsService {
|
||||
transactionalQueue: EmailQueueService.transactionalQueue,
|
||||
marketingQueue: EmailQueueService.marketingQueue,
|
||||
},
|
||||
"Email queues before update"
|
||||
"Email queues before update",
|
||||
);
|
||||
|
||||
EmailQueueService.initializeQueue(
|
||||
setting.region,
|
||||
setting.sesEmailRateLimit,
|
||||
setting.transactionalQuota
|
||||
setting.transactionalQuota,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
@@ -186,7 +186,7 @@ export class SesSettingsService {
|
||||
transactionalQueue: EmailQueueService.transactionalQueue,
|
||||
marketingQueue: EmailQueueService.marketingQueue,
|
||||
},
|
||||
"Email queues after update"
|
||||
"Email queues after update",
|
||||
);
|
||||
|
||||
await this.invalidateCache();
|
||||
@@ -229,7 +229,7 @@ async function registerConfigurationSet(setting: SesSetting) {
|
||||
configGeneral,
|
||||
setting.topicArn,
|
||||
GENERAL_EVENTS,
|
||||
setting.region
|
||||
setting.region,
|
||||
);
|
||||
|
||||
const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`;
|
||||
@@ -237,7 +237,7 @@ async function registerConfigurationSet(setting: SesSetting) {
|
||||
configClick,
|
||||
setting.topicArn,
|
||||
[...GENERAL_EVENTS, "CLICK"],
|
||||
setting.region
|
||||
setting.region,
|
||||
);
|
||||
|
||||
const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`;
|
||||
@@ -245,7 +245,7 @@ async function registerConfigurationSet(setting: SesSetting) {
|
||||
configOpen,
|
||||
setting.topicArn,
|
||||
[...GENERAL_EVENTS, "OPEN"],
|
||||
setting.region
|
||||
setting.region,
|
||||
);
|
||||
|
||||
const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`;
|
||||
@@ -253,7 +253,7 @@ async function registerConfigurationSet(setting: SesSetting) {
|
||||
configFull,
|
||||
setting.topicArn,
|
||||
[...GENERAL_EVENTS, "CLICK", "OPEN"],
|
||||
setting.region
|
||||
setting.region,
|
||||
);
|
||||
|
||||
return await db.sesSetting.update({
|
||||
@@ -273,7 +273,7 @@ async function registerConfigurationSet(setting: SesSetting) {
|
||||
});
|
||||
}
|
||||
|
||||
async function isValidUnsendUrl(url: string) {
|
||||
async function isValidUsesendUrl(url: string) {
|
||||
logger.info({ url }, "Checking if URL is valid");
|
||||
try {
|
||||
const response = await fetch(`${url}/api/ses_callback`, {
|
||||
|
Reference in New Issue
Block a user