mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
feat: move email sending into background jobs for retry support
Each direct mailer.sendMail() call is replaced by a dedicated background job so that email delivery failures can be retried independently. New jobs: send-pending-email, send-completed-email, send-forgot-password-email, send-document-super-delete-email, send-recipient-removed-email, send-document-deleted-emails, send-2fa-token-email, send-resend-document-email, send-direct-template-created-email. Existing handlers (send-document-cancelled-emails, send-organisation-member-*, send-recipient-signed-email) have io.runTask wrappers removed since they interfere with the job scheduler. Job triggers are dispatched after transactions commit to avoid race conditions with uncommitted data.
This commit is contained in:
@@ -1,30 +1,21 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { DocumentStatus, SendStatus } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/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 { jobs } from '../../jobs/client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export type AdminSuperDeleteDocumentOptions = {
|
||||
envelopeId: string;
|
||||
reason: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const adminSuperDeleteDocument = async ({
|
||||
envelopeId,
|
||||
reason,
|
||||
requestMetadata,
|
||||
}: AdminSuperDeleteDocumentOptions) => {
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
@@ -32,7 +23,6 @@ export const adminSuperDeleteDocument = async ({
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
@@ -50,75 +40,14 @@ export const adminSuperDeleteDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const { status, user } = envelope;
|
||||
const { user } = envelope;
|
||||
|
||||
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
const recipientsToNotify = envelope.recipients.filter((recipient) =>
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
);
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (
|
||||
status === DocumentStatus.PENDING &&
|
||||
recipientsToNotify.length > 0 &&
|
||||
isDocumentDeletedEmailEnabled
|
||||
) {
|
||||
await Promise.all(
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const lang = envelope.documentMeta?.language ?? settings.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// always hard delete if deleted from admin
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Always hard delete if deleted from admin.
|
||||
const deletedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId,
|
||||
@@ -133,4 +62,21 @@ export const adminSuperDeleteDocument = async ({
|
||||
|
||||
return await tx.envelope.delete({ where: { id: envelopeId } });
|
||||
});
|
||||
|
||||
// Notify the document owner after the hard delete transaction commits.
|
||||
// We only send the owner notification; recipient cancellation emails are
|
||||
// omitted because the recipients are hard-deleted with the envelope.
|
||||
if (isDocumentDeletedEmailEnabled) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.super.delete.email',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
documentTitle: envelope.title,
|
||||
reason,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return deletedEnvelope;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@@ -317,7 +316,13 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (pendingRecipients.length > 0) {
|
||||
await sendPendingEmail({ id, recipientId: recipient.id });
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.pending.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
import { createElement } from 'react';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
@@ -47,17 +24,6 @@ export const resendDocument = async ({
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: ResendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
@@ -70,12 +36,6 @@ export const resendDocument = async ({
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,138 +80,20 @@ export const resendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isRecipientSigningRequestEmailEnabled) {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
// Dispatch the email sending to a background job so that email delivery
|
||||
// failures don't block the resend operation and can be retried independently.
|
||||
if (recipientsToRemind.length > 0) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.resend.document.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
userId,
|
||||
teamId: envelope.teamId,
|
||||
recipientIds: recipientsToRemind.map((r) => r.id),
|
||||
requestMetadata,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map(async (recipient) => {
|
||||
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
const { email, name } = recipient;
|
||||
const selfSigner = email === user.email;
|
||||
|
||||
const recipientActionVerb = i18n
|
||||
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
|
||||
.toLowerCase();
|
||||
|
||||
let emailMessage = envelope.documentMeta.message || '';
|
||||
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
|
||||
|
||||
if (selfSigner) {
|
||||
emailMessage = i18n._(
|
||||
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
|
||||
);
|
||||
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
|
||||
}
|
||||
|
||||
if (organisationType === OrganisationType.ORGANISATION) {
|
||||
emailSubject = i18n._(
|
||||
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
|
||||
);
|
||||
emailMessage =
|
||||
envelope.documentMeta.message ||
|
||||
i18n._(
|
||||
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': envelope.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail:
|
||||
organisationType === OrganisationType.ORGANISATION
|
||||
? envelope.team?.teamEmail?.email || user.email
|
||||
: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
organisationType,
|
||||
teamName: envelope.team?.name,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: envelope.documentMeta.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
)
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
const TWENTY_MB_IN_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
id: EnvelopeIdOptions;
|
||||
requestMetadata?: RequestMetadata;
|
||||
@@ -81,7 +83,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
||||
|
||||
const { user: owner } = envelope;
|
||||
|
||||
const completedDocumentEmailAttachments = await Promise.all(
|
||||
let completedDocumentEmailAttachments = await Promise.all(
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
const file = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
@@ -97,6 +99,16 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
||||
}),
|
||||
);
|
||||
|
||||
const allAttachmentsSize = completedDocumentEmailAttachments.reduce(
|
||||
(acc, attachment) => acc + attachment.content.length,
|
||||
0,
|
||||
);
|
||||
|
||||
// If the total size of attachments exceeds 20MB, do not include attachments and instead provide a download link in the email body.
|
||||
if (allAttachmentsSize > TWENTY_MB_IN_BYTES) {
|
||||
completedDocumentEmailAttachments = [];
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Field, Signature } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
@@ -18,14 +15,11 @@ import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
|
||||
|
||||
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, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||
@@ -48,8 +42,6 @@ import {
|
||||
extractDocumentAuthMethods,
|
||||
} from '../../utils/document-auth';
|
||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../utils/teams';
|
||||
import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
@@ -156,7 +148,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
|
||||
const { settings } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -164,7 +156,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const { recipients, directLink, user: templateOwner } = directTemplateEnvelope;
|
||||
const { recipients, directLink } = directTemplateEnvelope;
|
||||
|
||||
const directTemplateRecipient = recipients.find(
|
||||
(recipient) => recipient.id === directLink.directTemplateRecipientId,
|
||||
@@ -755,37 +747,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Send email to template owner.
|
||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||
recipientName: directRecipientEmail,
|
||||
recipientRole: directTemplateRecipient.role,
|
||||
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(createdEnvelope.team?.url)}/${
|
||||
createdEnvelope.id
|
||||
}`,
|
||||
documentName: createdEnvelope.title,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: [
|
||||
{
|
||||
name: templateOwner.name || '',
|
||||
address: templateOwner.email,
|
||||
},
|
||||
],
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`Document created from direct template`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return {
|
||||
createdEnvelope,
|
||||
token: createdDirectRecipient.token,
|
||||
@@ -793,6 +754,17 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
};
|
||||
});
|
||||
|
||||
// Dispatch the email notification to the template owner as a background job.
|
||||
// This is outside the transaction so email failures don't roll back document creation.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.direct.template.created.email',
|
||||
payload: {
|
||||
envelopeId: createdEnvelope.id,
|
||||
teamId: createdEnvelope.teamId,
|
||||
directRecipientId: recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// This handles sending emails and sealing the document if required.
|
||||
await sendDocument({
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from 'crypto';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ONE_DAY } from '../../constants/time';
|
||||
import { sendForgotPassword } from '../auth/send-forgot-password';
|
||||
import { jobs } from '../../jobs/client';
|
||||
|
||||
export const forgotPassword = async ({ email }: { email: string }) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
@@ -46,7 +46,10 @@ export const forgotPassword = async ({ email }: { email: string }) => {
|
||||
},
|
||||
});
|
||||
|
||||
await sendForgotPassword({
|
||||
userId: user.id,
|
||||
}).catch((err) => console.error(err));
|
||||
await jobs.triggerJob({
|
||||
name: 'send.forgot.password.email',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user