From af6fede1b30f1c5ec783837811ff2f830ef9e4b6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Nov 2025 21:17:52 +0000 Subject: [PATCH] email config --- server/src/config/index.ts | 8 +- server/src/controllers/SettingsController.ts | 18 ++- server/src/init/services.ts | 11 +- server/src/routes/settings.ts | 4 +- .../infrastructure/NotificationService.ts | 13 +- .../NotificationServices/Email.ts | 137 +++++++++++++----- server/src/services/system/SettingsService.ts | 49 ++++++- 7 files changed, 179 insertions(+), 61 deletions(-) diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 1ffaa95aa..c634a449e 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -26,8 +26,8 @@ export const config: IEnvConfig = { JWT_SECRET: process.env.JWT_SECRET || "your_jwt_secret", PORT: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000, PAGESPEED_API_KEY: process.env.PAGESPEED_API_KEY || "", - SMTP_HOST: process.env.SMTP_HOST || "smtp.example.com", - SMTP_PORT: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : 587, - SMTP_USER: process.env.SMTP_USER || "user@example.com", - SMTP_PASS: process.env.SMTP_PASS || "your_smtp_password", + SMTP_HOST: process.env.SMTP_HOST || "not_set", + SMTP_PORT: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : -1, + SMTP_USER: process.env.SMTP_USER || "not_set", + SMTP_PASS: process.env.SMTP_PASS || "not_set", }; diff --git a/server/src/controllers/SettingsController.ts b/server/src/controllers/SettingsController.ts index 500a60ff4..f1ed5c2a0 100644 --- a/server/src/controllers/SettingsController.ts +++ b/server/src/controllers/SettingsController.ts @@ -4,7 +4,11 @@ import { NotificationService, SettingsService } from "@/services/index.js"; export interface ISettingsController { get: (req: Request, res: Response, next: NextFunction) => void; - update: (req: Request, res: Response, next: NextFunction) => void; + updateEmailSettings: ( + req: Request, + res: Response, + next: NextFunction + ) => void; testTransport: (req: Request, res: Response, next: NextFunction) => void; } @@ -31,11 +35,17 @@ class SettingsController implements ISettingsController { } }; - update = async (req: Request, res: Response, next: NextFunction) => { + updateEmailSettings = async ( + req: Request, + res: Response, + next: NextFunction + ) => { try { - const updatedSettings = await this.settingsService.update(req.body); + const updatedSettings = await this.settingsService.updateEmailSettings( + req.body + ); res.status(200).json({ - message: "Settings updated successfully", + message: "Email settings updated successfully", data: updatedSettings, }); } catch (error) { diff --git a/server/src/init/services.ts b/server/src/init/services.ts index 2054bfd76..a2988f03f 100644 --- a/server/src/init/services.ts +++ b/server/src/init/services.ts @@ -38,12 +38,17 @@ export const initServices = async () => { const notificationChannelService = new NotificationChannelService(); const userService = new UserService(); const discordService = new DiscordService(); - const emailService = new EmailService(userService); const slackService = new SlackService(); const webhookService = new WebhookService(); const networkService = new NetworkService(got); const statusService = new StatusService(); - const notificationService = new NotificationService(userService); + const settingsService = new SettingsService(); + const emailService = new EmailService(userService, settingsService); + settingsService.setEmailService(emailService); + const notificationService = new NotificationService( + userService, + settingsService + ); const incidentService = new IncidentService(); const jobGenerator = new JobGenerator( networkService, @@ -64,8 +69,6 @@ export const initServices = async () => { const statusPageService = new StatusPageService(); const diagnosticService = new DiagnosticService(jobQueue); const recoveryService = new RecoveryService(); - const settingsService = new SettingsService(); - const services = { checkService, inviteService, diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index a543762c4..b74c03f19 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -27,12 +27,12 @@ class SettingsRoutes { ); this.router.patch( - "/", + "/email", verifyToken, addUserContext, verifyTeamPermission([PERMISSIONS.master]), validateBody(systemSettingsSchema), - this.controller.update + this.controller.updateEmailSettings ); this.router.post( "/test-transport", diff --git a/server/src/services/infrastructure/NotificationService.ts b/server/src/services/infrastructure/NotificationService.ts index 67239eceb..c20e0fe76 100644 --- a/server/src/services/infrastructure/NotificationService.ts +++ b/server/src/services/infrastructure/NotificationService.ts @@ -12,6 +12,7 @@ import { DiscordService, WebhookService, } from "./NotificationServices/index.js"; +import SettingsService from "@/services/system/SettingsService.js"; import ApiError from "@/utils/ApiError.js"; import { getChildLogger } from "@/logger/Logger.js"; import type { IEmailTransport } from "./NotificationServices/Email.js"; @@ -37,7 +38,7 @@ export interface INotificationService { testNotificationChannel: ( notificationChannel: INotificationChannel ) => Promise; - testTransport: (transport: IEmailTransport) => Promise; + testEmailTransport: (transport: IEmailTransport) => Promise; } class NotificationService implements INotificationService { @@ -48,10 +49,10 @@ class NotificationService implements INotificationService { private webhookService: WebhookService; private userService: UserService; - constructor(userService: UserService) { + constructor(userService: UserService, settingsService: SettingsService) { this.SERVICE_NAME = SERVICE_NAME; this.userService = userService; - this.emailService = new EmailService(userService); + this.emailService = new EmailService(userService, settingsService); this.slackService = new SlackService(); this.discordService = new DiscordService(); this.webhookService = new WebhookService(); @@ -111,6 +112,7 @@ class NotificationService implements INotificationService { case "email": const sentEmail = await this.emailService.testMessage(channel); results.push({ + channelId: channel._id, channelName: channel.name, channelType: channel.type, channelUrl: channel.config?.emailAddress || "N/A", @@ -120,6 +122,7 @@ class NotificationService implements INotificationService { case "slack": const sentSlack = await this.slackService.testMessage(channel); results.push({ + channelId: channel._id, channelName: channel.name, channelType: channel.type, channelUrl: channel.config?.url || "N/A", @@ -129,6 +132,7 @@ class NotificationService implements INotificationService { case "discord": const sentDiscord = await this.discordService.testMessage(channel); results.push({ + channelId: channel._id, channelName: channel.name, channelType: channel.type, channelUrl: channel.config?.url || "N/A", @@ -138,6 +142,7 @@ class NotificationService implements INotificationService { case "webhook": const sentWebhook = await this.webhookService.testMessage(channel); results.push({ + channelId: channel._id, channelName: channel.name, channelType: channel.type, channelUrl: channel.config?.url || "N/A", @@ -179,7 +184,7 @@ class NotificationService implements INotificationService { const result: any[] = []; await this.testNotification(notificationChannel, result); if (result.length > 0) { - return true; + return result.find((r) => r._id === notificationChannel._id)?.sent; } return false; }; diff --git a/server/src/services/infrastructure/NotificationServices/Email.ts b/server/src/services/infrastructure/NotificationServices/Email.ts index fc1b00d3a..c652cfd58 100644 --- a/server/src/services/infrastructure/NotificationServices/Email.ts +++ b/server/src/services/infrastructure/NotificationServices/Email.ts @@ -8,7 +8,9 @@ import nodemailer, { Transporter } from "nodemailer"; import { config } from "@/config/index.js"; import UserService from "../../business/UserService.js"; import ApiError from "@/utils/ApiError.js"; +import { SettingsService } from "@/services/index.js"; import { getChildLogger } from "@/logger/Logger.js"; +import { transport } from "winston"; const SERVICE_NAME = "EmailService"; const logger = getChildLogger(SERVICE_NAME); @@ -34,17 +36,74 @@ export interface IEmailService extends IMessageService { content: string ) => Promise; testTransport: (transport: IEmailTransport) => Promise; + rebuildTransport: (transport: IEmailTransport) => Promise; } class EmailService implements IEmailService { public SERVICE_NAME = SERVICE_NAME; - private transporter: Transporter; + private transporter: Transporter | null; private userService: UserService; + private settingsService: SettingsService; + private from: string | null; - constructor(userService: UserService) { + constructor(userService: UserService, settingsService: SettingsService) { this.SERVICE_NAME = SERVICE_NAME; this.userService = userService; - this.transporter = nodemailer.createTransport({ + this.settingsService = settingsService; + this.transporter = null; + this.from = null; + } + + private hasEnvConfig = () => { + return ( + config.SMTP_HOST !== "not_set" || + config.SMTP_PORT !== -1 || + config.SMTP_USER !== "not_set" || + config.SMTP_PASS !== "not_set" + ); + }; + + private buildSystemSettingsTransport = async (transport: IEmailTransport) => { + const host = (transport.systemEmailHost ?? "").trim(); + const port = + transport.systemEmailPort !== undefined && + transport.systemEmailPort !== null + ? Number(transport.systemEmailPort) + : undefined; + const baseOptions = { + port, + secure: transport.systemEmailSecure, + auth: { + user: transport.systemEmailUser || transport.systemEmailAddress, + pass: transport.systemEmailPassword, + }, + name: transport.systemEmailConnectionHost || undefined, + connectionTimeout: 5000, + tls: { + rejectUnauthorized: transport.systemEmailRejectUnauthorized, + ignoreTLS: transport.systemEmailIgnoreTLS, + requireTLS: transport.systemEmailRequireTLS, + servername: transport.systemEmailTLSServername, + }, + }; + + const transportOptions = transport.systemEmailPool + ? { + ...baseOptions, + pool: true, + host, + } + : { + ...baseOptions, + host: host || undefined, + }; + this.from = transport.systemEmailAddress || null; + return nodemailer.createTransport(transportOptions); + }; + + private buildEnvTransport = () => { + this.from = config.SMTP_USER || null; + return nodemailer.createTransport({ host: config.SMTP_HOST, port: config.SMTP_PORT, secure: config.SMTP_PORT === 465, @@ -53,7 +112,16 @@ class EmailService implements IEmailService { pass: config.SMTP_PASS, }, }); - } + }; + + private buildTransport = async () => { + if (this.hasEnvConfig()) { + return this.buildEnvTransport(); + } else { + const systemSettings = await this.settingsService.get(); + return this.buildSystemSettingsTransport(systemSettings); + } + }; buildAlert = (monitor: IMonitor, incident: IIncident) => { const name = monitor?.name || "Unnamed monitor"; @@ -83,20 +151,26 @@ class EmailService implements IEmailService { throw new ApiError("No user emails found", 500); } - await this.transporter.sendMail({ - from: `"Checkmate" <${config.SMTP_USER}>`, + if (this.transporter === null) { + this.transporter = await this.buildTransport(); + } + + const res = await this.transporter.sendMail({ + from: `"Checkmate" <${this.from}>`, to: emails, subject: "Monitor Alert", text: JSON.stringify(alert, null, 2), }); + return true; } catch (error) { + logger.error(error); return false; } }; testMessage = async (channel: INotificationChannel) => { - return this.sendMessage( + return await this.sendMessage( { name: "This is a test", url: "Test URL", @@ -114,8 +188,12 @@ class EmailService implements IEmailService { sendGeneric = async (to: string, subject: string, content: string) => { try { + if (this.transporter === null) { + this.transporter = await this.buildTransport(); + } + await this.transporter.sendMail({ - from: `"Checkmate" <${config.SMTP_USER}>`, + from: `"Checkmate" <${this.from}>`, to: to, subject: subject, text: content, @@ -127,36 +205,13 @@ class EmailService implements IEmailService { }; testTransport = async (transport: IEmailTransport) => { - const host = (transport.systemEmailHost ?? "").trim(); - const baseOptions = { - port: Number(transport.systemEmailPort), - secure: transport.systemEmailSecure, - auth: { - user: transport.systemEmailUser || transport.systemEmailAddress, - pass: transport.systemEmailPassword, - }, - name: transport.systemEmailConnectionHost || undefined, - connectionTimeout: 5000, - tls: { - rejectUnauthorized: transport.systemEmailRejectUnauthorized, - ignoreTLS: transport.systemEmailIgnoreTLS, - requireTLS: transport.systemEmailRequireTLS, - servername: transport.systemEmailTLSServername, - }, - }; - - const transportOptions = transport.systemEmailPool - ? { - ...baseOptions, - pool: true, - host, - } - : { - ...baseOptions, - host: host || undefined, - }; - - const testTransport = nodemailer.createTransport(transportOptions); + if (this.hasEnvConfig()) { + throw new ApiError( + "Cannot test transport when environment SMTP settings are set", + 400 + ); + } + const testTransport = await this.buildSystemSettingsTransport(transport); try { await testTransport.sendMail({ from: `"Checkmate Test" <${transport.systemEmailAddress}>`, @@ -170,6 +225,12 @@ class EmailService implements IEmailService { throw new ApiError(`Email transport test failed`, 400); } }; + + rebuildTransport = async (transport: IEmailTransport) => { + this.transporter = await this.buildSystemSettingsTransport(transport); + this.from = transport.systemEmailAddress || null; + return true; + }; } export default EmailService; diff --git a/server/src/services/system/SettingsService.ts b/server/src/services/system/SettingsService.ts index 50d571660..fe4bc933e 100644 --- a/server/src/services/system/SettingsService.ts +++ b/server/src/services/system/SettingsService.ts @@ -1,19 +1,26 @@ import { ISystemSettings, SystemSettings } from "@/db/models/index.js"; import ApiError from "@/utils/ApiError.js"; - +import { EmailService } from "@/services/index.js"; const SERVICE_NAME = "SettingsService"; export interface ISettingsService { get: () => Promise; - update: (settings: Partial) => Promise; + updateEmailSettings: ( + settings: Partial + ) => Promise; } class SettingsService implements ISettingsService { public SERVICE_NAME: string; + private emailService!: EmailService; constructor() { this.SERVICE_NAME = SERVICE_NAME; } + setEmailService(emailService: EmailService) { + this.emailService = emailService; + } + get = async (): Promise => { let settings = await SystemSettings.findById("global").select( "-_id -__v -createdAt -updatedAt" @@ -34,17 +41,47 @@ class SettingsService implements ISettingsService { return settings; }; - update = async ( + updateEmailSettings = async ( payload: Partial ): Promise => { - const { _id, createdAt, updatedAt, ...updates } = payload as Record< + const allFields = [ + "systemEmailHost", + "systemEmailPort", + "systemEmailAddress", + "systemEmailPassword", + "systemEmailUser", + "systemEmailConnectionHost", + "systemEmailTLSServername", + "systemEmailSecure", + "systemEmailPool", + "systemEmailIgnoreTLS", + "systemEmailRequireTLS", + "systemEmailRejectUnauthorized", + ] as const; + + const { _id, createdAt, updatedAt, ...incoming } = payload as Record< string, unknown >; + const updateDoc: { + $set?: Record; + $unset?: Record; + } = {}; + + for (const field of allFields) { + if (Object.prototype.hasOwnProperty.call(incoming, field)) { + updateDoc.$set ??= {}; + updateDoc.$set[field] = incoming[field]; + } else { + updateDoc.$unset ??= {}; + updateDoc.$unset[field] = 1; + } + } + const settings = await SystemSettings.findOneAndUpdate( { _id: "global" }, - { $set: updates }, + updateDoc, { new: true, upsert: true, setDefaultsOnInsert: true } ); @@ -52,6 +89,8 @@ class SettingsService implements ISettingsService { throw new ApiError("Unable to update system settings", 500); } + this.emailService.rebuildTransport(settings); + return settings; }; }