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

137 lines
3.9 KiB
TypeScript

import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@hanzo/esign-email/mailer';
import { AccessAuth2FAEmailTemplate } from '@hanzo/esign-email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@hanzo/esign-lib/utils/recipients';
import { prisma } from '@hanzo/esign-prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { getEmailContext } from '../../email/get-email-context';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email';
export type Send2FATokenEmailOptions = {
token: string;
envelopeId: string;
};
export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmailOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(
{
type: 'envelopeId',
id: envelopeId,
},
EnvelopeType.DOCUMENT,
),
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
where: {
token,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const [recipient] = envelope.recipients;
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
if (!isRecipientEmailValidForSending(recipient)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient is missing email address',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
envelopeId,
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const subject = i18n._(msg`Your two-factor authentication code`);
const template = createElement(AccessAuth2FAEmailTemplate, {
documentTitle: envelope.title,
userName: recipient.name,
userEmail: recipient.email,
code: twoFactorTokenToken,
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
// One write, so it needs no transaction to be atomic — and outside one the
// send above does not hold the write connection open across the network.
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};