mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
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.
137 lines
3.9 KiB
TypeScript
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,
|
|
},
|
|
}),
|
|
});
|
|
};
|