diff --git a/apps/api/src/routes/internal/mailbox/[mailbox]/settings/route.ts b/apps/api/src/routes/internal/mailbox/[mailbox]/settings/route.ts index 5228f3d5..17e94c01 100644 --- a/apps/api/src/routes/internal/mailbox/[mailbox]/settings/route.ts +++ b/apps/api/src/routes/internal/mailbox/[mailbox]/settings/route.ts @@ -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"; @@ -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 }); @@ -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 }); @@ -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() @@ -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) { @@ -172,6 +195,8 @@ export type PossibleData = | AddUserData | RemoveUserData | CreateTempAliasData + | SetCustomDomainCustomSendApiData + | SetCustomDomainDKIMData; export type MappedPossibleData = { "verify-domain": VerifyDomainData; @@ -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 = @@ -198,7 +225,7 @@ export type MappedPossibleDataResponse = sync: { mailboxes: InferSelectModel[]; mailboxAliases: InferSelectModel[]; - mailboxCustomDomains: InferSelectModel[]; + mailboxCustomDomains: (Omit, 'dkimPrivateKey'> & { customSend: Pick, 'url' | 'type'> | null })[]; mailboxCategories: InferSelectModel[]; tempAliases: InferSelectModel[]; mailboxesForUser: InferSelectModel[]; @@ -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!" } +} diff --git a/apps/api/src/routes/internal/send-draft/route.tsx b/apps/api/src/routes/internal/send-draft/route.tsx index 5e17fe0c..73fee0f8 100644 --- a/apps/api/src/routes/internal/send-draft/route.tsx +++ b/apps/api/src/routes/internal/send-draft/route.tsx @@ -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; @@ -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), ]); @@ -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, { key, value }) => { + acc[key] = value; + return acc; + }, + {} as Record, + ) ?? {}; + _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 diff --git a/apps/api/src/routes/internal/send-draft/send-resend.ts b/apps/api/src/routes/internal/send-draft/send-resend.ts new file mode 100644 index 00000000..4b7bd3ee --- /dev/null +++ b/apps/api/src/routes/internal/send-draft/send-resend.ts @@ -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; + 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; + }; +} + +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 }; +} diff --git a/apps/api/src/routes/internal/sync/route.ts b/apps/api/src/routes/internal/sync/route.ts index e7070842..2f9e08a7 100644 --- a/apps/api/src/routes/internal/sync/route.ts +++ b/apps/api/src/routes/internal/sync/route.ts @@ -1,20 +1,21 @@ import db, { - DraftEmail, - Email, - EmailAttachments, - EmailRecipient, - EmailSender, - Mailbox, - MailboxAlias, - MailboxCategory, - MailboxCustomDomain, - MailboxForUser, - TempAlias, - User + DraftEmail, + Email, + EmailAttachments, + EmailRecipient, + EmailSender, + Mailbox, + MailboxAlias, + MailboxCategory, + MailboxCustomDomain, + MailboxCustomDomainCustomSend, + MailboxForUser, + TempAlias, + User } from "@/db"; import { - and, desc, eq, getTableColumns, gte, inArray, lte, - or, sql, type InferSelectModel + and, desc, eq, getTableColumns, gte, inArray, lte, + or, sql, type InferSelectModel } from "drizzle-orm"; // import { hideToken } from "@/(email)/mail/[mailbox]/config/page"; import { env } from "@/utils/env"; @@ -467,6 +468,10 @@ async function getChanges( db .select() .from(MailboxCustomDomain) + .leftJoin( + MailboxCustomDomainCustomSend, + eq(MailboxCustomDomainCustomSend.id, MailboxCustomDomain.id), + ) .where( and( inArray(MailboxCustomDomain.mailboxId, mailboxIds), @@ -564,7 +569,21 @@ async function getChanges( // ...t, // token: hideToken(t.token), // })), - mailboxCustomDomains, + 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, + })), // defaultDomains: defaultDomains.map((d) => ({ // ...d, // authKey: undefined as never, diff --git a/apps/api/src/utils/encrypt.test.ts b/apps/api/src/utils/encrypt.test.ts new file mode 100644 index 00000000..9b5a5613 --- /dev/null +++ b/apps/api/src/utils/encrypt.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { encryptString, decryptString } from "./encrypt"; + +const SECRET = "testpassword123"; +const SECRET2 = "anotherPassword!"; + +async function roundTrip(value: string, secret: string) { + const encrypted = await encryptString(value, secret); + const decrypted = await decryptString(encrypted, secret); + return { encrypted, decrypted }; +} + +describe.concurrent("encryption", () => { + test("should encrypt and decrypt a simple string", async () => { + const { encrypted, decrypted } = await roundTrip("Hello, world!", SECRET); + expect(decrypted).toBe("Hello, world!"); + expect(typeof encrypted).toBe("string"); + expect(encrypted.length).toBeGreaterThan(20); + }); + + test("different secrets produce different outputs for same plaintext", async () => { + const encryptedA = await encryptString("hello world", SECRET); + const encryptedB = await encryptString("hello world", SECRET2); + expect(encryptedA).not.toBe(encryptedB); + }); + + test("encrypting the same plaintext/secret twice gives different output (random IV)", async () => { + const plain = "my test string!"; + const ct1 = await encryptString(plain, SECRET); + const ct2 = await encryptString(plain, SECRET); + expect(ct1).not.toBe(ct2); + }); + + test("decrypt with wrong secret fails (throws/Error)", async () => { + const encrypted = await encryptString("a secret", SECRET); + let failed = false; + try { + await decryptString(encrypted, SECRET2); + } catch { + failed = true; + } + expect(failed).toBe(true); + }); + + test("decrypt fails with tampered data or malformed input", async () => { + const encrypted = await encryptString("tampers test", SECRET); + const parts = encrypted.split(":"); + parts[1] = parts[1]!.slice(0, -1) + (parts[1]!.slice(-1) === 'A' ? 'B' : 'A'); + let errorRaised = false; + try { + await decryptString(parts.join(":"), SECRET); + } catch { + errorRaised = true; + } + expect(errorRaised).toBe(true); + await expect(decryptString("onlyonepart", SECRET)).rejects.toThrow(); + }); + + test("should not handle empty string encryption/decryption", async () => { + expect(encryptString("", SECRET)).rejects.toThrow("Data and secret must be non-empty strings"); + + const hardcoded = "g0uho:60+iQjjRm3t7guL7::wsplISPQE4gSRY0TjlYk+g=="; // encrypted "" + expect(decryptString(hardcoded, SECRET)).rejects.toThrow("Invalid encrypted data format. Expected salt:iv:ciphertext:authTag"); + }); + + test("should handle special characters", async () => { + const data = "!@#$%^&*()_+-=[]{}|;':,.<>/?Привет 世界 🌎"; + const { decrypted } = await roundTrip(data, SECRET); + expect(decrypted).toBe(data); + }); + + test("should handle long strings", async () => { + const data = "x".repeat(10_000); + const { decrypted } = await roundTrip(data, SECRET); + expect(decrypted).toBe(data); + }); +}); + +describe.concurrent("decrypting hardcoded reference ciphertexts", () => { + const fixtures = [ + { + name: "fixture 1: 'Hello there!'", + plaintext: "Hello there!", + secret: SECRET, + ciphertext: "dc91h:5FTHm26nE3WaXieL:haUdYUAxmnkgXV93:WD6RfgxkinUhSrz02sSu1g==", + }, + { + name: "fixture 2: special characters", + plaintext: "p@ssw0rd!#$%^&*()Привет", + secret: SECRET, + ciphertext: "eydt5:xqE1zisSErggSQ6t:IqaTehlHxLbPHzkL/icLbmEX+iqSqotHcoNCDXU=:kMT2C7rhbw2FyHc8SRW77A==", + }, + { + name: "fixture 3: long string", + plaintext: "a".repeat(100), + secret: SECRET, + ciphertext: "jjbtg:Blpm2XsJNCXLDATI:mbNKbXPyedF1P8CfoOe6BYU4JNhpFsAOIMtoZmXaKH73kagWEjlUzY+FY+MN/0zrykkirRjLgYs8ZvGYFM4zJtZ+3vyvWo6UxRJEvZ90PX3wSVoJnPARx1rCA6pRKfz0hTZOfQ==:kHH4f1r7KRehrKAjP+I5Iw==", + }, + ]; + + test.each(fixtures)(`$name should decrypt correctly`, async (fixture) => { + const out = await decryptString(fixture.ciphertext, fixture.secret); + expect(out).toBe(fixture.plaintext); + }); + + test.each(fixtures)(`$name should fail with wrong key`, async (fixture) => { + expect(decryptString(fixture.ciphertext, SECRET2)).rejects.toThrow(); + }); +}); diff --git a/apps/api/src/utils/encrypt.ts b/apps/api/src/utils/encrypt.ts new file mode 100644 index 00000000..43c40854 --- /dev/null +++ b/apps/api/src/utils/encrypt.ts @@ -0,0 +1,66 @@ +import { randomBytes, scrypt as _scrypt, createCipheriv, createDecipheriv } from 'crypto'; +import { promisify } from 'util'; +import { init } from '@paralleldrive/cuid2'; + +const scrypt = promisify(_scrypt); +const createId = init({ length: 5 }); + +/** + * Encrypts a string with a password using AES-256-GCM and per-message salt. + * The output is base64 encoded and includes salt, IV, ciphertext, and authentication tag. + * + * @param data The string to encrypt + * @param secret The password (used for key derivation) + * @returns Promise that resolves to a base64 string: salt:iv:ciphertext:authTag + * + * @example + * const ciphertext = await encryptString('my secret text', 'password123'); + * + */ +export async function encryptString(data: string, secret: string): Promise { + if (!data || !secret) throw new Error("Data and secret must be non-empty strings"); + + // Generate a random salt (5 chars) + const salt = createId(); + const iv = randomBytes(12); // 96 bits, best for GCM + const key = (await scrypt(secret, salt, 32)) as Buffer; + + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + // Output format: salt:iv:ciphertext:authTag (each base64 except salt which is already ascii) + return [salt, iv.toString('base64'), encrypted.toString('base64'), authTag.toString('base64')].join(':'); +} + +/** + * Decrypts data encrypted by encryptString using the same password. + * + * @param encryptedData The base64 salt:iv:ciphertext:authTag string + * @param secret The password (used for key derivation) + * @returns Promise that resolves to the decrypted string + * + * @example + * const plain = await decryptString(ciphertext, 'password123'); + */ +export async function decryptString(encryptedData: string, secret: string): Promise { + const [salt, ivB64, encryptedB64, authTagB64] = encryptedData.split(':'); + if (!salt || !ivB64 || !encryptedB64 || !authTagB64) { + throw new Error('Invalid encrypted data format. Expected salt:iv:ciphertext:authTag'); + } + const iv = Buffer.from(ivB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + const key = (await scrypt(secret, salt, 32)) as Buffer; + + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); +} + +/** + * NOTES/BEST PRACTICES: + * - Each ciphertext has a unique random salt, preventing rainbow table attacks. + * - Output is salt (5-char CUID2, ascii), iv (base64), ciphertext (base64), authtag (base64). + * - These functions are fully async. + */ diff --git a/apps/api/src/utils/env.ts b/apps/api/src/utils/env.ts index aa98d99e..5a30103b 100644 --- a/apps/api/src/utils/env.ts +++ b/apps/api/src/utils/env.ts @@ -13,6 +13,7 @@ const envSchema = z.object({ S3_URL: z.string(), S3_BUCKET: z.string(), NEXT_PUBLIC_NOTIFICATIONS_PUBLIC_KEY: z.string().check(z.minLength(1)), + ENCRYPT_SECRET: z.string().check(z.minLength(1)), }); const _env = envSchema.safeParse({ @@ -28,6 +29,7 @@ const _env = envSchema.safeParse({ S3_URL: process.env.S3_URL, S3_BUCKET: process.env.S3_BUCKET || "email", NEXT_PUBLIC_NOTIFICATIONS_PUBLIC_KEY: process.env.NEXT_PUBLIC_NOTIFICATIONS_PUBLIC_KEY, + ENCRYPT_SECRET: process.env.ENCRYPT_SECRET, }); if (!_env.success) { diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 81f3921c..56d720d6 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/apps/pwa/src/(app)/mailbox-config/_api.ts b/apps/pwa/src/(app)/mailbox-config/_api.ts index ba21aa26..13c3b1ae 100644 --- a/apps/pwa/src/(app)/mailbox-config/_api.ts +++ b/apps/pwa/src/(app)/mailbox-config/_api.ts @@ -1,7 +1,7 @@ import type { MappedPossibleData, MappedPossibleDataResponse -} from "@/../app/api/internal/mailbox/[mailbox]/settings/route"; +} from "@/../../apps/api/src/routes/internal/mailbox/[mailbox]/settings/route"; import { db } from "@/utils/data/db"; import { getLogedInUserApi } from "@/utils/data/queries/user"; import { parseSync } from "@/utils/data/sync-user"; diff --git a/apps/pwa/src/(app)/mailbox-config/custom-domains.tsx b/apps/pwa/src/(app)/mailbox-config/custom-domains.tsx index 579b7da4..48af8f5b 100644 --- a/apps/pwa/src/(app)/mailbox-config/custom-domains.tsx +++ b/apps/pwa/src/(app)/mailbox-config/custom-domains.tsx @@ -2,7 +2,8 @@ import CopyButton from "@/components/copy-button.client"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger + DropdownMenuGroup, + DropdownMenuItem, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -22,12 +23,16 @@ import { CopyIcon, Loader2, MoreHorizontalIcon, PlusIcon, Trash2Icon } from "lucide-react"; -import { lazy, Suspense, useState, useTransition } from "react"; +import { FormEvent, lazy, Suspense, useEffect, useState, useTransition } from "react"; import { useParams } from "react-router-dom"; import { toast } from "sonner"; import { useSWRConfig } from "swr"; import { DeleteButton } from "./components.client"; import changeMailboxSettings from "./_api"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { getTXT } from "@/utils/use-dns"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const CfWorkerCode = lazy(() => import("./custom-domain-dyn")); @@ -52,6 +57,17 @@ const deleteCustomDomain = async (mailboxId: string, domainId: string) => { return changeMailboxSettings(mailboxId, "delete-custom-domain", { domainId }); }; +const setCustomSend = async (mailboxId: string, domainId: string, url: string, key: string, type: "RESEND" | "EMAILTHING" | "DISABLED") => { + return changeMailboxSettings(mailboxId, "set-custom-domain-send", { domainId, url, key, type }); +} + +const setDKIM = async (mailboxId: string, domainId: string, selector: string, privateKey: string) => { + return changeMailboxSettings(mailboxId, "set-custom-domain-dkim", { domainId, selector, privateKey }); +} +const deleteDKIM = async (mailboxId: string, domainId: string) => { + return changeMailboxSettings(mailboxId, "set-custom-domain-dkim", { domainId, deleteDKIM: true }); +} + export default function CustomDomains() { const { mailboxId } = useParams<{ mailboxId: string }>(); @@ -148,6 +164,34 @@ export default function CustomDomains() { + + + + + + Advanced + + + + + Custom Send API + + + + + + + + DKIM Settings + + + + + + + + + @@ -163,7 +207,7 @@ export default function CustomDomains() { - + ); } @@ -483,3 +527,365 @@ export function AddCustomDomainForm({ mailboxId, initialDomain = "" }: { mailbox // ); } + +export function SetCustomSend({ + mailboxId, + id, + existingType, + existingURL, + // secrent key not sent anywhere +}: { mailboxId: string; id: string; existingType?: "RESEND" | "EMAILTHING", existingURL?: string }) { + const [isPending, startTransition] = useTransition(); + + const formSubmit = (event: FormEvent) => { + event.preventDefault(); + if (isPending) return; + + startTransition(async () => { + const res = await setCustomSend(mailboxId, id, event.currentTarget.url.value, event.currentTarget.secretKey.value, event.currentTarget.type.value); + if ('error' in res) { + toast.error(res.error); + } else { + toast.success("Custom send API configuration updated!", { description: "Try to send an email to validate if it works" }); + document.getElementById("smart-drawer:close")?.click(); + } + }); + }; + + return ( + <> + + + Set Custom Send API + + + Instead of using EmailThing's sending infrastructure, you can use your own sending provider using this feature. + + + + +
+ + + +
+ + + + + + + + +
+ +
+ + + + ); +} + + +// Helper to turn ArrayBuffer to base64 +function ab2b64(buf: ArrayBuffer): string { + return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)))); +} + +function pkcs8ToPem(b64: string) { + const lines = ["-----BEGIN PRIVATE KEY-----"]; + for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64)); + // lines.push(b64); + lines.push("-----END PRIVATE KEY-----"); + return lines.join("\n"); +} + +const pemToArrayBuffer = (value: string): ArrayBuffer => { + const clean = value + .replace(/-----BEGIN PRIVATE KEY-----/g, "") + .replace(/-----END PRIVATE KEY-----/g, "") + .replace(/\s+/g, ""); + const bin = atob(clean); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; +}; + +// DKIM Settings UI +export function CustomDomainDKIMSettings({ domain, mailboxId, domainId, existingSelector }: { domain: string; mailboxId: string; domainId: string; existingSelector?: string }) { + const [pubB64, setPubB64] = useState(""); + const [privPKCS8, setPrivPKCS8] = useState(null); + const [dkimSelector, setDkimSelector] = useState(existingSelector || "emailthing"); + + const pem = pkcs8ToPem(ab2b64(privPKCS8!)); + const dkimValue = `v=DKIM1; k=rsa; p=${pubB64}`; + + async function handleGenerate(providedPem?: string) { + try { + if (providedPem?.trim()) { + const pkcs8Raw = pemToArrayBuffer(providedPem); + + const privateKey = await window.crypto.subtle.importKey( + "pkcs8", + pkcs8Raw, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + true, + ["sign"] + ); + + const privateJwk = await window.crypto.subtle.exportKey("jwk", privateKey); + if (!privateJwk.n || !privateJwk.e) throw new Error("Invalid private key"); + + const publicKey = await window.crypto.subtle.importKey( + "jwk", + { + kty: "RSA", + n: privateJwk.n, + e: privateJwk.e, + alg: "RS256", + ext: true, + key_ops: ["verify"], + }, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + true, + ["verify"] + ); + + const pubRaw = await window.crypto.subtle.exportKey("spki", publicKey); + setPubB64(ab2b64(pubRaw)); + setPrivPKCS8(pkcs8Raw); + return; + } + } catch { + toast.error("Invalid PEM provided. Generated a new key pair instead."); + } + + const pair = await window.crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + + const pubRaw = await window.crypto.subtle.exportKey("spki", (pair as CryptoKeyPair).publicKey); + setPubB64(ab2b64(pubRaw)); + + const privRaw = await window.crypto.subtle.exportKey("pkcs8", (pair as CryptoKeyPair).privateKey); + setPrivPKCS8(privRaw); + } + + useEffect(() => { + handleGenerate(); + }, []); + + const [isPending, startTransition] = useTransition(); + + const formSubmit = (event: FormEvent) => { + event.preventDefault(); + if (isPending) return; + + startTransition(async () => { + const dnsRes = await getTXT(`${dkimSelector}._domainkey.${domain}`); + if (!dnsRes?.length) { + toast.error("DNS record not set yet.", { description: "Please add the TXT record shown in the form and wait for it to propagate before saving." }); + return; + } + if (dnsRes.length > 1) { + toast.error("Multiple TXT records found for this selector.", { description: "Please make sure there is only one TXT record for this selector and it matches the value shown in the form." }); + return; + } + if (dnsRes[0].replace(/\\?" ?/g, "") !== dkimValue) { + toast.error("DNS record value is incorrect.", { description: "Please make sure the TXT record value matches the one shown in the form." }); + return; + } + + const res = await setDKIM(mailboxId, domainId, dkimSelector, pkcs8ToPem(ab2b64(privPKCS8!))); + if ('error' in res) { + toast.error(res.error); + } else { + toast.success("Custom DKIM configuration saved!", { description: "Try to send an email to validate if it works" }); + document.getElementById("smart-drawer:close")?.click(); + } + }); + }; + + const formSubmitDelete = (event: FormEvent) => { + event.preventDefault(); + if (isPending) return; + + startTransition(async () => { + const res = await deleteDKIM(mailboxId, domainId); + if ('error' in res) { + toast.error(res.error); + } else { + toast.success("DKIM configuration deleted!"); + document.getElementById("smart-drawer:close")?.click(); + } + }); + }; + + + return ( + <> + + + Custom DKIM + + + Sign your emails with DKIM by adding the folowing DNS record and saving this! + + + + + Easy + Custom + {existingSelector && Remove} + + +
+
+ + +
+ + +
+
+ + +
+
+ +
+ +
+ +