Skip to content

Commit

Permalink
Added custom SMTP email server testing on the dashboard (#376)
Browse files Browse the repository at this point in the history
  • Loading branch information
fomalhautb authored Dec 20, 2024
1 parent c1416ba commit 534fef5
Show file tree
Hide file tree
Showing 12 changed files with 405 additions and 78 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"Crudl",
"ctsx",
"deindent",
"EAUTH",
"EDNS",
"EMESSAGE",
"Falsey",
"frontends",
"geoip",
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@
"@types/semver": "^7.5.8",
"concurrently": "^8.2.2",
"glob": "^10.4.1",
"import-in-the-middle": "^1.12.0",
"prisma": "^6.0.1",
"require-in-the-middle": "^7.4.0",
"rimraf": "^5.0.5",
"tsup": "^8.3.0",
"tsx": "^4.7.2",
"require-in-the-middle": "^7.4.0",
"import-in-the-middle": "^1.12.0"
"tsx": "^4.7.2"
}
}
67 changes: 67 additions & 0 deletions apps/backend/src/app/api/v1/internal/send-test-email/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { isSecureEmailPort, sendEmailWithKnownErrorTypes } from "@/lib/emails";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, adminAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
project: adaptSchema.defined(),
}).defined(),
body: yupObject({
recipient_email: emailSchema.defined(),
email_config: yupObject({
host: schemaFields.emailHostSchema.defined(),
port: schemaFields.emailPortSchema.defined(),
username: schemaFields.emailUsernameSchema.defined(),
password: schemaFields.emailPasswordSchema.defined(),
sender_name: schemaFields.emailSenderNameSchema.defined(),
sender_email: schemaFields.emailSenderEmailSchema.defined(),
}).defined(),
}).defined(),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
success: yupBoolean().defined(),
error_message: yupString().optional(),
}).defined(),
}),
handler: async ({ body }) => {
const result = await sendEmailWithKnownErrorTypes({
emailConfig: {
type: 'standard',
host: body.email_config.host,
port: body.email_config.port,
username: body.email_config.username,
password: body.email_config.password,
senderEmail: body.email_config.sender_email,
senderName: body.email_config.sender_name,
secure: isSecureEmailPort(body.email_config.port),
},
to: body.recipient_email,
subject: "Test Email from Stack Auth",
text: "This is a test email from Stack Auth. If you successfully received this email, your email server configuration is working correctly.",
});

if (result.status === 'error' && result.error.errorType === 'UNKNOWN') {
captureError("Unknown error sending test email", result.error);
}

return {
statusCode: 200,
bodyType: 'json',
body: {
success: result.status === 'ok',
error_message: result.status === 'error' ? result.error.message : undefined,
},
};
},
});
187 changes: 138 additions & 49 deletions apps/backend/src/lib/emails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,9 @@ export async function getEmailTemplateWithDefault(projectId: string, type: keyof
};
}

