Files
zooqueen 21c8e47076 fix: send mail after the commit, not inside the transaction
Base SQLite serves one write connection. A call that leaves the process
does not deadlock the way a second Prisma client does, but it holds that
one connection open across the network: every other write in the process
waits on a mail server, and a slow send reaches the same P2028 timeout by
a longer road.

Six waits sat inside a transaction. Three of them wrapped a single write,
so the transaction was doing nothing — keep the order, drop it, and one
write is still atomic on its own. The fourth returns the envelope it
created and tells the template owner about it afterwards, where a bounced
notification is logged rather than rolling back a document that exists.

The rule then asks the general question instead of naming callees: is the
client called tx, does the callback name a client that is not tx, and does
it await a call it was never handed tx. Jobs and webhooks fall out of the
third, along with mail and anything else that leaves the process, and it
reads the call site's shape rather than its callee's name — so a helper
that reaches the world from its own body is no longer invisible.
2026-08-14 15:02:12 -07:00

158 lines
4.2 KiB
TypeScript

import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { generateKeyPair } from 'crypto';
import { promisify } from 'util';
import { prisma } from '@hanzo/esign-prisma';
import { SIGN_ENCRYPTION_KEY } from '../../constants/crypto';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { symmetricEncrypt } from '../../universal/crypto';
import { generateDatabaseId } from '../../universal/id';
import { generateEmailDomainRecords } from '../../utils/email-domains';
import { env } from '../../utils/env';
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 = SIGN_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing SIGN_ENCRYPTION_KEY');
}
const selector = `sign-${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,
});
// SES owns the domain identity, so it decides first: a rejection here leaves
// nothing to undo. The row below is one write, atomic on its own, and outside
// a transaction this call does not hold the write connection open across the
// network.
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;
});
const emailDomain = await prisma.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,
lastVerifiedAt: true,
emails: true,
},
});
return {
emailDomain,
records,
};
};