email config

This commit is contained in:
Alex Holliday committed 2025-11-20 21:17:52 +00:00
1 parent 60153a88e2
commit af6fede1b3
7 files changed
+179 -61

No files matched your search

+4 -4
View File
@@ -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",
};
+14 -4
View File
@@ -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) {
+7 -4
View File
@@ -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,
+2 -2
View File
@@ -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",
@@ -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<Boolean>;
testTransport: (transport: IEmailTransport) => Promise<boolean>;
testEmailTransport: (transport: IEmailTransport) => Promise<boolean>;
}
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;
};
@@ -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<boolean>;
testTransport: (transport: IEmailTransport) => Promise<boolean>;
rebuildTransport: (transport: IEmailTransport) => Promise<boolean>;
}
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;
+44 -5
View File
@@ -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<ISystemSettings>;
update: (settings: Partial<ISystemSettings>) => Promise<ISystemSettings>;
updateEmailSettings: (
settings: Partial<ISystemSettings>
) => Promise<ISystemSettings>;
}
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<ISystemSettings> => {
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<ISystemSettings>
): Promise<ISystemSettings> => {
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<string, unknown>;
$unset?: Record<string, 1>;
} = {};
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;
};
}