function getPortConfig(port: number | string) {
export function isSecureEmailPort(port: number | string) {
let parsedPort = parseInt(port.toString());
const secure = parsedPort === 465;
return { secure };
return parsedPort === 465;
}

export type EmailConfig = {
Expand All @@ -63,62 +62,152 @@ export type EmailConfig = {
type: 'shared' | 'standard',
}

export async function sendEmail({
emailConfig,
to,
subject,
text,
html,
}: {
type SendEmailOptions = {
emailConfig: EmailConfig,
to: string | string[],
subject: string,
html: string,
html?: string,
text?: string,
}) {
await trace.getTracer('stackframe').startActiveSpan('sendEmail', async (span) => {
try {
const transporter = nodemailer.createTransport({
logger: !emailConfig.username.toLowerCase().includes('inbucket'), // the info is not particularly useful for Inbucket, so we don't log anything
host: emailConfig.host,
port: emailConfig.port,
secure: emailConfig.secure,
auth: {
user: emailConfig.username,
pass: emailConfig.password,
},
}

export async function sendEmailWithKnownErrorTypes(options: SendEmailOptions): Promise<Result<undefined, {
rawError: any,
errorType: 'UNKNOWN' | 'HOST_NOT_FOUND' | 'AUTH_FAILED' | 'SOCKET_CLOSED' | 'TEMPORARY' | 'INVALID_EMAIL_ADDRESS',
canRetry: boolean,
message?: string,
}>> {
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,
});

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}`;
}
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.'
});
}

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.',
});
}

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),
});
}

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),
});
}

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.',
});
}
}

// ============ 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: '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.',
});
}

// ============ unknown error ============
return Result.error({
rawError: error,
errorType: 'UNKNOWN',
canRetry: false,
message: 'An unknown error occurred while sending the email.',
});
}
}

export async function sendEmail(options: SendEmailOptions) {
await trace.getTracer('stackframe').startActiveSpan('sendEmail', async (span) => {
try {
return Result.orThrow(await Result.retry(async (attempt) => {
try {
return Result.ok(await transporter.sendMail({
from: `"${emailConfig.senderName}" <${emailConfig.senderEmail}>`,
to,
subject,
text,
html
}));
} catch (error) {
const extraData = { host: emailConfig.host, from: emailConfig.senderEmail, to, subject, cause: error };
const temporaryErrorIndicators = [
"450 ",
"Client network socket disconnected before secure TLS connection was established",
"Too many requests",
...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
console.warn("Failed to send email, but error is possibly transient so retrying.", extraData, error);
return Result.error(error);
const result = await sendEmailWithKnownErrorTypes(options);

if (result.status === 'error') {
const extraData = {
host: options.emailConfig.host,
from: options.emailConfig.senderEmail,
to: options.to,
subject: options.subject,
cause: result.error.rawError,
};

if (result.error.canRetry) {
console.warn("Failed to send email, but error is possibly transient so retrying.", extraData, result.error.rawError);
return Result.error(result.error);
}

// TODO if using custom email config, we should notify the developer instead of throwing an error
throw new StackAssertionError('Failed to send email', extraData);
}

return result;
}, 3, { exponentialDelayBase: 2000 }));
} finally {
span.end();
Expand Down Expand Up @@ -163,7 +252,7 @@ async function getEmailConfig(project: ProjectsCrud["Admin"]["Read"]): Promise<E
password: getEnvVariable('STACK_EMAIL_PASSWORD'),
senderEmail: getEnvVariable('STACK_EMAIL_SENDER'),
senderName: project.display_name,
secure: getPortConfig(getEnvVariable('STACK_EMAIL_PORT')).secure,
secure: isSecureEmailPort(getEnvVariable('STACK_EMAIL_PORT')),
type: 'shared',
};
} else {
Expand All @@ -177,7 +266,7 @@ async function getEmailConfig(project: ProjectsCrud["Admin"]["Read"]): Promise<E
password: projectEmailConfig.password,
senderEmail: projectEmailConfig.sender_email,
senderName: projectEmailConfig.sender_name,
secure: getPortConfig(projectEmailConfig.port).secure,
secure: isSecureEmailPort(projectEmailConfig.port),
type: 'standard',
};
}
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/lib/types.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { PrismaClient } from "@prisma/client";

export type PrismaTransaction = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];

export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"export-to-csv": "^1.3.0",
"geist": "^1",
"jose": "^5.2.2",
"lodash": "^4.17.21",
"lucide-react": "^0.378.0",
"next": "^14.2.5",
"next-themes": "^0.2.1",
Expand All @@ -61,6 +62,7 @@
},
"devDependencies": {
"@types/canvas-confetti": "^1.6.4",
"@types/lodash": "^4.17.5",
"@types/node": "^20.8.10",
"@types/nodemailer": "^6.4.14",
"@types/react": "link:@types/[email protected]",
Expand Down
Loading

0 comments on commit 534fef5

Please sign in to comment.