Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 146 additions & 10 deletions apps/api/src/routes/internal/mailbox/[mailbox]/settings/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { db, MailboxForUser, TempAlias } from "@/db";
import { db, MailboxCustomDomainCustomSend, MailboxForUser, TempAlias } from "@/db";
import { getSession, isValidOrigin } from "@/routes/internal/tools";
import { and, eq, getColumns, gte, or, type InferSelectModel } from "drizzle-orm";
// import { revalidateTag } from "next/cache";
import {
DefaultDomain,
Mailbox,
MailboxAlias,
MailboxCategory,
MailboxCustomDomain,
MailboxTokens,
User
DefaultDomain,
Mailbox,
MailboxAlias,
MailboxCategory,
MailboxCustomDomain,
MailboxTokens,
User
} from "@/db";
import { generateToken } from "@/utils/token";
import { emailSchema } from "@/utils/validations/auth";
Expand All @@ -18,6 +18,8 @@ import { TEMP_EMAIL_EXPIRES_IN } from "@emailthing/const/expiry";
import { aliasLimit, customDomainLimit, mailboxUsersLimit } from "@emailthing/const/limits";
import { createId, init } from "@paralleldrive/cuid2";
import { count, like, not, sql } from "drizzle-orm";
import { env } from "@/utils/env";
import { encryptString } from "@/utils/encrypt";

const createSmallId = init({ length: 7 });

Expand Down Expand Up @@ -76,10 +78,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ mai
"add-user": addUserToMailbox.bind(null, userAccess.role),
"remove-user": removeUserFromMailbox.bind(null, userAccess.role),
"create-temp-alias": createTempAlias,
"set-custom-domain-send": setCustomDomainCustomSend,
"set-custom-domain-dkim": setCustomDomainDKIM,
} as const;


