feat: add Twilio SMS notification channel

Adds Twilio SMS as a new notification channel, allowing users to
receive SMS alerts via the Twilio API.

Backend: provider using HTTP Basic Auth, type registration, validation,
service wiring. Uses existing fields: address (Account SID),
accessToken (Auth Token), homeserverUrl (From number), phone (To number).
Frontend: form UI with 4 fields, validation, i18n keys.
Tests: 18 provider tests + updated service test constructor.

Closes #3095

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
egeoztassandClaude Opus 4.6 committed 2026-04-16 21:46:40 +03:00
1 parent e9a0914fad
commit 9210259511
14 files changed
+381 -3

No files matched your search

+10
View File
@@ -60,6 +60,16 @@ function buildDefaults(data: Notification | null): NotificationFormData {
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
homeserverUrl: data.homeserverUrl || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
@@ -149,7 +149,8 @@ const NotificationsCreatePage = () => {
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" && (
watchedType !== "pushover" &&
watchedType !== "twilio" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -262,6 +263,86 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="homeserverUrl"
control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
+1
View File
@@ -8,6 +8,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+9
View File
@@ -62,6 +62,14 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
address: z.string().min(1, "Account SID is required"),
accessToken: z.string().min(1, "Auth token is required"),
phone: z.string().min(1, "Recipient phone number is required"),
homeserverUrl: z.string().min(1, "Twilio phone number is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -72,6 +80,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema,
telegramSchema,
pushoverSchema,
twilioSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+12
View File
@@ -967,6 +967,18 @@
"placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi",
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
"optionAccountSid": "Account SID",
"placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"optionAuthToken": "Auth token",
"placeholderAuthToken": "your_auth_token",
"optionFromNumber": "From number (Twilio)",
"placeholderFromNumber": "+15551234567",
"optionToNumber": "To number (recipient)",
"placeholderToNumber": "+15559876543"
}
},
"table": {
+3
View File
@@ -30,6 +30,7 @@ import {
TeamsProvider,
TelegramProvider,
PushoverProvider,
TwilioProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -300,6 +301,7 @@ export const initializeServices = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -313,6 +315,7 @@ export const initializeServices = async ({
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
settingsService,
logger,
notificationMessageBuilder
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
required: true,
},
notificationName: {
+1
View File
@@ -31,6 +31,7 @@ export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
export * from "@/service/infrastructure/notificationProviders/pushover.js";
export * from "@/service/infrastructure/notificationProviders/twilio.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -0,0 +1,96 @@
const SERVICE_NAME = "TwilioProvider";
import type { Notification } from "@/types/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class TwilioProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) {
return false;
}
try {
await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, {
form: {
To: notification.phone,
From: notification.homeserverUrl,
Body: getTestMessage(),
},
username: notification.address,
password: notification.accessToken,
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Twilio test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) {
return false;
}
const text = this.buildSmsText(message);
try {
await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, {
form: {
To: notification.phone,
From: notification.homeserverUrl,
Body: text,
},
username: notification.address,
password: notification.accessToken,
...this.gotRequestOptions(),
});
this.logger.info({
message: "Twilio SMS notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Twilio SMS alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildSmsText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.title);
lines.push(message.content.summary);
lines.push("");
lines.push(`URL: ${message.monitor.url}`);
lines.push(`Status: ${message.monitor.status}`);
if (message.content.thresholds && message.content.thresholds.length > 0) {
message.content.thresholds.forEach((breach) => {
lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`);
});
}
return lines.join("\n");
}
}
@@ -35,6 +35,7 @@ export class NotificationsService implements INotificationsService {
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private pushoverProvider: INotificationProvider;
private twilioProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -51,6 +52,7 @@ export class NotificationsService implements INotificationsService {
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
pushoverProvider: INotificationProvider,
twilioProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -66,6 +68,7 @@ export class NotificationsService implements INotificationsService {
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.pushoverProvider = pushoverProvider;
this.twilioProvider = twilioProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -107,6 +110,8 @@ export class NotificationsService implements INotificationsService {
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
case "pushover":
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
case "twilio":
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -171,6 +176,8 @@ export class NotificationsService implements INotificationsService {
return await this.telegramProvider.sendTestAlert(notification);
case "pushover":
return await this.pushoverProvider.sendTestAlert(notification);
case "twilio":
return await this.twilioProvider.sendTestAlert(notification);
default:
return false;
}
+12 -1
View File
@@ -1,4 +1,15 @@
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const;
export const NotificationChannels = [
"email",
"slack",
"discord",
"webhook",
"pager_duty",
"matrix",
"teams",
"telegram",
"pushover",
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
@@ -79,6 +79,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("twilio"),
address: z.string().min(1, "Account SID is required"),
accessToken: z.string().min(1, "Auth token is required"),
phone: z.string().min(1, "Recipient phone number is required"),
homeserverUrl: z.string().min(1, "Twilio phone number is required"),
}),
]);
export const testNotificationBodyValidation = createNotificationBodyValidation;
@@ -0,0 +1,135 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new TwilioProvider(logger as any), logger };
};
const makeTwilioNotification = (overrides?: Record<string, unknown>) =>
makeNotification({
phone: "+15559876543",
homeserverUrl: "+15551234567",
...overrides,
});
testNotificationProviderContract("TwilioProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeTwilioNotification(),
});
describe("TwilioProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Twilio API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringContaining("api.twilio.com/2010-04-01/Accounts/"),
expect.objectContaining({
form: expect.objectContaining({ To: "+15559876543", From: "+15551234567" }),
username: expect.any(String),
password: expect.any(String),
})
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false when phone is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false);
});
it("returns false when homeserverUrl (from number) is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ homeserverUrl: "" }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends SMS and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(true);
const form = mockGotPost.mock.calls[0][1].form;
expect(form.Body).toContain("Monitor Down");
expect(form.To).toBe("+15559876543");
expect(form.From).toBe("+15551234567");
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeTwilioNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false when phone is missing", async () => {
expect(await createProvider().provider.sendMessage(makeTwilioNotification({ phone: "" }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeTwilioNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].form.Body).toContain("CPU");
});
it("includes monitor URL in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeTwilioNotification() as any, makeMessage());
expect(mockGotPost.mock.calls[0][1].form.Body).toContain("https://example.com");
});
it("omits thresholds when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
await provider.sendMessage(makeTwilioNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].form.Body;
expect(text).not.toContain("CPU");
});
});
});
@@ -46,6 +46,7 @@ const createService = (overrides?: Record<string, unknown>) => {
const teamsProvider = createProvider();
const telegramProvider = createProvider();
const pushoverProvider = createProvider();
const twilioProvider = createProvider();
const settingsService = createSettingsService();
const notificationMessageBuilder = createMessageBuilder();
@@ -62,6 +63,7 @@ const createService = (overrides?: Record<string, unknown>) => {
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
settingsService,
notificationMessageBuilder,
...overrides,
@@ -79,6 +81,7 @@ const createService = (overrides?: Record<string, unknown>) => {
defaults.teamsProvider as any,
defaults.telegramProvider as any,
defaults.pushoverProvider as any,
defaults.twilioProvider as any,
defaults.settingsService as any,
defaults.logger as any,
defaults.notificationMessageBuilder as any