Files
documenso/packages/ee/server-only/lib/create-email-domain.ts
David Nguyen 3409aae411 feat: add email domains (#1895)
Implemented Email Domains which allows Platform/Enterprise customers to
send emails to recipients using their custom emails.
2025-07-24 16:05:00 +10:00

155 lines
4.1 KiB
TypeScript

import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { generateKeyPair } from 'crypto';
import { promisify } from 'util';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
export const getSesClient = () => {
const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY');
const region = env('NEXT_PRIVATE_SES_REGION');
if (!accessKeyId || !secretAccessKey || !region) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing AWS SES credentials',
});
}
return new SESv2Client({
region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
};
/**
* Removes first and last line, then removes all newlines
*/
const flattenKey = (key: string) => {
return key.trim().split('\n').slice(1, -1).join('');
};
export async function verifyDomainWithDKIM(domain: string, selector: string, privateKey: string) {
const command = new CreateEmailIdentityCommand({
EmailIdentity: domain,
DkimSigningAttributes: {
DomainSigningSelector: selector,
DomainSigningPrivateKey: privateKey,
},
});
return await getSesClient().send(command);
}
type CreateEmailDomainOptions = {
domain: string;
organisationId: string;
};
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const createEmailDomain = async ({ domain, organisationId }: CreateEmailDomainOptions) => {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const selector = `documenso-${organisationId}`.replace(/[_.]/g, '-');
const recordName = `${selector}._domainkey.${domain}`;
// Check if domain already exists
const existingDomain = await prisma.emailDomain.findUnique({
where: {
domain,
},
});
if (existingDomain) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in database',
});
}
// Generate DKIM key pair
const generateKeyPairAsync = promisify(generateKeyPair);
const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
// Format public key for DNS record
const publicKeyFlattened = flattenKey(publicKey);
const privateKeyFlattened = flattenKey(privateKey);
// Create DNS records
const records: DomainRecord[] = generateEmailDomainRecords(recordName, publicKeyFlattened);
const encryptedPrivateKey = symmetricEncrypt({
key: encryptionKey,
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
emails: true,
},
});
});
return {
emailDomain,
records,
};
};