Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added custom SMTP email server testing on the dashboard #376

Merged
merged 15 commits into from
Dec 20, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -15,6 +15,9 @@
"Crudl",
"ctsx",
"deindent",
"EAUTH",
"EDNS",
"EMESSAGE",
"Falsey",
"frontends",
"geoip",
6 changes: 3 additions & 3 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
@@ -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
@@ -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;
}
Comment on lines +49 to 52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can't determine whether an SMTP server uses TLS or not just by the port (eg. it may be on a different port such as 587, 2525, 25025, etc). Also, 465 is not recommended anyways: https://www.mailgun.com/blog/email/which-smtp-port-understanding-ports-25-465-587/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a setting in the DB for TLS?


export type EmailConfig = {
@@ -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();
@@ -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 {
@@ -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',
};
}
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
@@ -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",
@@ -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]",
Loading