try {
if (!(type in results)) return new Response("Not allowed", { status: 403 });
const result = await results[type as keyof typeof results](mailboxId, data);
if (!result) return new Response("Not allowed", { status: 403 });

Expand All @@ -98,6 +103,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ mai
db
.select()
.from(MailboxCustomDomain)
.leftJoin(
MailboxCustomDomainCustomSend,
eq(MailboxCustomDomainCustomSend.id, MailboxCustomDomain.id),
)
.where(and(eq(MailboxCustomDomain.mailboxId, mailboxId), gte(MailboxCustomDomain.updatedAt, date))),
db
.select()
Expand Down Expand Up @@ -130,10 +139,24 @@ export async function POST(request: Request, { params }: { params: Promise<{ mai
sync: {
mailboxes: mailbox ? [mailbox] : [],
mailboxAliases: mailboxAliases,
mailboxCustomDomains: mailboxCustomDomains,
mailboxCategories: mailboxCategories,
tempAliases: tempAliases,
mailboxesForUser: mailboxesForUser,
mailboxCustomDomains: mailboxCustomDomains.map(({ mailbox_custom_domain: customDomain, mailbox_custom_domain_custom_send: customSend }) => ({
id: customDomain.id,
mailboxId: customDomain.mailboxId,
addedAt: customDomain.addedAt,
updatedAt: customDomain.updatedAt,
domain: customDomain.domain,
// dkimPrivateKey: customDomain.dkimPrivateKey,
dkimSelector: customDomain.dkimSelector,
isDeleted: customDomain.isDeleted,
customSend: customSend ? {
url: customSend.url,
type: customSend.type,
// key: customSend.key,
} : null,
})),
}
} satisfies MappedPossibleDataResponse, { status: 200, headers });
} catch (error) {
Expand Down Expand Up @@ -172,6 +195,8 @@ export type PossibleData =
| AddUserData
| RemoveUserData
| CreateTempAliasData
| SetCustomDomainCustomSendApiData
| SetCustomDomainDKIMData;

export type MappedPossibleData = {
"verify-domain": VerifyDomainData;
Expand All @@ -185,6 +210,8 @@ export type MappedPossibleData = {
"add-user": AddUserData;
"remove-user": RemoveUserData;
"create-temp-alias": CreateTempAliasData;
"set-custom-domain-send": SetCustomDomainCustomSendApiData;
"set-custom-domain-dkim": SetCustomDomainDKIMData;
}

export type MappedPossibleDataResponse =
Expand All @@ -198,7 +225,7 @@ export type MappedPossibleDataResponse =
sync: {
mailboxes: InferSelectModel<typeof Mailbox>[];
mailboxAliases: InferSelectModel<typeof MailboxAlias>[];
mailboxCustomDomains: InferSelectModel<typeof MailboxCustomDomain>[];
mailboxCustomDomains: (Omit<InferSelectModel<typeof MailboxCustomDomain>, 'dkimPrivateKey'> & { customSend: Pick<InferSelectModel<typeof MailboxCustomDomainCustomSend>, 'url' | 'type'> | null })[];
mailboxCategories: InferSelectModel<typeof MailboxCategory>[];
tempAliases: InferSelectModel<typeof TempAlias>[];
mailboxesForUser: InferSelectModel<typeof MailboxForUser>[];
Expand Down Expand Up @@ -837,3 +864,112 @@ async function removeUserFromMailbox(currentUserRole: "OWNER" | "ADMIN" | "NONE"
// revalidateTag(`user-mailboxes-${userId}`);
return { success: `Removed @${proposedUser.username} from this mailbox` }
}

export interface SetCustomDomainCustomSendApiData {
domainId: string;
url: string;
key: string;
type: "RESEND" | "EMAILTHING" | "DISABLED";
}
async function setCustomDomainCustomSend(mailboxId: string, data: SetCustomDomainCustomSendApiData) {
const { domainId } = data;

const [domain] = await db
.select()
.from(MailboxCustomDomain)
.where(and(
eq(MailboxCustomDomain.id, domainId),
eq(MailboxCustomDomain.mailboxId, mailboxId),
eq(MailboxCustomDomain.isDeleted, false),
))
.limit(1);

if (!domain) {
return { error: "Domain not found" };
}

if (data.type === "DISABLED") {
await db.delete(MailboxCustomDomainCustomSend)
.where(eq(MailboxCustomDomainCustomSend.id, domainId))
.execute();

return { success: "Custom API disabled for this domain" }

}
if (!data.url || !data.key) {
return { error: "URL and key are required for RESEND and EMAILTHING types" };
}

const encryptedKey = await encryptString(data.key, env.ENCRYPT_SECRET!);
await db.insert(MailboxCustomDomainCustomSend)
.values({
id: domainId,
url: data.url,
key: encryptedKey,
type: data.type,
})
.onConflictDoUpdate({
target: MailboxCustomDomainCustomSend.id,
set: {
url: data.url,
key: encryptedKey,
type: data.type,
updatedAt: new Date(),
}
})
.execute();

return { success: "Custom API Set! Try to send an email to validate if it works." }
}

export interface SetCustomDomainDKIMData {
domainId: string;
selector?: string;
privateKey?: string;
deleteDKIM?: boolean;
}
async function setCustomDomainDKIM(mailboxId: string, data: SetCustomDomainDKIMData) {
const { domainId } = data;

const [domain] = await db
.select()
.from(MailboxCustomDomain)
.where(and(
eq(MailboxCustomDomain.id, domainId),
eq(MailboxCustomDomain.mailboxId, mailboxId),
eq(MailboxCustomDomain.isDeleted, false),
))
.limit(1);

if (!domain) {
return { error: "Domain not found" };
}

if (data.deleteDKIM) {
await db.update(MailboxCustomDomain)
.set({
dkimSelector: null,
dkimPrivateKey: null,
updatedAt: new Date(),
})
.where(eq(MailboxCustomDomain.id, domainId))
.execute();

return { success: "DKIM settings deleted for this domain" }
}

if (!data.selector || !data.privateKey) {
return { error: "Selector and private key are required" };
}

await db.update(MailboxCustomDomain)
.set({
dkimSelector: data.selector,
dkimPrivateKey: await encryptString(data.privateKey, env.ENCRYPT_SECRET!),
updatedAt: new Date(),
})
.where(eq(MailboxCustomDomain.id, domainId))
.execute();

return { success: "DKIM settings updated!" }
}
79 changes: 69 additions & 10 deletions apps/api/src/routes/internal/send-draft/route.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import db, { Mailbox, MailboxForUser } from "@/db";
import { sendEmail } from "@/utils/send-email";
import { DraftEmail, Email, EmailAttachments, EmailRecipient, EmailSender, MailboxAlias } from "@emailthing/db/connect";
import { DraftEmail, Email, EmailAttachments, EmailRecipient, EmailSender, MailboxAlias, MailboxCustomDomain, MailboxCustomDomainCustomSend } from "@emailthing/db/connect";
import { and, eq, sql } from "drizzle-orm";
import { createMimeMessage } from "mimetext";
import type { ChangesResponse } from "../sync/route";
import { getSession, isValidOrigin } from "../tools";
import { createId } from "@paralleldrive/cuid2";
import { emailSendRatelimit } from "@/utils/redis-ratelimit";
import { sendResendEmail } from "./send-resend";
import { decryptString } from "@/utils/encrypt";
import { env } from "@/utils/env";

export interface Data extends SaveActionProps {
draftId: string;
Expand Down Expand Up @@ -49,17 +52,34 @@ export async function POST(request: Request) {
const currentUserId = await getSession(request);
if (!currentUserId) return Response.json({ error: "Unauthorized" } as SendEmailResponse, { status: 401, headers: responseHeaders });

const [[mailbox], [userAccess]] = await db.batchFetch([
const [[mailbox], [userAccess], [customDomain]] = await db.batchFetch([
db.select({ id: Mailbox.id })
.from(Mailbox)
.where(eq(Mailbox.id, data.mailboxId))
.limit(1),

db.select()
.from(MailboxForUser)
.where(and(
eq(MailboxForUser.mailboxId, data.mailboxId),
eq(MailboxForUser.userId, currentUserId),
eq(MailboxForUser.isDeleted, false)
eq(MailboxForUser.isDeleted, false),
))
.limit(1),

db.select({
customDomain: MailboxCustomDomain,
customSend: MailboxCustomDomainCustomSend,
})
.from(MailboxCustomDomain)
.leftJoin(
MailboxCustomDomainCustomSend,
eq(MailboxCustomDomainCustomSend.id, MailboxCustomDomain.id),
)
.where(and(
eq(MailboxCustomDomain.mailboxId, data.mailboxId),
eq(MailboxCustomDomain.domain, data.from?.split("@")[1] ?? ""),
eq(MailboxCustomDomain.isDeleted, false),
))
.limit(1),
]);
Expand Down Expand Up @@ -157,14 +177,53 @@ export async function POST(request: Request) {
email.setHeader("X-UserId", currentUserId);
email.setHeader("X-MailboxId", mailboxId);

const e = await sendEmail({
from: alias.alias,
to: to.map((e) => e.address),
data: email,
important: true,
});
if (customDomain?.customSend?.type === "RESEND") {
const _headers = headers?.reduce(
(acc: Record<string, string>, { key, value }) => {
acc[key] = value;
return acc;
},
{} as Record<string, string>,
) ?? {};
_headers["X-UserId"] = currentUserId;
_headers["X-MailboxId"] = mailboxId;
_headers["X-EmailThing"] = "true";

const res = await sendResendEmail(
{
from: alias.name ? `${alias.name} <${alias.alias}>` : alias.alias,
to: to.filter((e) => !e.cc).map((e) => e.name ? `${e.name} <${e.address}>` : e.address),
bcc: to.filter((e) => e.cc === "bcc").map((e) => e.name ? `${e.name} <${e.address}>` : e.address),
cc: to.filter((e) => e.cc === "cc").map((e) => e.name ? `${e.name} <${e.address}>` : e.address),
subject,
html,
text: body,
headers: _headers,
},
await decryptString(customDomain.customSend.key, env.ENCRYPT_SECRET),
customDomain.customSend.url,
);

if (e?.error) return Response.json(e, { status: 400, headers: responseHeaders });
if ("error" in res && res.error) {
return Response.json({ error: `Failed to send via Resend: ${res.error}` } as SendEmailResponse, { status: 500, headers: responseHeaders });
}
} else if (customDomain?.customSend?.type === "EMAILTHING") {
return Response.json({ error: "Custom EmailThing sending not implemented yet" } as SendEmailResponse, { status: 500, headers: responseHeaders });
} else {
const e = await sendEmail({
from: alias.alias,
to: to.map((e) => e.address),
data: email,
important: true,
dkim: customDomain?.customDomain.dkimPrivateKey ? {
privateKey: await decryptString(customDomain.customDomain.dkimPrivateKey ?? "", env.ENCRYPT_SECRET),
selector: customDomain.customDomain.dkimSelector ?? "default",
domain: customDomain.customDomain.domain,
} : undefined,
});

if (e?.error) return Response.json(e, { status: 400, headers: responseHeaders });
}

// const emailId = createId()
const emailId = draftId === "new" ? createId() : draftId; // could also make new id here
Expand Down
53 changes: 53 additions & 0 deletions apps/api/src/routes/internal/send-draft/send-resend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
interface SendEmailRequest {
from: string;
to: string | string[];
subject: string;
bcc?: string | string[];
cc?: string | string[];
reply_to?: string | string[];
html?: string;
text?: string;
react?: React.ReactNode;
headers?: Record<string, string>;
scheduledAt?: string;
topicId?: string;
attachments?: {
content?: Buffer | string;
filename?: string;
path?: string;
content_type?: string;
content_id?: string;
}[];
tags?: {
name: string;
value: string;
}[];
template?: {
id: string;
variables?: Record<string, string>;
};
}

interface SendEmailResponse {
id: string;
}

export async function sendResendEmail(data: SendEmailRequest, key: string, url = "https://api.resend.com"): Promise<{ success: SendEmailResponse } | { error: string }> {
const res = await fetch(`${url}/emails`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`,
"user-agent": "EmailThing (https://emailthing.app)",
},
body: JSON.stringify(data),
});

if (!res.ok) {
const errorData = await res.json();
return { error: `${res.status} - ${errorData.message}` };
}

const responseData = await res.json();
return { success: responseData as any };
}
Loading
Loading