fix: propagate SMTP transport errors from EmailService.sendEmail

sendEmail caught transport failures internally and resolved to false or
undefined, so every caller's .catch() was dead code and send failures were
silently suppressed. Password recovery in particular reported "email sent
successfully" while the user received nothing.

sendEmail now throws an AppError carrying the underlying cause, and returns
Promise<string> instead of Promise<string | false | undefined>.

Call sites updated for the new contract:
- UserService.registerUser: the existing .catch() now actually fires
- UserService.requestRecovery: the failure reaches the client instead of a
  misleading success response
- InviteService and the test-email endpoint: keep their user-facing messages
  and attach the SMTP cause
- EmailProvider: catches and returns false, because NotificationsService fans
  providers out through Promise.all and a rejection there would abort the
  other channels' alerts

Closes #3757
This commit is contained in:
egeoztass committed 2026-08-14 14:11:16 +03:00
1 parent f97ca8565a
commit c35b22e1ac
9 files changed
+119 -49

No files matched your search

@@ -93,24 +93,30 @@ class SettingsController implements ISettingsController {
if (!html) {
throw new AppError({ message: "Failed to build email template.", status: 500 });
}
const messageId = await this.emailService.sendEmail(to, subject, html, {
systemEmailHost,
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailSecure,
systemEmailPool,
systemEmailIgnoreTLS,
systemEmailRequireTLS,
systemEmailRejectUnauthorized,
systemEmailTLSServername,
});
if (!messageId) {
throw new AppError({ message: "Failed to send test email.", status: 500 });
let messageId: string;
try {
messageId = await this.emailService.sendEmail(to, subject, html, {
systemEmailHost,
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailSecure,
systemEmailPool,
systemEmailIgnoreTLS,
systemEmailRequireTLS,
systemEmailRejectUnauthorized,
systemEmailTLSServername,
});
} catch (error: unknown) {
// Surface the underlying SMTP failure: diagnosing the settings is the whole
// point of the test email endpoint.
throw new AppError({
message: error instanceof Error ? `Failed to send test email. ${error.message}` : "Failed to send test email.",
status: 500,
});
}
return res.status(200).json({
+4 -2
View File
@@ -96,13 +96,15 @@ export class InviteService implements IInviteService {
});
}
const result = await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html);
if (!result) {
try {
await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html);
} catch (error: unknown) {
throw new AppError({
message: "Failed to send invite e-mail... Please verify your settings.",
service: SERVICE_NAME,
method: "sendInviteEmail",
status: 500,
details: { cause: error instanceof Error ? error.message : "Unknown error" },
});
}
};
@@ -35,16 +35,18 @@ export class EmailProvider extends NotificationProvider {
return false;
}
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
try {
await this.emailService.sendEmail(notification.address, subject, html);
return true;
} catch (error: unknown) {
this.logger.warn({
message: "Email test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
return true;
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
@@ -64,16 +66,20 @@ export class EmailProvider extends NotificationProvider {
return false;
}
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
// Providers must resolve to a boolean: NotificationsService fans these out through
// Promise.all, so a rejection here would abort the other channels' alerts too.
try {
await this.emailService.sendEmail(notification.address, subject, html);
return true;
} catch (error: unknown) {
this.logger.warn({
message: "Email notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
return true;
}
private buildSubject(message: NotificationMessage): string {
+1 -1
View File
@@ -30,7 +30,7 @@ export interface IUserService {
currentUserEmail: string
): Promise<User>;
checkSuperadminExists(): Promise<boolean>;
requestRecovery(email: string): Promise<string | false | undefined>;
requestRecovery(email: string): Promise<string>;
validateRecovery(recoveryToken: string): Promise<void>;
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
deleteUser(params: { userId: string; teamId: string; roles: UserRole[] }): Promise<void>;
+14 -2
View File
@@ -2,6 +2,7 @@ import { fileURLToPath } from "url";
import { EmailTransportConfig } from "@/domain/app-settings/app-settings.type.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import fs from "node:fs";
import path from "node:path";
import nodemailer from "nodemailer";
@@ -20,7 +21,7 @@ type TemplateCompiler = (template: string) => (context: Record<string, unknown>)
export interface IEmailService {
init(): void;
buildEmail(template: string, context: Record<string, unknown>): Promise<string | undefined>;
sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise<string | false | undefined>;
sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise<string>;
}
export class EmailService implements IEmailService {
@@ -151,7 +152,12 @@ export class EmailService implements IEmailService {
method: "verifyTransporter",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
throw new AppError({
message: "Email transporter verification failed",
service: SERVICE_NAME,
method: "sendEmail",
details: { cause: error instanceof Error ? error.message : "Unknown error" },
});
}
const trimmedDisplayName = systemEmailDisplayName?.trim();
@@ -172,6 +178,12 @@ export class EmailService implements IEmailService {
method: "sendEmail",
stack: error instanceof Error ? error.stack : undefined,
});
throw new AppError({
message: "Failed to send email",
service: SERVICE_NAME,
method: "sendEmail",
details: { cause: error instanceof Error ? error.message : "Unknown error" },
});
}
};
}