From b7d42775103ab758d0bc92404169a1bf56bc37e8 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 13 Jan 2025 10:36:40 -0800 Subject: [PATCH] Error when sending email takes too long --- apps/backend/src/lib/emails.tsx | 200 +++++++++++++++++--------------- 1 file changed, 109 insertions(+), 91 deletions(-) diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index fee0a5ca8..52c530ce3 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -6,8 +6,9 @@ import { EMAIL_TEMPLATES_METADATA, renderEmailTemplate } from '@stackframe/stack import { ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects'; import { UsersCrud } from '@stackframe/stack-shared/dist/interface/crud/users'; import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; -import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; -import { filterUndefined } from '@stackframe/stack-shared/dist/utils/objects'; +import { StackAssertionError, captureError } from '@stackframe/stack-shared/dist/utils/errors'; +import { filterUndefined, pick } from '@stackframe/stack-shared/dist/utils/objects'; +import { runAsynchronously, wait } from '@stackframe/stack-shared/dist/utils/promises'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { typedToUppercase } from '@stackframe/stack-shared/dist/utils/strings'; import nodemailer from 'nodemailer'; @@ -75,113 +76,130 @@ export async function sendEmailWithKnownErrorTypes(options: SendEmailOptions): P canRetry: boolean, message?: string, }>> { - return await traceSpan('sending email to ' + JSON.stringify(options.to), async () => { - try { - const transporter = nodemailer.createTransport({ - host: options.emailConfig.host, - port: options.emailConfig.port, - secure: options.emailConfig.secure, - auth: { - user: options.emailConfig.username, - pass: options.emailConfig.password, - }, - }); + let finished = false; + runAsynchronously(async () => { + await wait(5000); + if (!finished) { + captureError("email-send-timeout", new StackAssertionError("Email send took longer than 8s; maybe the email service is too slow?", { + config: options.emailConfig.type === 'shared' ? "shared" : pick(options.emailConfig, ['host', 'port', 'username', 'senderEmail', 'senderName']), + to: options.to, + subject: options.subject, + html: options.html, + text: options.text, + })); + } + }); + try { + return await traceSpan('sending email to ' + JSON.stringify(options.to), async () => { + try { + const transporter = nodemailer.createTransport({ + host: options.emailConfig.host, + port: options.emailConfig.port, + secure: options.emailConfig.secure, + auth: { + user: options.emailConfig.username, + pass: options.emailConfig.password, + }, + }); - await transporter.sendMail({ - from: `"${options.emailConfig.senderName}" <${options.emailConfig.senderEmail}>`, - ...options, - }); + await transporter.sendMail({ + from: `"${options.emailConfig.senderName}" <${options.emailConfig.senderEmail}>`, + ...options, + }); - return Result.ok(undefined); - } catch (error) { - if (error instanceof Error) { - const code = (error as any).code as string | undefined; - const responseCode = (error as any).responseCode as number | undefined; - const errorNumber = (error as any).errno as number | undefined; + return Result.ok(undefined); + } catch (error) { + if (error instanceof Error) { + const code = (error as any).code as string | undefined; + const responseCode = (error as any).responseCode as number | undefined; + const errorNumber = (error as any).errno as number | undefined; - const getServerResponse = (error: any) => { - if (error?.response) { - return `\nResponse from the email server:\n${error.response}`; + const getServerResponse = (error: any) => { + if (error?.response) { + return `\nResponse from the email server:\n${error.response}`; + } + return ''; + }; + + if (errorNumber === -3008 || code === 'EDNS') { + return Result.error({ + rawError: error, + errorType: 'HOST_NOT_FOUND', + canRetry: false, + message: 'Failed to connect to the email host. Please make sure the email host configuration is correct.' + } as const); } - return ''; - }; - if (errorNumber === -3008 || code === 'EDNS') { - return Result.error({ - rawError: error, - errorType: 'HOST_NOT_FOUND', - canRetry: false, - message: 'Failed to connect to the email host. Please make sure the email host configuration is correct.' - } as const); + if (responseCode === 535 || code === 'EAUTH') { + return Result.error({ + rawError: error, + errorType: 'AUTH_FAILED', + canRetry: false, + message: 'Failed to authenticate with the email server. Please check your email credentials configuration.', + } as const); + } + + if (responseCode === 450) { + return Result.error({ + rawError: error, + errorType: 'TEMPORARY', + canRetry: true, + message: 'The email server returned a temporary error. This could be due to a temporary network issue or a temporary block on the email server. Please try again later.\n\nError: ' + getServerResponse(error), + } as const); + } + + if (responseCode === 553) { + return Result.error({ + rawError: error, + errorType: 'INVALID_EMAIL_ADDRESS', + canRetry: false, + message: 'The email address provided is invalid. Please verify both the recipient and sender email addresses configuration are correct.\n\nError:' + getServerResponse(error), + } as const); + } + + if (error.message.includes('Unexpected socket close')) { + return Result.error({ + rawError: error, + errorType: 'SOCKET_CLOSED', + canRetry: false, + message: 'Connection to email server was lost unexpectedly. This could be due to incorrect email server port configuration or a temporary network issue. Please verify your configuration and try again.', + } as const); + } } - if (responseCode === 535 || code === 'EAUTH') { + // ============ temporary error ============ + const temporaryErrorIndicators = [ + "450 ", + "Client network socket disconnected before secure TLS connection was established", + "Too many requests", + ...options.emailConfig.host.includes("resend") ? [ + // Resend is a bit unreliable, so we'll retry even in some cases where it may send duplicate emails + "ECONNRESET", + ] : [], + ]; + if (temporaryErrorIndicators.some(indicator => error instanceof Error && error.message.includes(indicator))) { + // this can happen occasionally (especially with certain unreliable email providers) + // so let's retry return Result.error({ rawError: error, - errorType: 'AUTH_FAILED', - canRetry: false, - message: 'Failed to authenticate with the email server. Please check your email credentials configuration.', - } as const); - } - - if (responseCode === 450) { - return Result.error({ - rawError: error, - errorType: 'TEMPORARY', + errorType: 'UNKNOWN', canRetry: true, - message: 'The email server returned a temporary error. This could be due to a temporary network issue or a temporary block on the email server. Please try again later.\n\nError: ' + getServerResponse(error), + message: 'Failed to send email, but error is possibly transient due to the internet connection. Please check your email configuration and try again later.', } as const); } - if (responseCode === 553) { - return Result.error({ - rawError: error, - errorType: 'INVALID_EMAIL_ADDRESS', - canRetry: false, - message: 'The email address provided is invalid. Please verify both the recipient and sender email addresses configuration are correct.\n\nError:' + getServerResponse(error), - } as const); - } - - if (error.message.includes('Unexpected socket close')) { - return Result.error({ - rawError: error, - errorType: 'SOCKET_CLOSED', - canRetry: false, - message: 'Connection to email server was lost unexpectedly. This could be due to incorrect email server port configuration or a temporary network issue. Please verify your configuration and try again.', - } as const); - } - } - - // ============ temporary error ============ - const temporaryErrorIndicators = [ - "450 ", - "Client network socket disconnected before secure TLS connection was established", - "Too many requests", - ...options.emailConfig.host.includes("resend") ? [ - // Resend is a bit unreliable, so we'll retry even in some cases where it may send duplicate emails - "ECONNRESET", - ] : [], - ]; - if (temporaryErrorIndicators.some(indicator => error instanceof Error && error.message.includes(indicator))) { - // this can happen occasionally (especially with certain unreliable email providers) - // so let's retry + // ============ unknown error ============ return Result.error({ rawError: error, errorType: 'UNKNOWN', - canRetry: true, - message: 'Failed to send email, but error is possibly transient due to the internet connection. Please check your email configuration and try again later.', + canRetry: false, + message: 'An unknown error occurred while sending the email.', } as const); } - - // ============ unknown error ============ - return Result.error({ - rawError: error, - errorType: 'UNKNOWN', - canRetry: false, - message: 'An unknown error occurred while sending the email.', - } as const); - } - }); + }); + } finally { + finished = true; + } } export async function sendEmail(options: SendEmailOptions) {