mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 01:32:06 +10:00
feat: add email domains (#1895)
Implemented Email Domains which allows Platform/Enterprise customers to send emails to recipients using their custom emails.
This commit is contained in:
154
packages/ee/server-only/lib/create-email-domain.ts
Normal file
154
packages/ee/server-only/lib/create-email-domain.ts
Normal file
@ -0,0 +1,154 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
52
packages/ee/server-only/lib/delete-email-domain.ts
Normal file
52
packages/ee/server-only/lib/delete-email-domain.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getSesClient } from './create-email-domain';
|
||||
|
||||
type DeleteEmailDomainOptions = {
|
||||
emailDomainId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete the email domain and SES email identity.
|
||||
*
|
||||
* Permission is assumed to be checked in the caller.
|
||||
*/
|
||||
export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions) => {
|
||||
const emailDomain = await prisma.emailDomain.findUnique({
|
||||
where: {
|
||||
id: emailDomainId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!emailDomain) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email domain not found',
|
||||
});
|
||||
}
|
||||
|
||||
const sesClient = getSesClient();
|
||||
|
||||
await sesClient
|
||||
.send(
|
||||
new DeleteEmailIdentityCommand({
|
||||
EmailIdentity: emailDomain.domain,
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
// Do nothing if it no longer exists in SES.
|
||||
if (err.name === 'NotFoundException') {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.emailDomain.delete({
|
||||
where: {
|
||||
id: emailDomainId,
|
||||
},
|
||||
});
|
||||
};
|
||||
45
packages/ee/server-only/lib/verify-email-domain.ts
Normal file
45
packages/ee/server-only/lib/verify-email-domain.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getSesClient } from './create-email-domain';
|
||||
|
||||
export const verifyEmailDomain = async (emailDomainId: string) => {
|
||||
const emailDomain = await prisma.emailDomain.findUnique({
|
||||
where: {
|
||||
id: emailDomainId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!emailDomain) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email domain not found',
|
||||
});
|
||||
}
|
||||
|
||||
const sesClient = getSesClient();
|
||||
|
||||
const response = await sesClient.send(
|
||||
new GetEmailIdentityCommand({
|
||||
EmailIdentity: emailDomain.domain,
|
||||
}),
|
||||
);
|
||||
|
||||
const isVerified = response.VerificationStatus === 'SUCCESS';
|
||||
|
||||
const updatedEmailDomain = await prisma.emailDomain.update({
|
||||
where: {
|
||||
id: emailDomainId,
|
||||
},
|
||||
data: {
|
||||
status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
emailDomain: updatedEmailDomain,
|
||||
isVerified,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user