This commit is contained in:
Raj Nandan Sharma
2026-01-27 11:40:57 +05:30
parent c4f094572d
commit d0d8e60a8f
34 changed files with 2419 additions and 110 deletions
@@ -0,0 +1,27 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create email_templates_config table
await knex.schema.createTable("email_templates_config", (table) => {
table.increments("id").primary();
table.string("email_type", 50).notNullable().unique(); // email_code, forgot_password, verify_email
table.integer("email_template_id").unsigned().nullable();
table.string("is_active", 3).notNullable().defaultTo("NO"); // YES or NO
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
// Foreign key to templates table
table.foreign("email_template_id").references("id").inTable("templates").onDelete("SET NULL");
});
// Pre-fill with default email types
await knex("email_templates_config").insert([
{ email_type: "email_code", email_template_id: null, is_active: "NO" },
{ email_type: "forgot_password", email_template_id: null, is_active: "NO" },
{ email_type: "verify_email", email_template_id: null, is_active: "NO" },
]);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("email_templates_config");
}
@@ -0,0 +1,16 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// Create vault table for storing encrypted secrets
await knex.schema.createTable("vault", (table) => {
table.increments("id").primary();
table.string("secret_name", 255).notNullable().unique();
table.text("secret_value").notNullable(); // Encrypted value
table.timestamp("created_at").defaultTo(knex.fn.now());
table.timestamp("updated_at").defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists("vault");
}
+12
View File
@@ -2,6 +2,8 @@ import defaultEmailTemplate from "../src/lib/server/templates/email_alert_templa
import defaultWebhookTemplate from "../src/lib/server/templates/webhook_alert_template.ts";
import defaultSlackTemplate from "../src/lib/server/templates/slack_alert_template.ts";
import defaultDiscordTemplate from "../src/lib/server/templates/discord_alert_template.ts";
import defaultLoginEmailCodeTemplate from "../src/lib/server/templates/email_code_template.ts";
import defaultEmailUpdateTemplate from "../src/lib/server/templates/email_update_template.ts";
import type { Knex } from "knex";
@@ -28,5 +30,15 @@ export async function seed(knex: Knex): Promise<void> {
updated_at: knex.fn.now(),
...defaultEmailTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultLoginEmailCodeTemplate,
});
await knex("templates").insert({
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
...defaultEmailUpdateTemplate,
});
}
}
+1 -1
View File
@@ -84,7 +84,7 @@
interface Props {
open: boolean;
monitor_tags?: string[];
incident_ids?: string[];
incident_ids?: number[];
maintenance_ids?: string[];
}
let { open = $bindable(false), monitor_tags = [], incident_ids = [], maintenance_ids = [] }: Props = $props();
+5 -3
View File
@@ -32,6 +32,7 @@
monitor_tags?: string[];
shareLinkString?: string;
embedMonitorTag?: string;
incident_ids?: number[];
}
let openSubscribeMenu = $state(false);
@@ -45,7 +46,8 @@
showHomeButton = false,
monitor_tags = [],
shareLinkString = "",
embedMonitorTag = ""
embedMonitorTag = "",
incident_ids = []
}: Props = $props();
let protocol = $state("");
@@ -110,7 +112,7 @@
</div>
<div class="flex gap-2">
<ButtonGroup.Root class="">
{#if monitor_tags.length > 0}
{#if monitor_tags.length > 0 || incident_ids.length > 0}
<ButtonGroup.Root class="hidden sm:flex">
<Button
variant="outline"
@@ -177,6 +179,6 @@
</div>
</div>
<SubscribeMenuV2 bind:open={openSubscribeMenu} {monitor_tags} />
<SubscribeMenuV2 bind:open={openSubscribeMenu} {monitor_tags} {incident_ids} />
<BadgesMenu bind:open={openBadgesMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
<EmbedMenu bind:open={openEmbedMenu} monitorTag={embedMonitorTag} {protocol} {domain} />
@@ -8,6 +8,10 @@ import { GetSubscriptionConfig } from "$lib/server/controllers/subscriptionConfi
import db from "$lib/server/db/db";
import emailCodeTemplate from "$lib/server/templates/email_code";
import type { SubscriptionMethodType, SubscriptionEventType, SubscriptionEntityType } from "$lib/server/types/db";
import { getPreferredEmailConfiguration, siteDataToVariables } from "$lib/server/notification/notification_utils";
import type { EmailCodeVariableMap } from "$lib/server/notification/types";
import sendEmail from "$lib/server/notification/email_notification.js";
import { GetTemplateByEmailType } from "$lib/server/controllers/emailTemplateConfigController";
interface SubscriptionV2RequestBody {
action:
@@ -188,18 +192,20 @@ async function handleLogin(email?: string): Promise<Response> {
const siteLogo = await GetSiteLogoURL(siteData.siteURL || "", siteData.logo || "", "/");
const siteName = siteData.siteName || "Status Page";
await SendEmailWithTemplate(
emailCodeTemplate,
{
site_name: siteName,
logo_url: siteLogo,
code: code,
site_url: siteData.siteURL || "",
},
normalizedEmail,
`Your Verification Code - ${siteName}`,
`Your verification code is: ${code}`,
);
const templateSiteVars = siteDataToVariables(siteData);
const emailCodeVars: EmailCodeVariableMap = {
email_code: code,
email_subject: `Your Verification Code - ${siteName}`,
action: "login",
};
const emailConfig = getPreferredEmailConfiguration();
const template = await GetTemplateByEmailType("email_code");
if (!template) {
return error(500, { message: "Email template not found" });
}
await sendEmail(emailConfig, emailCodeVars, template, templateSiteVars, [normalizedEmail]);
return json({
success: true,
@@ -146,3 +146,8 @@ export const CreateHash = (apiKey: string): string => {
.update(apiKey)
.digest("hex");
};
//create md5 hash
export const CreateMD5Hash = (data: string): string => {
return crypto.createHash("md5").update(data).digest("hex");
};
@@ -0,0 +1,147 @@
import db from "$lib/server/db/db";
import type {
EmailTemplateConfigRecord,
EmailTemplateConfigUpdate,
EmailTemplateConfigWithTemplate,
} from "$lib/server/db/repositories/emailTemplateConfig";
import type { TemplateRecord } from "$lib/server/types/db";
// Email type constants
export const EMAIL_TYPES = {
EMAIL_CODE: "email_code",
FORGOT_PASSWORD: "forgot_password",
VERIFY_EMAIL: "verify_email",
SUBSCRIPTION_UPDATE: "subscription_update",
} as const;
export type EmailType = (typeof EMAIL_TYPES)[keyof typeof EMAIL_TYPES];
// Human-readable labels for email types
export const EMAIL_TYPE_LABELS: Record<EmailType, string> = {
email_code: "Verification Code",
forgot_password: "Forgot Password",
verify_email: "Verify Email",
subscription_update: "Subscription Update",
};
// Descriptions for email types
export const EMAIL_TYPE_DESCRIPTIONS: Record<EmailType, string> = {
email_code: "Email sent when a user requests a verification code for login or actions",
forgot_password: "Email sent when a user requests to reset their password",
verify_email: "Email sent when a user needs to verify their email address",
subscription_update: "Email sent to subscribers when there is an update on monitored services",
};
/**
* Get all email template configurations
*/
export async function GetAllEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
return await db.getAllEmailTemplateConfigs();
}
/**
* Get all email template configurations with template details
*/
export async function GetAllEmailTemplateConfigsWithTemplates(): Promise<EmailTemplateConfigWithTemplate[]> {
return await db.getAllEmailTemplateConfigsWithTemplates();
}
/**
* Get email template config by email type
*/
export async function GetEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigRecord | undefined> {
return await db.getEmailTemplateConfigByType(emailType);
}
/**
* Update email template config
*/
export async function UpdateEmailTemplateConfig(
emailType: string,
data: EmailTemplateConfigUpdate,
): Promise<{ success: boolean; error?: string }> {
// Validate email type
if (!Object.values(EMAIL_TYPES).includes(emailType as EmailType)) {
return { success: false, error: `Invalid email type: ${emailType}` };
}
// Validate template if provided
if (data.email_template_id !== null && data.email_template_id !== undefined) {
const template = await db.getTemplateById(data.email_template_id);
if (!template) {
return { success: false, error: `Template with ID ${data.email_template_id} not found` };
}
// Ensure it's an email template
if (template.template_type !== "EMAIL") {
return {
success: false,
error: `Template ${data.email_template_id} is not an email template`,
};
}
}
// Validate is_active if provided
if (data.is_active !== undefined && !["YES", "NO"].includes(data.is_active)) {
return { success: false, error: `Invalid is_active value: ${data.is_active}` };
}
await db.updateEmailTemplateConfigByType(emailType, data);
return { success: true };
}
/**
* Batch update multiple email template configs
*/
export async function UpdateAllEmailTemplateConfigs(
configs: Array<{ email_type: string; email_template_id: number | null; is_active: string }>,
): Promise<{ success: boolean; error?: string }> {
for (const config of configs) {
const result = await UpdateEmailTemplateConfig(config.email_type, {
email_template_id: config.email_template_id,
is_active: config.is_active,
});
if (!result.success) {
return result;
}
}
return { success: true };
}
/**
* Get active email template config by type with template details
*/
export async function GetActiveEmailTemplateConfigByType(
emailType: string,
): Promise<EmailTemplateConfigWithTemplate | undefined> {
return await db.getActiveEmailTemplateConfigByType(emailType);
}
/**
* Get all email templates (for dropdown)
*/
export async function GetEmailTemplates(): Promise<
Array<{ id: number; template_name: string; template_type: string }>
> {
const allTemplates = await db.getTemplatesByTypeAndUsage("EMAIL", ["GENERAL"]);
return allTemplates.map((t) => ({
id: t.id,
template_name: t.template_name,
template_type: t.template_type,
}));
}
/**
* Get the template associated with an email type
* Returns the template if the config is active and has a valid template_id
*/
export async function GetTemplateByEmailType(emailType: string): Promise<TemplateRecord | null> {
const config = await db.getEmailTemplateConfigByType(emailType);
// Check if config exists, is active, and has a template assigned
if (!config || config.is_active !== "YES" || !config.email_template_id) {
return null;
}
const template = await db.getTemplateById(config.email_template_id);
return template || null;
}
@@ -16,6 +16,7 @@ import type {
} from "../types/db.js";
import * as queueController from "./queueController.js";
import type { NumberWithChange } from "../../types/monitor.js";
import GC from "../../global-constants.js";
interface IncidentsDashboardInput {
page: number;
@@ -225,6 +226,18 @@ export const GetIncidentsByIDS = async (ids: number[]): Promise<unknown[]> => {
return incidents;
};
export const CreateNewIncidentWithCommentAndMonitor = async (
data: IncidentInput,
update: string,
monitorTag: string,
monitorStatus: string,
): Promise<{ incident_id: number }> => {
let incidentCreation = await CreateIncident(data);
await AddIncidentComment(incidentCreation.incident_id, update, GC.INVESTIGATING, data.start_date_time);
await AddIncidentMonitor(incidentCreation.incident_id, monitorTag, monitorStatus);
return incidentCreation;
};
export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id: number }> => {
//return error if no title or startDateTime
if (!data.title || !data.start_date_time) {
@@ -252,10 +265,12 @@ export const CreateIncident = async (data: IncidentInput): Promise<{ incident_id
}
let newIncident = await db.createIncident(incident);
queueController.PushDataToQueue(newIncident.id, "createIncident", {
message: `${incident.incident_type} Created`,
...incident,
});
//we need to
// queueController.PushDataToQueue(newIncident.id, "createIncident", {
// message: `${incident.incident_type} Created`,
// ...incident,
// });
return {
incident_id: newIncident.id,
};
@@ -30,7 +30,7 @@ export type {
// ============ Validation ============
const VALID_TEMPLATE_TYPES: TemplateType[] = ["EMAIL", "WEBHOOK", "SLACK", "DISCORD"];
const VALID_TEMPLATE_USAGES: TemplateUsageType[] = ["ALERT", "SUBSCRIPTION"];
const VALID_TEMPLATE_USAGES: TemplateUsageType[] = ["ALERT", "SUBSCRIPTION", "GENERAL"];
function validateTemplateType(value: string): asserts value is TemplateType {
if (!VALID_TEMPLATE_TYPES.includes(value as TemplateType)) {
@@ -272,11 +272,11 @@ export async function GetTemplatesByUsage(templateUsage: TemplateUsageType): Pro
*/
export async function GetTemplatesByTypeAndUsage(
templateType: TemplateType,
templateUsage: TemplateUsageType,
templateUsages: TemplateUsageType[],
): Promise<TemplateRecord[]> {
validateTemplateType(templateType);
validateTemplateUsage(templateUsage);
return await db.getTemplatesByTypeAndUsage(templateType, templateUsage);
templateUsages.forEach((usage) => validateTemplateUsage(usage));
return await db.getTemplatesByTypeAndUsage(templateType, templateUsages);
}
/**
@@ -10,6 +10,7 @@ import type {
UserSubscriptionV2Record,
SubscriberUserRecord,
SubscriberMethodRecord,
SubscriptionEntityType,
} from "$lib/server/types/db.js";
// ============ V2 Admin Functions ============
@@ -242,3 +243,17 @@ export function FormatEntityType(entityType: string | null): string {
return entityType;
}
}
//getSubscriptionMethodsByEntity
export async function GetSubscriptionMethodsByEntity(
entity_type: string,
entity_id: string,
): Promise<
Array<{
method: SubscriberMethodRecord;
user: SubscriberUserRecord;
subscription: UserSubscriptionV2Record;
}>
> {
return await db.getSubscriptionMethodsByEntity(entity_type as SubscriptionEntityType, entity_id);
}
@@ -0,0 +1,180 @@
import crypto from "crypto";
import db from "$lib/server/db/db";
import type { VaultRecord } from "$lib/server/db/repositories/vault";
const DUMMY_SECRET = "DUMMY_SECRET_KEY_32_BYTES_LONG!";
const ALGORITHM = "aes-256-cbc";
const IV_LENGTH = 16;
/**
* Get the encryption key from environment variable
* Pads or truncates to 32 bytes for AES-256
*/
function getEncryptionKey(): Buffer {
const key = process.env.KENER_SECRET_KEY || DUMMY_SECRET;
// AES-256 requires a 32-byte key
const keyBuffer = Buffer.alloc(32);
Buffer.from(key).copy(keyBuffer);
return keyBuffer;
}
/**
* Encrypt a value using AES-256-CBC
*/
export function encryptValue(plainText: string): string {
const key = getEncryptionKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plainText, "utf8", "hex");
encrypted += cipher.final("hex");
// Prepend IV to encrypted data (IV:encrypted)
return iv.toString("hex") + ":" + encrypted;
}
/**
* Decrypt a value using AES-256-CBC
*/
export function decryptValue(encryptedText: string): string {
const key = getEncryptionKey();
const parts = encryptedText.split(":");
if (parts.length !== 2) {
throw new Error("Invalid encrypted value format");
}
const iv = Buffer.from(parts[0], "hex");
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
/**
* Interface for decrypted vault secret
*/
export interface VaultSecretDecrypted {
id: number;
secret_name: string;
secret_value: string; // Decrypted value
created_at: Date;
updated_at: Date;
}
/**
* Get all secrets with decrypted values
*/
export async function GetAllSecrets(): Promise<VaultSecretDecrypted[]> {
const secrets = await db.getAllSecrets();
return secrets.map((secret) => ({
...secret,
secret_value: decryptValue(secret.secret_value),
}));
}
/**
* Get a secret by ID with decrypted value
*/
export async function GetSecretById(id: number): Promise<VaultSecretDecrypted | undefined> {
const secret = await db.getSecretById(id);
if (!secret) return undefined;
return {
...secret,
secret_value: decryptValue(secret.secret_value),
};
}
/**
* Get a secret by name with decrypted value
*/
export async function GetSecretByName(secretName: string): Promise<VaultSecretDecrypted | undefined> {
const secret = await db.getSecretByName(secretName);
if (!secret) return undefined;
return {
...secret,
secret_value: decryptValue(secret.secret_value),
};
}
/**
* Create a new secret (encrypts the value before storing)
*/
export async function CreateSecret(
secretName: string,
secretValue: string,
): Promise<{ success: boolean; error?: string; id?: number }> {
// Validate input
if (!secretName || !secretName.trim()) {
return { success: false, error: "Secret name is required" };
}
if (!secretValue) {
return { success: false, error: "Secret value is required" };
}
// Check if name already exists
const exists = await db.secretNameExists(secretName.trim());
if (exists) {
return { success: false, error: `Secret with name "${secretName}" already exists` };
}
// Encrypt and store
const encryptedValue = encryptValue(secretValue);
const ids = await db.insertSecret({
secret_name: secretName.trim(),
secret_value: encryptedValue,
});
return { success: true, id: ids[0] };
}
/**
* Update a secret by ID (encrypts the new value before storing)
*/
export async function UpdateSecret(
id: number,
data: { secret_name?: string; secret_value?: string },
): Promise<{ success: boolean; error?: string }> {
// Check if secret exists
const existing = await db.getSecretById(id);
if (!existing) {
return { success: false, error: `Secret with ID ${id} not found` };
}
// If updating name, check for duplicates
if (data.secret_name && data.secret_name !== existing.secret_name) {
const nameExists = await db.secretNameExists(data.secret_name.trim(), id);
if (nameExists) {
return { success: false, error: `Secret with name "${data.secret_name}" already exists` };
}
}
// Build update data
const updateData: { secret_name?: string; secret_value?: string } = {};
if (data.secret_name !== undefined) {
updateData.secret_name = data.secret_name.trim();
}
if (data.secret_value !== undefined) {
updateData.secret_value = encryptValue(data.secret_value);
}
await db.updateSecretById(id, updateData);
return { success: true };
}
/**
* Delete a secret by ID
*/
export async function DeleteSecret(id: number): Promise<{ success: boolean; error?: string }> {
const existing = await db.getSecretById(id);
if (!existing) {
return { success: false, error: `Secret with ID ${id} not found` };
}
await db.deleteSecretById(id);
return { success: true };
}
/**
* Get secrets count
*/
export async function GetSecretsCount(): Promise<number> {
return await db.getSecretsCount();
}
+75
View File
@@ -17,6 +17,8 @@ import { TemplatesRepository } from "./repositories/templates.js";
import { SubscriptionConfigRepository } from "./repositories/subscriptionConfig.js";
import { UserSubscriptionsRepository } from "./repositories/userSubscriptions.js";
import { SubscriptionSystemRepository } from "./repositories/subscriptionSystem.js";
import { EmailTemplateConfigRepository } from "./repositories/emailTemplateConfig.js";
import { VaultRepository } from "./repositories/vault.js";
// Re-export types from base
export type { MonitorFilter, TriggerFilter, IncidentFilter, CountResult } from "./repositories/base.js";
@@ -49,6 +51,8 @@ class DbImpl {
private subscriptionConfig!: SubscriptionConfigRepository;
private userSubscriptions!: UserSubscriptionsRepository;
private subscriptionSystem!: SubscriptionSystemRepository;
private emailTemplateConfig!: EmailTemplateConfigRepository;
private vault!: VaultRepository;
// Method bindings - declared with definite assignment assertion
// ============ Monitoring Data ============
@@ -387,6 +391,30 @@ class DbImpl {
getMethodsCountByType!: SubscriptionSystemRepository["getMethodsCountByType"];
getSubscribersByMethodTypeV2!: SubscriptionSystemRepository["getSubscribersByMethodTypeV2"];
getSubscriberDetailsByMethodId!: SubscriptionSystemRepository["getSubscriberDetailsByMethodId"];
getSubscriptionMethodsByEntity!: SubscriptionSystemRepository["getSubscriptionMethodsByEntity"];
// ============ Email Template Config ============
getAllEmailTemplateConfigs!: EmailTemplateConfigRepository["getAllEmailTemplateConfigs"];
getAllEmailTemplateConfigsWithTemplates!: EmailTemplateConfigRepository["getAllEmailTemplateConfigsWithTemplates"];
getEmailTemplateConfigByType!: EmailTemplateConfigRepository["getEmailTemplateConfigByType"];
getEmailTemplateConfigById!: EmailTemplateConfigRepository["getEmailTemplateConfigById"];
updateEmailTemplateConfigByType!: EmailTemplateConfigRepository["updateEmailTemplateConfigByType"];
updateEmailTemplateConfigById!: EmailTemplateConfigRepository["updateEmailTemplateConfigById"];
getActiveEmailTemplateConfigs!: EmailTemplateConfigRepository["getActiveEmailTemplateConfigs"];
getActiveEmailTemplateConfigByType!: EmailTemplateConfigRepository["getActiveEmailTemplateConfigByType"];
ensureDefaultEmailTypes!: EmailTemplateConfigRepository["ensureDefaultEmailTypes"];
// ============ Vault ============
getAllSecrets!: VaultRepository["getAllSecrets"];
getSecretById!: VaultRepository["getSecretById"];
getSecretByName!: VaultRepository["getSecretByName"];
insertSecret!: VaultRepository["insertSecret"];
updateSecretById!: VaultRepository["updateSecretById"];
updateSecretByName!: VaultRepository["updateSecretByName"];
deleteSecretById!: VaultRepository["deleteSecretById"];
deleteSecretByName!: VaultRepository["deleteSecretByName"];
secretNameExists!: VaultRepository["secretNameExists"];
getSecretsCount!: VaultRepository["getSecretsCount"];
constructor(opts: KnexType.Config) {
this.knex = Knex(opts);
@@ -407,6 +435,8 @@ class DbImpl {
this.subscriptionConfig = new SubscriptionConfigRepository(this.knex);
this.userSubscriptions = new UserSubscriptionsRepository(this.knex);
this.subscriptionSystem = new SubscriptionSystemRepository(this.knex);
this.emailTemplateConfig = new EmailTemplateConfigRepository(this.knex);
this.vault = new VaultRepository(this.knex);
// Bind methods after repositories are initialized
this.bindMonitoringMethods();
@@ -424,6 +454,8 @@ class DbImpl {
this.bindSubscriptionConfigMethods();
this.bindUserSubscriptionsMethods();
this.bindSubscriptionSystemMethods();
this.bindEmailTemplateConfigMethods();
this.bindVaultMethods();
this.init();
}
@@ -858,6 +890,49 @@ class DbImpl {
this.getSubscriberDetailsByMethodId = this.subscriptionSystem.getSubscriberDetailsByMethodId.bind(
this.subscriptionSystem,
);
this.getSubscriptionMethodsByEntity = this.subscriptionSystem.getSubscriptionMethodsByEntity.bind(
this.subscriptionSystem,
);
}
private bindEmailTemplateConfigMethods(): void {
this.getAllEmailTemplateConfigs = this.emailTemplateConfig.getAllEmailTemplateConfigs.bind(
this.emailTemplateConfig,
);
this.getAllEmailTemplateConfigsWithTemplates =
this.emailTemplateConfig.getAllEmailTemplateConfigsWithTemplates.bind(this.emailTemplateConfig);
this.getEmailTemplateConfigByType = this.emailTemplateConfig.getEmailTemplateConfigByType.bind(
this.emailTemplateConfig,
);
this.getEmailTemplateConfigById = this.emailTemplateConfig.getEmailTemplateConfigById.bind(
this.emailTemplateConfig,
);
this.updateEmailTemplateConfigByType = this.emailTemplateConfig.updateEmailTemplateConfigByType.bind(
this.emailTemplateConfig,
);
this.updateEmailTemplateConfigById = this.emailTemplateConfig.updateEmailTemplateConfigById.bind(
this.emailTemplateConfig,
);
this.getActiveEmailTemplateConfigs = this.emailTemplateConfig.getActiveEmailTemplateConfigs.bind(
this.emailTemplateConfig,
);
this.getActiveEmailTemplateConfigByType = this.emailTemplateConfig.getActiveEmailTemplateConfigByType.bind(
this.emailTemplateConfig,
);
this.ensureDefaultEmailTypes = this.emailTemplateConfig.ensureDefaultEmailTypes.bind(this.emailTemplateConfig);
}
private bindVaultMethods(): void {
this.getAllSecrets = this.vault.getAllSecrets.bind(this.vault);
this.getSecretById = this.vault.getSecretById.bind(this.vault);
this.getSecretByName = this.vault.getSecretByName.bind(this.vault);
this.insertSecret = this.vault.insertSecret.bind(this.vault);
this.updateSecretById = this.vault.updateSecretById.bind(this.vault);
this.updateSecretByName = this.vault.updateSecretByName.bind(this.vault);
this.deleteSecretById = this.vault.deleteSecretById.bind(this.vault);
this.deleteSecretByName = this.vault.deleteSecretByName.bind(this.vault);
this.secretNameExists = this.vault.secretNameExists.bind(this.vault);
this.getSecretsCount = this.vault.getSecretsCount.bind(this.vault);
}
async init(): Promise<void> {}
@@ -0,0 +1,136 @@
import { BaseRepository } from "./base.js";
export interface EmailTemplateConfigRecord {
id: number;
email_type: string;
email_template_id: number | null;
is_active: string; // "YES" or "NO"
created_at: Date;
updated_at: Date;
}
export interface EmailTemplateConfigInsert {
email_type: string;
email_template_id?: number | null;
is_active?: string;
}
export interface EmailTemplateConfigUpdate {
email_template_id?: number | null;
is_active?: string;
}
export interface EmailTemplateConfigWithTemplate extends EmailTemplateConfigRecord {
template_name?: string | null;
}
/**
* Repository for email template configuration operations
*/
export class EmailTemplateConfigRepository extends BaseRepository {
/**
* Get all email template configs
*/
async getAllEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
return await this.knex("email_templates_config").orderBy("id", "asc");
}
/**
* Get all email template configs with template details
*/
async getAllEmailTemplateConfigsWithTemplates(): Promise<EmailTemplateConfigWithTemplate[]> {
return await this.knex("email_templates_config")
.leftJoin("templates", "email_templates_config.email_template_id", "templates.id")
.select("email_templates_config.*", "templates.template_name as template_name")
.orderBy("email_templates_config.id", "asc");
}
/**
* Get email template config by email type
*/
async getEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigRecord | undefined> {
return await this.knex("email_templates_config").where({ email_type: emailType }).first();
}
/**
* Get email template config by ID
*/
async getEmailTemplateConfigById(id: number): Promise<EmailTemplateConfigRecord | undefined> {
return await this.knex("email_templates_config").where({ id }).first();
}
/**
* Update email template config by email type
*/
async updateEmailTemplateConfigByType(emailType: string, data: EmailTemplateConfigUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.email_template_id !== undefined) {
updateData.email_template_id = data.email_template_id;
}
if (data.is_active !== undefined) {
updateData.is_active = data.is_active;
}
return await this.knex("email_templates_config").where({ email_type: emailType }).update(updateData);
}
/**
* Update email template config by ID
*/
async updateEmailTemplateConfigById(id: number, data: EmailTemplateConfigUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.email_template_id !== undefined) {
updateData.email_template_id = data.email_template_id;
}
if (data.is_active !== undefined) {
updateData.is_active = data.is_active;
}
return await this.knex("email_templates_config").where({ id }).update(updateData);
}
/**
* Get active email template configs (is_active = "YES")
*/
async getActiveEmailTemplateConfigs(): Promise<EmailTemplateConfigRecord[]> {
return await this.knex("email_templates_config").where({ is_active: "YES" }).orderBy("id", "asc");
}
/**
* Get active email template config by type with template details
*/
async getActiveEmailTemplateConfigByType(emailType: string): Promise<EmailTemplateConfigWithTemplate | undefined> {
return await this.knex("email_templates_config")
.leftJoin("templates", "email_templates_config.email_template_id", "templates.id")
.select("email_templates_config.*", "templates.template_name as template_name")
.where({
"email_templates_config.email_type": emailType,
"email_templates_config.is_active": "YES",
})
.first();
}
/**
* Ensure default email types exist (for migration safety)
*/
async ensureDefaultEmailTypes(): Promise<void> {
const defaultTypes = ["email_code", "forgot_password", "verify_email"];
for (const emailType of defaultTypes) {
const existing = await this.getEmailTemplateConfigByType(emailType);
if (!existing) {
await this.knex("email_templates_config").insert({
email_type: emailType,
email_template_id: null,
is_active: "NO",
});
}
}
}
}
@@ -518,4 +518,89 @@ export class SubscriptionSystemRepository extends BaseRepository {
return { user, method, subscriptions };
}
/**
* Get subscription method details for a specific entity
* Returns all active subscriber methods that are subscribed to this entity
*/
async getSubscriptionMethodsByEntity(
entityType: SubscriptionEntityType,
entityId: string,
): Promise<
Array<{
method: SubscriberMethodRecord;
user: SubscriberUserRecord;
subscription: UserSubscriptionV2Record;
}>
> {
const rows = await this.knex("user_subscriptions_v2 as us")
.join("subscriber_methods as sm", "us.subscriber_method_id", "sm.id")
.join("subscriber_users as su", "us.subscriber_user_id", "su.id")
.where("us.status", "ACTIVE")
.andWhere("sm.status", "ACTIVE")
.andWhere("su.status", "ACTIVE")
.andWhere(function () {
// Match subscriptions for this specific entity OR global subscriptions (entity_type is null)
this.where(function () {
this.where("us.entity_type", entityType).andWhere("us.entity_id", entityId);
}).orWhereNull("us.entity_type");
})
.select(
"sm.id as method_id",
"sm.subscriber_user_id",
"sm.method_type",
"sm.method_value",
"sm.status as method_status",
"sm.meta as method_meta",
"sm.created_at as method_created_at",
"sm.updated_at as method_updated_at",
"su.id as user_id",
"su.email as user_email",
"su.status as user_status",
"su.verification_code",
"su.verification_expires_at",
"su.created_at as user_created_at",
"su.updated_at as user_updated_at",
"us.id as sub_id",
"us.event_type",
"us.entity_type",
"us.entity_id",
"us.status as sub_status",
"us.created_at as sub_created_at",
"us.updated_at as sub_updated_at",
);
return rows.map((row) => ({
method: {
id: row.method_id,
subscriber_user_id: row.subscriber_user_id,
method_type: row.method_type,
method_value: row.method_value,
status: row.method_status,
meta: row.method_meta,
created_at: row.method_created_at,
updated_at: row.method_updated_at,
},
user: {
id: row.user_id,
email: row.user_email,
status: row.user_status,
verification_code: row.verification_code,
verification_expires_at: row.verification_expires_at,
created_at: row.user_created_at,
updated_at: row.user_updated_at,
},
subscription: {
id: row.sub_id,
subscriber_user_id: row.subscriber_user_id,
subscriber_method_id: row.method_id,
event_type: row.event_type,
entity_type: row.entity_type,
entity_id: row.entity_id,
status: row.sub_status,
created_at: row.sub_created_at,
updated_at: row.sub_updated_at,
},
}));
}
}
+3 -2
View File
@@ -96,10 +96,11 @@ export class TemplatesRepository extends BaseRepository {
*/
async getTemplatesByTypeAndUsage(
templateType: TemplateType,
templateUsage: TemplateUsageType,
templateUsages: TemplateUsageType[],
): Promise<TemplateRecord[]> {
return await this.knex("templates")
.where({ template_type: templateType, template_usage: templateUsage })
.where({ template_type: templateType })
.whereIn("template_usage", templateUsages)
.orderBy("id", "desc");
}
+128
View File
@@ -0,0 +1,128 @@
import { BaseRepository } from "./base.js";
export interface VaultRecord {
id: number;
secret_name: string;
secret_value: string; // Encrypted value in DB
created_at: Date;
updated_at: Date;
}
export interface VaultInsert {
secret_name: string;
secret_value: string; // Should be encrypted before insert
}
export interface VaultUpdate {
secret_name?: string;
secret_value?: string; // Should be encrypted before update
}
/**
* Repository for vault (secrets) operations
* Note: Encryption/decryption should be handled at the controller level
*/
export class VaultRepository extends BaseRepository {
/**
* Get all vault secrets
*/
async getAllSecrets(): Promise<VaultRecord[]> {
return await this.knex("vault").orderBy("id", "asc");
}
/**
* Get a secret by ID
*/
async getSecretById(id: number): Promise<VaultRecord | undefined> {
return await this.knex("vault").where({ id }).first();
}
/**
* Get a secret by name
*/
async getSecretByName(secretName: string): Promise<VaultRecord | undefined> {
return await this.knex("vault").where({ secret_name: secretName }).first();
}
/**
* Insert a new secret
*/
async insertSecret(data: VaultInsert): Promise<number[]> {
return await this.knex("vault").insert({
secret_name: data.secret_name,
secret_value: data.secret_value,
created_at: this.knex.fn.now(),
updated_at: this.knex.fn.now(),
});
}
/**
* Update a secret by ID
*/
async updateSecretById(id: number, data: VaultUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.secret_name !== undefined) {
updateData.secret_name = data.secret_name;
}
if (data.secret_value !== undefined) {
updateData.secret_value = data.secret_value;
}
return await this.knex("vault").where({ id }).update(updateData);
}
/**
* Update a secret by name
*/
async updateSecretByName(secretName: string, data: VaultUpdate): Promise<number> {
const updateData: Record<string, unknown> = {
updated_at: this.knex.fn.now(),
};
if (data.secret_name !== undefined) {
updateData.secret_name = data.secret_name;
}
if (data.secret_value !== undefined) {
updateData.secret_value = data.secret_value;
}
return await this.knex("vault").where({ secret_name: secretName }).update(updateData);
}
/**
* Delete a secret by ID
*/
async deleteSecretById(id: number): Promise<number> {
return await this.knex("vault").where({ id }).del();
}
/**
* Delete a secret by name
*/
async deleteSecretByName(secretName: string): Promise<number> {
return await this.knex("vault").where({ secret_name: secretName }).del();
}
/**
* Check if a secret name already exists
*/
async secretNameExists(secretName: string, excludeId?: number): Promise<boolean> {
let query = this.knex("vault").where({ secret_name: secretName });
if (excludeId !== undefined) {
query = query.andWhereNot({ id: excludeId });
}
const result = await query.first();
return !!result;
}
/**
* Get secrets count
*/
async getSecretsCount(): Promise<number> {
const result = await this.knex("vault").count("id as count").first();
return Number(result?.count || 0);
}
}
@@ -10,28 +10,23 @@ import { GetRequiredSecrets, ReplaceAllOccurrences } from "../tool.js";
import Mustache from "mustache";
import striptags from "striptags";
import { Resend, type CreateEmailOptions } from "resend";
import type { SiteDataForNotification, TemplateVariableMap } from "./types.js";
import type {
ResendAPIConfiguration,
SiteDataForNotification,
SMTPConfiguration,
TemplateVariableMap,
} from "./types.js";
import getSMTPTransport from "./smtps.js";
export default async function send(
triggerRecord: TriggerRecordParsed<TriggerMetaEmailJson>,
triggerRecord: SMTPConfiguration | ResendAPIConfiguration,
variables: TemplateVariableMap,
template: TemplateRecord,
siteData: SiteDataForNotification,
to?: string[],
to: string[],
) {
// Implementation for sending email notification using the provided triggerRecord, variables, and template
let metaStringified = JSON.stringify(triggerRecord.trigger_meta);
let envSecrets = GetRequiredSecrets(metaStringified);
for (let i = 0; i < envSecrets.length; i++) {
const secret = envSecrets[i];
if (secret.replace !== undefined) {
metaStringified = ReplaceAllOccurrences(metaStringified, secret.find, secret.replace);
}
}
let trigger_meta_json = JSON.parse(metaStringified) as TriggerMetaEmailJson;
let emailBody = template.template_json;
let emailBodyJson = JSON.parse(emailBody) as EmailTemplateJson;
@@ -44,29 +39,33 @@ export default async function send(
}
}
let toAddresses = trigger_meta_json.to;
if (to) {
toAddresses = to;
}
const from = trigger_meta_json.from;
const subject = Mustache.render(emailBodyJson.email_subject, { ...variables, ...siteData });
const htmlBody = Mustache.render(emailBodyJson.email_body, { ...variables, ...siteData });
const textBody = striptags(htmlBody);
try {
const resend = new Resend(process.env.RESEND_API_KEY);
try {
//check if triggerRecord is of type ResendAPIConfiguration
if ("resend_api_key" in triggerRecord) {
const resend = new Resend(triggerRecord.resend_api_key);
const emailBody: CreateEmailOptions = {
from: from,
to: toAddresses,
from: triggerRecord.resend_sender_email,
to: to,
subject: subject,
html: htmlBody,
text: textBody,
};
return await resend.emails.send(emailBody);
} catch (error) {
console.error("Error sending webhook", error);
return error;
} else {
// SMTP Configuration
const transport = getSMTPTransport(triggerRecord as SMTPConfiguration);
const mailOptions = {
from: triggerRecord.smtp_sender, // sender address
to: to, // recipient address(es)
subject: subject, // email subject
text: textBody, // plain text body
html: htmlBody, // HTML body (if any)
};
return await transport.sendMail(mailOptions);
}
} catch (error) {
console.error("Error sending email", error);
@@ -1,7 +1,14 @@
import type { MonitorAlertConfigRecord, MonitorAlertV2Record } from "../types/db";
import type { AlertVariableMap, SiteDataForNotification, TemplateVariableMap } from "./types.js";
import type { MonitorAlertConfigRecord, MonitorAlertV2Record, TriggerMetaEmailJson } from "../types/db";
import type {
AlertVariableMap,
ResendAPIConfiguration,
SiteDataForNotification,
SMTPConfiguration,
TemplateVariableMap,
} from "./types.js";
import GC from "../../global-constants.js";
import type { SiteDataTransformed } from "../controllers/siteDataController.js";
import { GetSMTPFromENV } from "../controllers/controller.js";
export function alertToVariables(config: MonitorAlertConfigRecord, alert: MonitorAlertV2Record): AlertVariableMap {
// Ensure created_at is a Date object
@@ -39,3 +46,45 @@ export function siteDataToVariables(siteData: SiteDataTransformed): SiteDataForN
colors_maintenance: siteData.colors.MAINTENANCE,
};
}
//return SMTPConfiguration or ResendAPIConfiguration given TriggerMetaEmailJson
export function getEmailConfigFromTriggerMeta(
triggerMeta: TriggerMetaEmailJson,
): SMTPConfiguration | ResendAPIConfiguration {
if (triggerMeta.email_type.toUpperCase() === "SMTP") {
return {
smtp_host: triggerMeta.smtp_host || "",
smtp_port: Number(triggerMeta.smtp_port) || 587,
smtp_secure: triggerMeta.smtp_secure,
smtp_user: triggerMeta.smtp_user || "",
smtp_pass: triggerMeta.smtp_pass || "",
smtp_sender: triggerMeta.from || "",
};
} else {
return {
resend_api_key: process.env.RESEND_API_KEY || "",
resend_sender_email: triggerMeta.from || "",
};
}
}
//get preferred email type from environment variable or default to SMTP
export function getPreferredEmailConfiguration(): SMTPConfiguration | ResendAPIConfiguration {
let smtpData = GetSMTPFromENV();
if (smtpData) {
return {
smtp_host: smtpData.smtp_host,
smtp_port: smtpData.smtp_port,
smtp_secure: smtpData.smtp_secure,
smtp_user: smtpData.smtp_user,
smtp_pass: smtpData.smtp_pass,
smtp_sender: smtpData.smtp_from_email,
};
} else {
return {
resend_api_key: process.env.RESEND_API_KEY || "",
resend_sender_email: process.env.RESEND_SENDER_EMAIL || "",
};
}
}
+2 -9
View File
@@ -2,18 +2,11 @@ import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import { HashString } from "../tool.js";
import type { TriggerRecord, MonitorRecord } from "../types/db.js";
interface SMTPMeta {
smtp_host: string;
smtp_port?: string | number;
smtp_secure?: boolean;
smtp_user: string;
smtp_pass: string;
}
import type { SMTPConfiguration } from "./types.js";
const transports: Record<string, Transporter> = {};
export default function getSMTPTransport(meta: SMTPMeta): Transporter {
export default function getSMTPTransport(meta: SMTPConfiguration): Transporter {
//convert meta to string and generate has id
let transportId = "smtp_" + HashString(JSON.stringify(meta));
+30 -2
View File
@@ -26,7 +26,35 @@ export interface SiteDataForNotification {
colors_maintenance: string;
}
export interface SubscriptionVariableMap {
my_name: string;
title: string;
cta_url: string;
cta_text: string;
update_text: string;
update_subject: string;
}
export type TemplateVariableMap = SubscriptionVariableMap | AlertVariableMap;
export interface EmailCodeVariableMap {
email_code: string;
email_subject: string;
action: string;
}
export interface SMTPConfiguration {
smtp_host: string;
smtp_port?: string | number;
smtp_secure?: boolean;
smtp_user: string;
smtp_pass: string;
smtp_sender: string;
}
export interface WebhookConfiguration {
url: string;
headers: Record<string, string>;
}
export interface ResendAPIConfiguration {
resend_api_key: string;
resend_sender_email: string;
}
export type TemplateVariableMap = SubscriptionVariableMap | AlertVariableMap | EmailCodeVariableMap;
+39 -37
View File
@@ -8,6 +8,7 @@ import {
AddIncidentMonitor,
GetIncidentByIDDashboard,
GetAllSiteData,
CreateNewIncidentWithCommentAndMonitor,
} from "../controllers/controller.js";
import { SetLastMonitoringValue } from "../cache/setGet.js";
import type {
@@ -36,11 +37,16 @@ import { GetMonitorAlertsV2 } from "../controllers/monitorAlertConfigController.
import db from "../db/db.js";
import { getUnixTime, differenceInSeconds } from "date-fns";
import GC from "../../global-constants.js";
import { alertToVariables, siteDataToVariables } from "../notification/notification_utils.js";
import {
alertToVariables,
getEmailConfigFromTriggerMeta,
siteDataToVariables,
} from "../notification/notification_utils.js";
import sendEmail from "../notification/email_notification.js";
import sendWebhook from "$lib/server/notification/webhook_notification.js";
import sendSlack from "$lib/server/notification/slack_notification.js";
import sendDiscord from "$lib/server/notification/discord_notification.js";
import subscriberQueue from "./subscriberQueue.js";
import { GetTemplateById } from "../controllers/templateController.js";
@@ -74,8 +80,6 @@ async function createNewIncident(
incident_source: "ALERT",
};
let newIncident = await CreateIncident(incidentInput);
let incidentId = newIncident.incident_id;
let update = config.alert_description || "Alert triggered";
update = `${config.alert_description || "Alert triggered"}\n\n`;
update = update + `| Setting | Value |\n`;
@@ -88,13 +92,7 @@ async function createNewIncident(
update = update + `| **Alert Value** | ${config.alert_value} |\n`;
update = update + `| **Failure Threshold** | ${config.failure_threshold} |\n`;
//add update to incident
await AddIncidentComment(newIncident.incident_id, update, GC.INVESTIGATING, startDateTime);
//add monitor to incident
await AddIncidentMonitor(newIncident.incident_id, monitorTag, config.alert_value);
return { incident_id: incidentId };
return await CreateNewIncidentWithCommentAndMonitor(incidentInput, update, monitorTag, config.alert_value);
}
async function closeIncident(
@@ -175,37 +173,37 @@ async function sendAlertNotifications(
// Handle only email for now
if (trigger.trigger_type === "email") {
await sendEmail(
trigger as TriggerRecordParsed<TriggerMetaEmailJson>,
templateAlertVars,
template,
templateSiteVars,
);
} else if (trigger.trigger_type === "webhook") {
await sendWebhook(
trigger as TriggerRecordParsed<TriggerMetaWebhookJson>,
templateAlertVars,
template,
templateSiteVars,
);
} else if (trigger.trigger_type === "slack") {
await sendSlack(
trigger as TriggerRecordParsed<TriggerMetaSlackJson>,
templateAlertVars,
template,
templateSiteVars,
);
} else if (trigger.trigger_type === "discord") {
await sendDiscord(
trigger as TriggerRecordParsed<TriggerMetaDiscordJson>,
templateAlertVars,
template,
templateSiteVars,
);
const emailSendingConfig = getEmailConfigFromTriggerMeta(trigger.trigger_meta as TriggerMetaEmailJson);
const toAddresses = (trigger.trigger_meta as TriggerMetaEmailJson).to;
await sendEmail(emailSendingConfig, templateAlertVars, template, templateSiteVars, toAddresses);
}
}
}
/**
* Send notifications to subscribers when an alert is triggered or resolved
*/
// async function sendSubscriberNotifications(
// activeAlert: MonitorAlertV2Record,
// monitor_alerts_configured: MonitorAlertConfigRecord,
// monitor_name: string,
// monitor_tag: string,
// ): Promise<void> {
// const isResolved = activeAlert.alert_status === GC.RESOLVED;
// const statusText = isResolved ? "Resolved" : "Triggered";
// const subscriptionVariables = {
// title: `${monitor_name} - Alert ${statusText}`,
// cta_url: "", // Can be populated with link to status page
// cta_text: "View Status Page",
// update_text: `Alert for ${monitor_name} (${monitor_tag}) has been ${statusText.toLowerCase()}. Alert type: ${monitor_alerts_configured.alert_for}, Value: ${monitor_alerts_configured.alert_value}, Severity: ${monitor_alerts_configured.severity}`,
// update_subject: `[${statusText}] ${monitor_name} - ${monitor_alerts_configured.alert_for} ${monitor_alerts_configured.alert_value}`,
// };
// // Push to subscriber queue with the monitor tag
// await subscriberQueue.push(subscriptionVariables, [monitor_tag]);
// }
const getQueue = () => {
if (!alertingQueue) {
alertingQueue = q.createQueue(queueName);
@@ -253,6 +251,8 @@ const addWorker = () => {
}
// Send triggered alert notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
// Send subscriber notifications
//await sendSubscriberNotifications(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
}
} else {
//all good, resolve any existing alert
@@ -267,6 +267,8 @@ const addWorker = () => {
// Send resolution notifications
await sendAlertNotifications(activeAlert, monitor_alerts_configured, templateSiteVars);
// Send subscriber notifications
//await sendSubscriberNotifications(activeAlert, monitor_alerts_configured, monitor_name, monitor_tag);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Sender Queue - handles individual email sending
* Receives email configuration, variables, template, and recipient email
*/
import { Queue, Worker, Job, type JobsOptions } from "bullmq";
import q from "./q.js";
import sendEmail from "../notification/email_notification.js";
import type { TemplateRecord } from "../types/db.js";
import type {
SMTPConfiguration,
ResendAPIConfiguration,
SiteDataForNotification,
SubscriptionVariableMap,
WebhookConfiguration,
} from "../notification/types.js";
let senderQueue: Queue | null = null;
let worker: Worker | null = null;
const queueName = "senderQueue";
const jobNamePrefix = "sendJob";
const getQueue = () => {
if (!senderQueue) {
senderQueue = q.createQueue(queueName);
}
return senderQueue;
};
const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job): Promise<void> => {
const { details } = job.data as { details: NotificationObject };
try {
if (details.type === "email" && details.emailConfig && details.emailTo) {
const { emailConfig, variables, template, templateSiteVars, emailTo } = details;
await sendEmail(emailConfig, variables, template, templateSiteVars, [emailTo]);
console.log(`📧 Email sent to ${emailTo}`);
}
} catch (error) {
// console.error(`Failed to send email to ${toEmail}:`, error);
throw error; // Re-throw to trigger retry
}
});
worker.on("completed", (job: Job) => {
// const { toEmail } = job.data as SenderJobData;
// console.log(`✅ Email job completed for ${toEmail}`);
});
worker.on("failed", (job: Job | undefined, err: Error) => {
const toEmail = job?.data?.toEmail || "unknown";
console.error(`❌ Email job failed for ${toEmail}:`, err.message);
});
return worker;
};
/**
* Push an email sending job to the queue
*/
export interface NotificationObject {
emailConfig?: SMTPConfiguration | ResendAPIConfiguration;
variables: SubscriptionVariableMap;
template: TemplateRecord;
templateSiteVars: SiteDataForNotification;
emailTo?: string;
id: string;
type: string;
}
export const push = async (details: NotificationObject, options?: JobsOptions) => {
if (!options) {
options = {};
}
const queue = getQueue();
addWorker();
// Use deduplication to prevent duplicate emails
const deDupId = details.id;
if (!options.deduplication) {
options.deduplication = {
id: deDupId,
};
}
await queue.add(`${jobNamePrefix}_${details.emailTo}`, details, options);
};
/**
* Graceful shutdown
*/
export const shutdown = async () => {
if (worker) {
await worker.close();
worker = null;
}
};
export default {
push,
shutdown,
};
+139
View File
@@ -0,0 +1,139 @@
/**
* Subscriber Queue - receives subscription variable data and trigger id
* Finds all subscribers for a given trigger and sends emails via senderQueue
*/
import { Queue, Worker, Job, type JobsOptions } from "bullmq";
import q from "./q.js";
import db from "../db/db.js";
import { CreateHash, CreateMD5Hash, GetAllSiteData } from "../controllers/controller.js";
import { siteDataToVariables, getPreferredEmailConfiguration } from "../notification/notification_utils.js";
import { GetTemplateByEmailType } from "../controllers/emailTemplateConfigController.js";
import senderQueue from "./senderQueue.js";
import type { SubscriptionVariableMap } from "../notification/types.js";
import { GetSubscriptionMethodsByEntity } from "../controllers/userSubscriptionsController.js";
let subscriberQueue: Queue | null = null;
let worker: Worker | null = null;
const queueName = "subscriberQueue";
const jobNamePrefix = "subscriberJob";
interface SubscriberJobData {
variables: SubscriptionVariableMap;
entity_type: string;
entity_id: string;
}
const getQueue = () => {
if (!subscriberQueue) {
subscriberQueue = q.createQueue(queueName);
}
return subscriberQueue;
};
const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job): Promise<void> => {
const { variables, entity_type, entity_id } = job.data as SubscriberJobData;
try {
// Get site data for template variables
const siteData = await GetAllSiteData();
const templateSiteVars = siteDataToVariables(siteData);
//given entity_type = 'incident' and entity_id get subscription method details
let subscribers = await GetSubscriptionMethodsByEntity(entity_type, entity_id);
if (subscribers.length === 0) {
console.log(`No active subscribers found for ${entity_type} with ID: ${entity_id}`);
return;
}
//get template for email
const emailTemplate = await GetTemplateByEmailType("subscription_update");
for (const subscriber of subscribers) {
const methodDetails = subscriber.method;
if (methodDetails.method_type === "email" && emailTemplate) {
const emailID = methodDetails.method_value;
const methodId = methodDetails.id;
const messageHash = CreateMD5Hash(
JSON.stringify({
variables,
}),
);
await senderQueue.push({
emailConfig: getPreferredEmailConfiguration(),
variables,
template: emailTemplate,
templateSiteVars,
emailTo: emailID,
type: "email",
id: `${entity_type}-${entity_id}-${methodId}-${messageHash}`,
});
}
}
// console.log(`📮 Queued ${subscriberEmails.length} subscription emails for monitors: ${monitor_tags.join(", ")}`);
} catch (error) {
console.error("Error processing subscriber queue job:", error);
throw error;
}
});
worker.on("completed", (job: Job) => {
// const { monitor_tags } = job.data as SubscriberJobData;
// console.log(`✅ Subscriber job completed for monitors: ${monitor_tags.join(", ")}`);
});
worker.on("failed", (job: Job | undefined, err: Error) => {
console.error("❌ Subscriber job failed:", err.message);
});
return worker;
};
/**
* Push a subscriber notification job to the queue
*/
export const push = async (variables: SubscriptionVariableMap, monitor_tags: string[], options?: JobsOptions) => {
if (!options) {
options = {};
}
const queue = getQueue();
addWorker();
// Use deduplication to prevent duplicate notifications
const deDupId = `subscriber-${monitor_tags.join("-")}-${variables.update_subject}-${Date.now()}`;
if (!options.deduplication) {
options.deduplication = {
id: deDupId,
};
}
await queue.add(
`${jobNamePrefix}_${monitor_tags.join("_")}`,
{
variables,
monitor_tags,
},
options,
);
};
/**
* Graceful shutdown
*/
export const shutdown = async () => {
if (worker) {
await worker.close();
worker = null;
}
};
export default {
push,
shutdown,
};
+12 -1
View File
@@ -8,6 +8,7 @@ import { HashString } from "../tool.js";
import { getSchedulers, addJobToSchedulerQueue, removeJobFromSchedulerQueue } from "./monitorSchedulers.js";
import { GetMonitorsParsed } from "../controllers/controller.js";
import { GetAllSecrets } from "../controllers/vaultController.js";
let appSchedulerQueue: Queue | null = null;
let worker: Worker | null = null;
@@ -27,6 +28,16 @@ const addWorker = () => {
if (worker) return worker;
worker = q.createWorker(getQueue(), async (job: Job) => {
// Fetch secrets from vault and store in environment variables
try {
const secrets = await GetAllSecrets();
for (const secret of secrets) {
process.env[secret.secret_name] = secret.secret_value;
}
} catch (error) {
console.error("Failed to sync vault secrets to environment:", error);
}
const activeMonitors = (await GetMonitorsParsed({ status: "ACTIVE" })).map((monitor) => ({
...monitor,
hash: monitor.tag + "::" + HashString(JSON.stringify(monitor)),
@@ -91,7 +102,7 @@ export const start = async (options?: JobSchedulerTemplateOptions) => {
await queue.upsertJobScheduler(
jobNamePrefix + "_main_job",
{
every: 10000, // Job will repeat every 1000 milliseconds (1 second)
every: 10000, // Job will repeat every 10000 milliseconds (10 seconds)
},
{
opts: options,
@@ -0,0 +1,192 @@
const emailTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{site_logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
padding-top: 40px;
padding-bottom: 40px;
"
>
<!--$-->
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
Your verification code: {{email_code}}
</div>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background-color: rgb(255, 255, 255);
border-radius: 8px;
margin-left: auto;
margin-right: auto;
margin-top: 0px;
margin-bottom: 0px;
padding: 24px;
max-width: 600px;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="margin-top: 8px; margin-bottom: 32px; text-align: center"
>
<tbody>
<tr>
<td>
<img
alt="{{site_name}}"
height="40"
src="{{site_logo_url}}"
style="
margin-left: auto;
margin-right: auto;
display: block;
outline: none;
border: none;
text-decoration: none;
"
width="120"
/>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tbody>
<tr>
<td>
<h1
style="
font-size: 24px;
font-weight: 700;
color: rgb(31, 41, 55);
margin-bottom: 16px;
text-align: center;
"
>
Verification Code
</h1>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin-top: 16px;
"
>
To complete your {{action}} for {{site_name}}, please use the
verification code below:
</p>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background-color: rgb(249, 250, 251);
border-width: 1px;
border-color: rgb(229, 231, 235);
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
text-align: center;
"
>
<tbody>
<tr>
<td>
<p
style="
font-size: 32px;
font-weight: 700;
letter-spacing: 4px;
color: rgb(31, 41, 55);
margin: 0px;
line-height: 24px;
margin-bottom: 0px;
margin-top: 0px;
margin-left: 0px;
margin-right: 0px;
"
>
{{email_code}}
</p>
</td>
</tr>
</tbody>
</table>
<p
style="
font-size: 16px;
color: rgb(75, 85, 99);
margin-bottom: 24px;
line-height: 24px;
margin-top: 16px;
"
>
This code will expire in 5 minutes. If you didn&#x27;t request this code, you
can safely ignore this email.
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--7--><!--/$-->
</body>
</html>`;
export default {
template_name: "Default Email Code",
template_type: "EMAIL",
template_usage: "GENERAL",
template_json: JSON.stringify(
{
email_subject: "{{email_subject}}",
email_body: emailTemplate,
},
null,
2,
),
};
@@ -0,0 +1,262 @@
const emailTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="{{site_logo_url}}" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body
style="
background-color: rgb(243, 244, 246);
font-family:
ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;,
&quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;
padding-top: 40px;
padding-bottom: 40px;
"
>
<!--$-->
<div
style="
display: none;
overflow: hidden;
line-height: 1px;
opacity: 0;
max-height: 0;
max-width: 0;
"
>
{{title}}
</div>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
max-width: 600px;
margin-left: auto;
margin-right: auto;
background-color: rgb(255, 255, 255);
border-radius: 8px;
overflow: hidden;
"
>
<tbody>
<tr style="width: 100%">
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
background-color: #e4e5ec;
padding-left: 24px;
padding-right: 24px;
padding-top: 24px;
padding-bottom: 24px;
"
>
<tbody>
<tr>
<td>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
>
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="text-align: center">
<img
alt="{{site_name}}"
src="{{site_logo_url}}"
style="
height: auto;
display: block;
outline: none;
border: none;
text-decoration: none;
margin-left: auto;
margin-right: auto;
"
width="80"
/>
<p
style="
font-size: 20px;
font-weight: bold;
color: #191919;
margin: 8px 0 0 0;
text-align: center;
"
>
{{site_name}}
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
padding-left: 24px;
padding-right: 24px;
padding-top: 32px;
padding-bottom: 32px;
"
>
<tbody>
<tr>
<td>
<h1
style="
font-size: 24px;
font-weight: 700;
color: rgb(31, 41, 55);
margin-bottom: 16px;
"
>
{{title}}
</h1>
<p
style="
font-size: 16px;
color: rgb(55, 65, 81);
margin-bottom: 16px;
line-height: 24px;
margin-top: 16px;
"
>
Dear Valued Customer,
</p>
<p
style="
font-size: 16px;
color: rgb(55, 65, 81);
margin-bottom: 16px;
line-height: 24px;
margin-top: 16px;
"
>
We would like to provide you with an update regarding the current system
status.
</p>
<p
style="
font-size: 16px;
color: rgb(55, 65, 81);
margin-bottom: 16px;
line-height: 24px;
margin-top: 16px;
"
>
<strong>Update:</strong>
{{update_text}}
</p>
<a
href="{{cta_url}}"
style="
background-color: rgb(22, 163, 74);
color: rgb(255, 255, 255);
font-weight: 700;
padding-top: 12px;
padding-bottom: 12px;
padding-left: 24px;
padding-right: 24px;
border-radius: 4px;
font-size: 16px;
text-decoration-line: none;
text-align: center;
display: block;
box-sizing: border-box;
line-height: 100%;
text-decoration: none;
max-width: 100%;
mso-padding-alt: 0px;
padding: 12px 24px 12px 24px;
"
target="_blank"
><span
><!--[if mso
]><i style="mso-font-width: 400%; mso-text-raise: 18" hidden
>&#8202;&#8202;&#8202;</i
><!
[endif]--></span
><span
style="
max-width: 100%;
display: inline-block;
line-height: 120%;
mso-padding-alt: 0px;
mso-text-raise: 9px;
"
>{{cta_text}}</span
><span
><!--[if mso
]><i style="mso-font-width: 400%" hidden
>&#8202;&#8202;&#8202;&#8203;</i
><!
[endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr
style="
border-top-width: 1px;
border-color: rgb(209, 213, 219);
margin-top: 8px;
margin-bottom: 8px;
width: 100%;
border: none;
border-top: 1px solid #eaeaea;
"
/>
<footer>
<p style="text-align: center; color: #6b7280; font-size: 16px; margin: 24px 0 0 0">
Thank you,<br />The {{site_name}} Team
</p>
</footer>
<div style="height: 32px"></div>
</td>
</tr>
</tbody>
</table>
<!--7--><!--/$-->
</body>
</html>`;
export default {
template_name: "Default Email Update",
template_type: "EMAIL",
template_usage: "SUBSCRIPTION",
template_json: JSON.stringify(
{
email_subject: "{{update_subject}}",
email_body: emailTemplate,
},
null,
2,
),
};
+7 -1
View File
@@ -165,6 +165,12 @@ export interface TriggerRecordParsed<T extends TriggerMetaJson = TriggerMetaJson
export interface TriggerMetaEmailJson {
to: string[];
from: string;
email_type: string;
smtp_host?: string;
smtp_port?: string | number;
smtp_secure?: boolean;
smtp_user?: string;
smtp_pass?: string;
}
export interface TriggerMetaWebhookJson {
@@ -741,7 +747,7 @@ export interface MonitorAlertV2WithConfig extends MonitorAlertV2Record {
// ============ templates table ============
export type TemplateType = "EMAIL" | "WEBHOOK" | "SLACK" | "DISCORD";
export type TemplateUsageType = "ALERT" | "SUBSCRIPTION";
export type TemplateUsageType = "ALERT" | "SUBSCRIPTION" | "GENERAL";
// Template JSON types for each template type
export interface EmailTemplateJson {
@@ -70,7 +70,7 @@
</Item.Content>
</Item.Root>
<div class="my-4 flex justify-end gap-2">
<ThemePlus showEventsButton={true} showHomeButton={true} />
<ThemePlus showEventsButton={true} showHomeButton={true} incident_ids={[data.incident.id]} />
</div>
<!-- Incident Meta -->
<div class="mb-4 flex flex-col items-start gap-4 rounded-3xl border p-4 text-sm">
+6 -1
View File
@@ -26,6 +26,9 @@
import Columns3CogIcon from "@lucide/svelte/icons/columns-3-cog";
import SiteHeader from "./manage/site-header.svelte";
import TemplateIcon from "@lucide/svelte/icons/layout-template";
import EmailTemplateIcon from "@lucide/svelte/icons/mail-plus";
import VaultIcon from "@lucide/svelte/icons/vault";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
@@ -49,7 +52,9 @@
{ title: "Users", url: "/manage/app/users", icon: UsersIcon },
{ title: "Badges", url: "/manage/app/badges", icon: BadgeIcon },
{ title: "Embed", url: "/manage/app/embed", icon: CodeIcon },
{ title: "Templates", url: "/manage/app/templates", icon: TemplateIcon }
{ title: "Templates", url: "/manage/app/templates", icon: TemplateIcon },
{ title: "Email Customization", url: "/manage/app/email-customization", icon: EmailTemplateIcon },
{ title: "Vault", url: "/manage/app/vault", icon: VaultIcon }
];
// Derive page title from current URL
+85 -3
View File
@@ -120,8 +120,26 @@ import {
DeleteUserSubscription,
UpdateUserSubscriptionStatus,
} from "$lib/server/controllers/userSubscriptionsController.js";
import {
GetAllEmailTemplateConfigsWithTemplates,
UpdateAllEmailTemplateConfigs,
GetEmailTemplates,
EMAIL_TYPE_LABELS,
EMAIL_TYPE_DESCRIPTIONS,
} from "$lib/server/controllers/emailTemplateConfigController.js";
import {
GetAllSecrets,
GetSecretById,
CreateSecret,
UpdateSecret,
DeleteSecret,
} from "$lib/server/controllers/vaultController.js";
import type { AlertData, SiteDataForNotification } from "$lib/server/notification/variables";
import { alertToVariables, siteDataToVariables } from "$lib/server/notification/notification_utils";
import {
alertToVariables,
getEmailConfigFromTriggerMeta,
siteDataToVariables,
} from "$lib/server/notification/notification_utils";
import type {
TriggerRecordParsed,
TriggerMetaEmailJson,
@@ -351,11 +369,14 @@ export async function POST({ request, cookies }) {
templateSiteVars,
);
} else if (trigger.trigger_type === "email") {
const emailSendingConfig = getEmailConfigFromTriggerMeta(triggerParsed.trigger_meta as TriggerMetaEmailJson);
const toAddresses = (triggerParsed.trigger_meta as TriggerMetaEmailJson).to;
resp = await sendEmail(
triggerParsed as TriggerRecordParsed<TriggerMetaEmailJson>,
emailSendingConfig,
templateAlertVars as AlertData,
template,
templateSiteVars,
toAddresses,
);
} else if (trigger.trigger_type === "slack") {
resp = await sendSlack(
@@ -530,7 +551,7 @@ export async function POST({ request, cookies }) {
} else if (action == "getTemplatesByUsage") {
resp = await GetTemplatesByUsage(data.template_usage);
} else if (action == "getTemplatesByTypeAndUsage") {
resp = await GetTemplatesByTypeAndUsage(data.template_type, data.template_usage);
resp = await GetTemplatesByTypeAndUsage(data.template_type, data.template_usages);
} else if (action == "deleteTemplate") {
AdminEditorCan(userDB.role);
await DeleteTemplate(data.id);
@@ -629,6 +650,67 @@ export async function POST({ request, cookies }) {
}
resp = await UpdateUserSubscriptionStatus(subscriptionId, status);
}
// ============ Email Template Config ============
else if (action == "getEmailTemplateConfigs") {
const configs = await GetAllEmailTemplateConfigsWithTemplates();
// Add labels and descriptions
resp = configs.map((config) => ({
...config,
label: EMAIL_TYPE_LABELS[config.email_type as keyof typeof EMAIL_TYPE_LABELS] || config.email_type,
description: EMAIL_TYPE_DESCRIPTIONS[config.email_type as keyof typeof EMAIL_TYPE_DESCRIPTIONS] || "",
}));
} else if (action == "updateEmailTemplateConfigs") {
AdminCan(userDB.role);
const { configs } = data;
if (!configs || !Array.isArray(configs)) {
throw new Error("configs array is required");
}
resp = await UpdateAllEmailTemplateConfigs(configs);
} else if (action == "getEmailTemplates") {
resp = await GetEmailTemplates();
}
// ============ Vault ============
else if (action == "getVaultSecrets") {
AdminCan(userDB.role);
resp = await GetAllSecrets();
} else if (action == "getVaultSecret") {
AdminCan(userDB.role);
const { id } = data;
if (!id) {
throw new Error("Secret ID is required");
}
resp = await GetSecretById(id);
if (!resp) {
throw new Error("Secret not found");
}
} else if (action == "createVaultSecret") {
AdminCan(userDB.role);
const { secret_name, secret_value } = data;
resp = await CreateSecret(secret_name, secret_value);
if (!resp.success) {
throw new Error(resp.error);
}
} else if (action == "updateVaultSecret") {
AdminCan(userDB.role);
const { id, secret_name, secret_value } = data;
if (!id) {
throw new Error("Secret ID is required");
}
resp = await UpdateSecret(id, { secret_name, secret_value });
if (!resp.success) {
throw new Error(resp.error);
}
} else if (action == "deleteVaultSecret") {
AdminCan(userDB.role);
const { id } = data;
if (!id) {
throw new Error("Secret ID is required");
}
resp = await DeleteSecret(id);
if (!resp.success) {
throw new Error(resp.error);
}
}
} catch (error: unknown) {
console.log(error);
const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,248 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as Alert from "$lib/components/ui/alert/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import MailIcon from "@lucide/svelte/icons/mail";
import KeyIcon from "@lucide/svelte/icons/key";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import LockIcon from "@lucide/svelte/icons/lock";
import SaveIcon from "@lucide/svelte/icons/save";
import { toast } from "svelte-sonner";
import { onMount } from "svelte";
// Types
interface EmailTemplateConfig {
id: number;
email_type: string;
email_template_id: number | null;
is_active: string;
template_name: string | null;
label: string;
description: string;
created_at: string;
updated_at: string;
}
interface TemplateRecord {
id: number;
template_name: string;
template_type: string;
}
// State
let loading = $state(true);
let saving = $state(false);
let configs = $state<EmailTemplateConfig[]>([]);
let emailTemplates = $state<TemplateRecord[]>([]);
// Icon mapping for email types
const emailTypeIcons: Record<string, typeof MailIcon> = {
email_code: KeyIcon,
forgot_password: LockIcon,
verify_email: ShieldCheckIcon
};
// API functions
async function loadConfigs() {
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getEmailTemplateConfigs" })
});
const result = await res.json();
if (!result.error && Array.isArray(result)) {
configs = result;
}
} catch (error) {
console.error("Error loading email template configs:", error);
toast.error("Failed to load email configurations");
}
}
async function loadTemplates() {
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getEmailTemplates" })
});
const result = await res.json();
if (!result.error && Array.isArray(result)) {
emailTemplates = result;
}
} catch (error) {
console.error("Error loading templates:", error);
toast.error("Failed to load email templates");
}
}
async function saveConfigs() {
saving = true;
try {
const configsToSave = configs.map((c) => ({
email_type: c.email_type,
email_template_id: c.email_template_id,
is_active: c.is_active
}));
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "updateEmailTemplateConfigs",
data: { configs: configsToSave }
})
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success("Email configurations saved successfully");
}
} catch (error) {
console.error("Error saving email configs:", error);
toast.error("Failed to save email configurations");
} finally {
saving = false;
}
}
function handleTemplateChange(emailType: string, value: string) {
const configIndex = configs.findIndex((c) => c.email_type === emailType);
if (configIndex !== -1) {
configs[configIndex].email_template_id = value === "none" ? null : parseInt(value);
}
}
function handleActiveChange(emailType: string, checked: boolean) {
const configIndex = configs.findIndex((c) => c.email_type === emailType);
if (configIndex !== -1) {
configs[configIndex].is_active = checked ? "YES" : "NO";
}
}
function getTemplateName(templateId: number | null): string {
if (!templateId) return "Not selected";
const template = emailTemplates.find((t) => t.id === templateId);
return template?.template_name || "Unknown template";
}
// Initial load
onMount(async () => {
await Promise.all([loadConfigs(), loadTemplates()]);
loading = false;
});
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<MailIcon class="text-muted-foreground size-6" />
<div>
<h1 class="text-2xl font-bold">Email Customization</h1>
<p class="text-muted-foreground text-sm">Configure email templates for different email types</p>
</div>
</div>
</div>
{#if loading}
<div class="flex h-96 items-center justify-center">
<Spinner class="size-8" />
</div>
{:else}
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<MailIcon class="text-muted-foreground size-5" />
<div>
<Card.Title>Email Template Configuration</Card.Title>
<Card.Description>
Select which email template to use for each email type and enable/disable them
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if emailTemplates.length === 0}
<Alert.Root variant="default">
<Alert.Description>
No email templates available. <a href="/manage/app/templates/new" class="text-primary underline"
>Create an email template</a
> first before configuring email customization.
</Alert.Description>
</Alert.Root>
{/if}
{#each configs as config (config.id)}
{@const IconComponent = emailTypeIcons[config.email_type] || MailIcon}
<div class="rounded-lg border p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<IconComponent class="text-primary size-5" />
</div>
<div>
<Label class="font-medium">{config.label}</Label>
<p class="text-muted-foreground text-xs">{config.description}</p>
</div>
</div>
<Switch
checked={config.is_active === "YES"}
onCheckedChange={(checked) => handleActiveChange(config.email_type, checked)}
/>
</div>
<div class="mt-4 flex items-center gap-4">
<Label class="min-w-32 text-sm">Email Template</Label>
<Select.Root
type="single"
value={config.email_template_id ? String(config.email_template_id) : "none"}
onValueChange={(v) => handleTemplateChange(config.email_type, v)}
disabled={emailTemplates.length === 0}
>
<Select.Trigger class="w-full">
{getTemplateName(config.email_template_id)}
</Select.Trigger>
<Select.Content>
<Select.Item value="none">Not selected</Select.Item>
{#each emailTemplates as template (template.id)}
<Select.Item value={String(template.id)}>{template.template_name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if config.is_active === "YES" && !config.email_template_id}
<p class="text-muted-foreground mt-2 text-xs">
⚠️ This email type is enabled but no template is selected. Emails won't be sent until a template is
configured.
</p>
{/if}
</div>
{/each}
{#if configs.length === 0}
<div class="text-muted-foreground py-8 text-center">
No email configurations found. Please run database migrations.
</div>
{/if}
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={saveConfigs} disabled={saving || loading}>
{#if saving}
<Spinner class="mr-2 size-4" />
{:else}
<SaveIcon class="mr-2 size-4" />
{/if}
Save Configuration
</Button>
</Card.Footer>
</Card.Root>
{/if}
</div>
@@ -105,7 +105,7 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getTemplatesByTypeAndUsage",
data: { template_type: templateType, template_usage: "ALERT" }
data: { template_type: templateType, template_usages: ["ALERT", "SUBSCRIPTION"] }
})
});
const result = await response.json();
@@ -0,0 +1,337 @@
<script lang="ts">
import { SvelteSet } from "svelte/reactivity";
import * as Card from "$lib/components/ui/card/index.js";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import KeyIcon from "@lucide/svelte/icons/key";
import PlusIcon from "@lucide/svelte/icons/plus";
import PencilIcon from "@lucide/svelte/icons/pencil";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import EyeIcon from "@lucide/svelte/icons/eye";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import SaveIcon from "@lucide/svelte/icons/save";
import XIcon from "@lucide/svelte/icons/x";
import { toast } from "svelte-sonner";
import { onMount } from "svelte";
// Types
interface VaultSecret {
id: number;
secret_name: string;
secret_value: string;
created_at: string;
updated_at: string;
}
// State
let loading = $state(true);
let saving = $state(false);
let secrets = $state<VaultSecret[]>([]);
let visibleSecrets = new SvelteSet<number>();
// Form state
let isEditing = $state(false);
let editingId = $state<number | null>(null);
let formName = $state("");
let formValue = $state("");
// Delete confirmation state
let deleteDialogOpen = $state(false);
let secretToDelete = $state<VaultSecret | null>(null);
let isDeleting = $state(false);
// API functions
async function loadSecrets() {
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getVaultSecrets" })
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else if (Array.isArray(result)) {
secrets = result;
}
} catch (error) {
console.error("Error loading secrets:", error);
toast.error("Failed to load secrets");
}
}
async function saveSecret() {
if (!formName.trim()) {
toast.error("Secret name is required");
return;
}
if (!formValue.trim()) {
toast.error("Secret value is required");
return;
}
saving = true;
try {
const action = editingId ? "updateVaultSecret" : "createVaultSecret";
const payload: Record<string, unknown> = {
action,
data: {
secret_name: formName.trim(),
secret_value: formValue
}
};
if (editingId) {
payload.data = { ...(payload.data as object), id: editingId };
}
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success(editingId ? "Secret updated successfully" : "Secret created successfully");
resetForm();
await loadSecrets();
}
} catch (error) {
console.error("Error saving secret:", error);
toast.error("Failed to save secret");
} finally {
saving = false;
}
}
async function confirmDelete() {
if (!secretToDelete) return;
isDeleting = true;
try {
const res = await fetch("/manage/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "deleteVaultSecret",
data: { id: secretToDelete.id }
})
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success("Secret deleted successfully");
await loadSecrets();
}
} catch (error) {
console.error("Error deleting secret:", error);
toast.error("Failed to delete secret");
} finally {
isDeleting = false;
deleteDialogOpen = false;
secretToDelete = null;
}
}
function startEdit(secret: VaultSecret) {
editingId = secret.id;
formName = secret.secret_name;
formValue = secret.secret_value;
isEditing = true;
}
function startCreate() {
editingId = null;
formName = "";
formValue = "";
isEditing = true;
}
function resetForm() {
editingId = null;
formName = "";
formValue = "";
isEditing = false;
}
function openDeleteDialog(secret: VaultSecret) {
secretToDelete = secret;
deleteDialogOpen = true;
}
function toggleSecretVisibility(id: number) {
if (visibleSecrets.has(id)) {
visibleSecrets.delete(id);
} else {
visibleSecrets.add(id);
}
}
function maskValue(value: string): string {
return "*".repeat(Math.min(value.length, 20));
}
// Initial load
onMount(async () => {
await loadSecrets();
loading = false;
});
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<KeyIcon class="text-muted-foreground size-6" />
<div>
<h1 class="text-2xl font-bold">Vault</h1>
<p class="text-muted-foreground text-sm">Securely store and manage your secrets</p>
</div>
</div>
{#if !isEditing}
<Button onclick={startCreate}>
<PlusIcon class="mr-2 size-4" />
Add Secret
</Button>
{/if}
</div>
{#if loading}
<div class="flex h-96 items-center justify-center">
<Spinner class="size-8" />
</div>
{:else}
<!-- Form Card -->
{#if isEditing}
<Card.Root>
<Card.Header>
<Card.Title>{editingId ? "Edit Secret" : "Add New Secret"}</Card.Title>
<Card.Description>
{editingId ? "Update the secret details below" : "Enter the secret name and value below"}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label for="secret-name">Secret Name</Label>
<Input id="secret-name" placeholder="e.g., API_KEY" bind:value={formName} disabled={saving} />
</div>
<div class="space-y-2">
<Label for="secret-value">Secret Value</Label>
<Textarea
id="secret-value"
placeholder="Enter secret value..."
bind:value={formValue}
disabled={saving}
rows={4}
/>
</div>
</Card.Content>
<Card.Footer class="flex justify-end gap-2">
<Button variant="outline" onclick={resetForm} disabled={saving}>
<XIcon class="mr-2 size-4" />
Cancel
</Button>
<Button onclick={saveSecret} disabled={saving}>
{#if saving}
<Spinner class="mr-2 size-4" />
{:else}
<SaveIcon class="mr-2 size-4" />
{/if}
{editingId ? "Update Secret" : "Save Secret"}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Secrets List -->
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<KeyIcon class="text-muted-foreground size-5" />
<div>
<Card.Title>Stored Secrets</Card.Title>
<Card.Description>All secrets are encrypted using KENER_SECRET_KEY</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
{#if secrets.length === 0}
<div class="text-muted-foreground py-8 text-center">
No secrets stored yet. Click "Add Secret" to create your first secret.
</div>
{:else}
<div class="space-y-3">
{#each secrets as secret (secret.id)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<KeyIcon class="text-muted-foreground size-4" />
<span class="font-medium">{secret.secret_name}</span>
</div>
<div class="flex items-center gap-2">
<code class="bg-muted rounded px-2 py-1 font-mono text-sm">
{visibleSecrets.has(secret.id) ? secret.secret_value : maskValue(secret.secret_value)}
</code>
<Button
variant="ghost"
size="sm"
class="size-8 p-0"
onclick={() => toggleSecretVisibility(secret.id)}
>
{#if visibleSecrets.has(secret.id)}
<EyeOffIcon class="size-4" />
{:else}
<EyeIcon class="size-4" />
{/if}
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => startEdit(secret)} disabled={isEditing}>
<PencilIcon class="mr-2 size-4" />
Edit
</Button>
<Button variant="destructive" size="sm" onclick={() => openDeleteDialog(secret)} disabled={isEditing}>
<TrashIcon class="mr-2 size-4" />
Delete
</Button>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Secret</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete the secret "{secretToDelete?.secret_name}"? This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={isDeleting}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={confirmDelete}
disabled={isDeleting}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if isDeleting}
<Spinner class="mr-2 size-4" />
{/if}
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>