From c114feb32ef642c6e2e087c993fecd5dac3dc001 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Thu, 4 Dec 2025 17:34:24 +0100 Subject: [PATCH] Add ntfy.sh --- .env.self-host.example | 18 + CLAUDE.md | 1 + apps/api/src/controllers/Auth.ts | 4 + apps/api/src/controllers/Oauth/Github.ts | 8 + apps/api/src/controllers/Oauth/Google.ts | 8 + apps/api/src/controllers/Users.ts | 22 + apps/api/src/controllers/Webhooks.ts | 33 +- apps/api/src/services/BillingLimitService.ts | 24 + apps/api/src/services/CampaignService.ts | 85 +- apps/api/src/services/DomainService.ts | 41 +- apps/api/src/services/NtfyService.ts | 727 ++++++++++++++++++ apps/api/src/services/SecurityService.ts | 16 +- .../src/services/WorkflowExecutionService.ts | 25 +- apps/api/src/services/WorkflowService.ts | 46 +- docker-compose.yml | 31 + 15 files changed, 1070 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/services/NtfyService.ts diff --git a/.env.self-host.example b/.env.self-host.example index 4cb5a9b..caa7a2a 100644 --- a/.env.self-host.example +++ b/.env.self-host.example @@ -75,6 +75,24 @@ S3_BUCKET=uploads S3_PUBLIC_URL=http://localhost:9000/uploads S3_FORCE_PATH_STYLE=true +# ======================================== +# OPTIONAL: Notifications (ntfy.sh) +# ======================================== +# Plunk includes a self-hosted ntfy.sh server for system notifications +# You can access the ntfy web UI at http://localhost:8080 (or your configured NTFY_PORT) +# Subscribe to the topic 'plunk-notifications' to receive notifications + +# Port for ntfy web UI and API (default: 8080) +NTFY_PORT=8080 + +# Ntfy topic URL - Uses internal ntfy service by default +# To use external ntfy.sh or your own server, change this URL +# Examples: +# - Self-hosted (default): http://ntfy/plunk-notifications +# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name +# - Custom server: https://your-ntfy-server.com/your-topic +NTFY_URL=http://ntfy/plunk-notifications + # ======================================== # OPTIONAL: SMTP Server # ======================================== diff --git a/CLAUDE.md b/CLAUDE.md index bb60d7b..d2238ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,7 @@ Required for builds and deployment (see turbo.json and .env.example): - OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET` - Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`, `STRIPE_METER_EVENT_NAME` +- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications) **Important Notes:** diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts index e872e00..18fa0b2 100644 --- a/apps/api/src/controllers/Auth.ts +++ b/apps/api/src/controllers/Auth.ts @@ -7,6 +7,7 @@ import {prisma} from '../database/prisma.js'; import {redis, REDIS_ONE_MINUTE} from '../database/redis.js'; import {jwt} from '../middleware/auth.js'; import {AuthService} from '../services/AuthService.js'; +import {NtfyService} from '../services/NtfyService.js'; import {UserService} from '../services/UserService.js'; import {Keys} from '../services/keys.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -68,6 +69,9 @@ export class Auth { await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60); + // Send notification about new user signup + await NtfyService.notifyUserSignup(created_user.email, created_user.id); + const token = jwt.sign(created_user.id); const cookie = UserService.cookieOptions(); diff --git a/apps/api/src/controllers/Oauth/Github.ts b/apps/api/src/controllers/Oauth/Github.ts index 15eeda2..318248d 100644 --- a/apps/api/src/controllers/Oauth/Github.ts +++ b/apps/api/src/controllers/Oauth/Github.ts @@ -10,6 +10,7 @@ import { } from '../../app/constants.js'; import {prisma} from '../../database/prisma.js'; import {jwt} from '../../middleware/auth.js'; +import {NtfyService} from '../../services/NtfyService.js'; import {UserService} from '../../services/UserService.js'; import {CatchAsync} from '../../utils/asyncHandler.js'; @@ -59,6 +60,7 @@ export class Github { const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email; let user = await UserService.email(email as string); + let isNewUser = false; if (!user) { user = await prisma.user.create({ @@ -67,12 +69,18 @@ export class Github { type: 'GITHUB_OAUTH', }, }); + isNewUser = true; } if (user.type !== 'GITHUB_OAUTH') { return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication'); } + // Send notification if this is a new user + if (isNewUser) { + await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'GitHub'); + } + const token = jwt.sign(user.id); const cookie = UserService.cookieOptions(); diff --git a/apps/api/src/controllers/Oauth/Google.ts b/apps/api/src/controllers/Oauth/Google.ts index 6280c8e..59418fe 100644 --- a/apps/api/src/controllers/Oauth/Google.ts +++ b/apps/api/src/controllers/Oauth/Google.ts @@ -10,6 +10,7 @@ import { } from '../../app/constants.js'; import {prisma} from '../../database/prisma.js'; import {jwt} from '../../middleware/auth.js'; +import {NtfyService} from '../../services/NtfyService.js'; import {UserService} from '../../services/UserService.js'; import {CatchAsync} from '../../utils/asyncHandler.js'; @@ -53,6 +54,7 @@ export class Google { ); let user = await UserService.email(email); + let isNewUser = false; if (!user) { user = await prisma.user.create({ @@ -61,12 +63,18 @@ export class Google { type: 'GOOGLE_OAUTH', }, }); + isNewUser = true; } if (user.type !== 'GOOGLE_OAUTH') { return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication'); } + // Send notification if this is a new user + if (isNewUser) { + await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'Google'); + } + const token = jwt.sign(user.id); const cookie = UserService.cookieOptions(); diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 63560e8..7a7d156 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -11,6 +11,7 @@ import {NotAuthenticated, NotFound} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; import {isAuthenticated} from '../middleware/auth.js'; import {BillingLimitService} from '../services/BillingLimitService.js'; +import {NtfyService} from '../services/NtfyService.js'; import {SecurityService} from '../services/SecurityService.js'; import {UserService} from '../services/UserService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -82,6 +83,9 @@ export class Users { }, }); + // Send notification about project creation + await NtfyService.notifyProjectCreated(project.name, project.id, auth.userId); + return res.status(201).json(project); } @@ -150,8 +154,22 @@ export class Users { public: publicKey, secret: secretKey, }, + select: { + id: true, + name: true, + public: true, + secret: true, + createdAt: true, + updatedAt: true, + disabled: true, + customer: true, + subscription: true, + }, }); + // Send notification about API key regeneration + await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!); + return res.status(200).json(project); } @@ -717,6 +735,7 @@ export class Users { const project = await prisma.project.findUnique({ where: {id}, select: { + name: true, subscription: true, customer: true, }, @@ -741,6 +760,9 @@ export class Users { where: {id}, }); + // Send notification about project deletion + await NtfyService.notifyProjectDeleted(project.name, id, auth.userId); + return res.status(200).json({success: true, message: 'Project deleted successfully'}); } } diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index f878e8c..cbcd2cd 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -9,6 +9,7 @@ import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; import {EventService} from '../services/EventService.js'; +import {NtfyService} from '../services/NtfyService.js'; import {SecurityService} from '../services/SecurityService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -165,6 +166,14 @@ export class Webhooks { bounceType: body.bounce?.bounceType, bouncedAt: now.toISOString(), }; + + // Send notification about bounce + await NtfyService.notifyEmailBounce( + email.project.name, + email.projectId, + email.contact.email, + body.bounce?.bounceType, + ); break; case 'Complaint': @@ -180,6 +189,9 @@ export class Webhooks { ...baseEventData, complainedAt: now.toISOString(), }; + + // Send notification about complaint + await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email); break; default: @@ -256,7 +268,7 @@ export class Webhooks { } // Update project with customer and subscription IDs - await prisma.project.update({ + const updatedProject = await prisma.project.update({ where: {id: projectId}, data: { customer: customerId, @@ -265,6 +277,9 @@ export class Webhooks { }); signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`); + + // Send notification about subscription started + await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId); break; } @@ -283,7 +298,9 @@ export class Webhooks { } signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`); - // Additional logic can be added here (e.g., extend trial, update billing status) + + // Send notification about invoice payment + await NtfyService.notifyInvoicePaid(project.name, project.id); break; } @@ -302,7 +319,9 @@ export class Webhooks { } signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`); - // Additional logic can be added here (e.g., disable project, send notification) + + // Send notification about payment failure + await NtfyService.notifyPaymentFailed(project.name, project.id); break; } @@ -329,7 +348,9 @@ export class Webhooks { }); signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`); - // Additional logic can be added here (e.g., downgrade to free plan) + + // Send notification about subscription cancellation + await NtfyService.notifySubscriptionCancelled(project.name, project.id, subscriptionId); break; } @@ -351,7 +372,9 @@ export class Webhooks { signale.info( `[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`, ); - // Additional logic can be added here (e.g., update subscription status, handle plan changes) + + // Send notification about subscription update + await NtfyService.notifySubscriptionUpdated(project.name, project.id); break; } diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index fc77ca5..22e7cd7 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -3,6 +3,7 @@ import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {redis} from '../database/redis.js'; +import {NtfyService} from './NtfyService.js'; /** * Usage information for a specific email category @@ -189,6 +190,17 @@ export class BillingLimitService { // Check if blocked (at or over limit) if (usage >= limit) { + // Get project name for notification + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {name: true}, + }); + + if (project) { + // Send notification about limit exceeded + await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType); + } + return { allowed: false, warning: false, @@ -202,6 +214,18 @@ export class BillingLimitService { // Check if warning (80% or more) const isWarning = percentage >= this.WARNING_THRESHOLD * 100; + // Send notification for warning threshold + if (isWarning) { + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {name: true}, + }); + + if (project) { + await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType); + } + } + return { allowed: true, warning: isWarning, diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 01fd5a5..3a1bcca 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -8,6 +8,7 @@ import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; import {DomainService} from './DomainService.js'; import {EmailService} from './EmailService.js'; +import {NtfyService} from './NtfyService.js'; import {QueueService} from './QueueService.js'; import {SegmentService} from './SegmentService.js'; import {DASHBOARD_URI} from '../app/constants.js'; @@ -71,7 +72,7 @@ export class CampaignService { } // Create campaign - return prisma.campaign.create({ + const campaign = await prisma.campaign.create({ data: { projectId, name: data.name, @@ -86,7 +87,17 @@ export class CampaignService { segmentId: data.segmentId, status: CampaignStatus.DRAFT, }, + include: { + project: { + select: {name: true}, + }, + }, }); + + // Send notification about campaign creation + await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId); + + return campaign; } /** @@ -208,7 +219,18 @@ export class CampaignService { * Delete a campaign */ public static async delete(projectId: string, campaignId: string): Promise { - const campaign = await this.get(projectId, campaignId); + const campaign = await prisma.campaign.findUnique({ + where: {id: campaignId, projectId}, + include: { + project: { + select: {name: true}, + }, + }, + }); + + if (!campaign) { + throw new HttpException(404, 'Campaign not found'); + } // Can only delete draft campaigns if (campaign.status !== CampaignStatus.DRAFT) { @@ -218,6 +240,9 @@ export class CampaignService { await prisma.campaign.delete({ where: {id: campaignId}, }); + + // Send notification about campaign deletion + await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId); } /** @@ -283,11 +308,25 @@ export class CampaignService { scheduledFor, totalRecipients: recipientCount, }, + include: { + project: { + select: {name: true}, + }, + }, }); // Queue for scheduled sending await QueueService.scheduleCampaign(campaignId, scheduledFor); + // Send notification about campaign scheduled + await NtfyService.notifyCampaignScheduled( + updatedCampaign.name, + updatedCampaign.project.name, + projectId, + scheduledFor, + recipientCount, + ); + return updatedCampaign; } else { // Send immediately - start the batch processing @@ -319,15 +358,28 @@ export class CampaignService { } // Update campaign to SENDING status - await prisma.campaign.update({ + const updatedCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.SENDING, totalRecipients: recipientCount, sentAt: new Date(), }, + include: { + project: { + select: {name: true}, + }, + }, }); + // Send notification about campaign sending started + await NtfyService.notifyCampaignSendingStarted( + updatedCampaign.name, + updatedCampaign.project.name, + projectId, + recipientCount, + ); + // Queue first batch to start the cursor-based chain await QueueService.queueCampaignBatch({ campaignId, @@ -432,12 +484,25 @@ export class CampaignService { }); } else { // All batches processed, mark campaign as SENT - await prisma.campaign.update({ + const completedCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.SENT, }, + include: { + project: { + select: {name: true}, + }, + }, }); + + // Send notification about campaign send completed + await NtfyService.notifyCampaignSendCompleted( + completedCampaign.name, + completedCampaign.project.name, + completedCampaign.projectId, + completedCampaign.totalRecipients || 0, + ); } } @@ -458,12 +523,22 @@ export class CampaignService { } // Update status - return prisma.campaign.update({ + const cancelledCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.CANCELLED, }, + include: { + project: { + select: {name: true}, + }, + }, }); + + // Send notification about campaign cancellation + await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId); + + return cancelledCampaign; } /** diff --git a/apps/api/src/services/DomainService.ts b/apps/api/src/services/DomainService.ts index d837052..84001f2 100644 --- a/apps/api/src/services/DomainService.ts +++ b/apps/api/src/services/DomainService.ts @@ -2,6 +2,7 @@ import {prisma} from '../database/prisma.js'; import {wrapRedis} from '../database/redis.js'; import {HttpException} from '../exceptions/index.js'; import {Keys} from './keys.js'; +import {NtfyService} from './NtfyService.js'; import {getDomainVerificationAttributes, verifyDomain} from './SESService.js'; export class DomainService { @@ -41,8 +42,16 @@ export class DomainService { verified: false, dkimTokens, }, + include: { + project: { + select: {name: true}, + }, + }, }); + // Send notification about domain added + await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId); + return newDomain; } @@ -60,15 +69,31 @@ export class DomainService { // Update domain if verification status changed if (attributes.status === 'Success' && !domain.verified) { - await prisma.domain.update({ + const updatedDomain = await prisma.domain.update({ where: {id: domainId}, data: {verified: true}, + include: { + project: { + select: {name: true, id: true}, + }, + }, }); + + // Send notification about domain verified + await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id); } else if (attributes.status !== 'Success' && domain.verified) { - await prisma.domain.update({ + const updatedDomain = await prisma.domain.update({ where: {id: domainId}, data: {verified: false}, + include: { + project: { + select: {name: true, id: true}, + }, + }, }); + + // Send notification about domain verification failed + await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id); } return { @@ -83,7 +108,14 @@ export class DomainService { * Remove a domain from a project */ public static async removeDomain(domainId: string) { - const domain = await prisma.domain.findUnique({where: {id: domainId}}); + const domain = await prisma.domain.findUnique({ + where: {id: domainId}, + include: { + project: { + select: {name: true, id: true}, + }, + }, + }); if (!domain) { throw new Error('Domain not found'); @@ -152,6 +184,9 @@ export class DomainService { await prisma.domain.delete({where: {id: domainId}}); + // Send notification about domain removal + await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id); + return true; } diff --git a/apps/api/src/services/NtfyService.ts b/apps/api/src/services/NtfyService.ts new file mode 100644 index 0000000..ec33d1e --- /dev/null +++ b/apps/api/src/services/NtfyService.ts @@ -0,0 +1,727 @@ +import signale from 'signale'; + +/** + * Priority levels for ntfy notifications + * Based on ntfy.sh documentation + */ +export enum NtfyPriority { + MIN = 1, // No vibration/sound, relegated to "Other notifications" + LOW = 2, // No vibration/sound, hidden until drawer opened + DEFAULT = 3, // Short vibration and sound (standard) + HIGH = 4, // Long vibration, pop-over notification + MAX = 5, // Long vibration bursts, pop-over notification +} + +/** + * Tags for ntfy notifications (emoji shortcuts) + */ +export enum NtfyTag { + WARNING = 'warning', + ERROR = 'rotating_light', + SUCCESS = 'white_check_mark', + MONEY = 'money_with_wings', + SHIELD = 'shield', + ROCKET = 'rocket', + BELL = 'bell', + CHART = 'chart_with_upwards_trend', + SKULL = 'skull', + INFO = 'information_source', +} + +export interface NtfyNotification { + title: string; + message: string; + priority?: NtfyPriority; + tags?: NtfyTag[]; +} + +/** + * Service for sending notifications via ntfy.sh + * Supports configurable ntfy server URL via NTFY_URL environment variable + */ +export class NtfyService { + private static ntfyUrl: string | null = null; + private static isConfigured = false; + + /** + * Initialize the ntfy service with the configured URL + */ + private static initialize(): void { + if (this.isConfigured) { + return; + } + + this.ntfyUrl = process.env.NTFY_URL || null; + this.isConfigured = true; + + if (!this.ntfyUrl) { + signale.warn('[NTFY] NTFY_URL not configured - notifications will be skipped'); + } else { + signale.info(`[NTFY] Initialized with URL: ${this.ntfyUrl}`); + } + } + + /** + * Send a notification to ntfy + * @param notification - The notification details + * @returns Promise + */ + public static async send(notification: NtfyNotification): Promise { + this.initialize(); + + if (!this.ntfyUrl) { + // Silently skip if not configured + return; + } + + try { + const headers: Record = { + 'Content-Type': 'text/plain', + Title: notification.title, + }; + + if (notification.priority) { + headers.Priority = notification.priority.toString(); + } + + if (notification.tags && notification.tags.length > 0) { + headers.Tags = notification.tags.join(','); + } + + const response = await fetch(this.ntfyUrl, { + method: 'POST', + headers, + body: notification.message, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + signale.success(`[NTFY] Sent notification: ${notification.title}`); + } catch (error) { + signale.error('[NTFY] Failed to send notification:', error); + // Don't throw - notifications should not break the main flow + } + } + + /** + * Send a high-priority notification + */ + public static async sendHigh(title: string, message: string, tags?: NtfyTag[]): Promise { + await this.send({ + title, + message, + priority: NtfyPriority.HIGH, + tags, + }); + } + + /** + * Send a max-priority urgent notification + */ + public static async sendUrgent(title: string, message: string, tags?: NtfyTag[]): Promise { + await this.send({ + title, + message, + priority: NtfyPriority.MAX, + tags, + }); + } + + /** + * Send a low-priority informational notification + */ + public static async sendInfo(title: string, message: string, tags?: NtfyTag[]): Promise { + await this.send({ + title, + message, + priority: NtfyPriority.LOW, + tags, + }); + } + + /** + * Send a default priority notification + */ + public static async sendDefault(title: string, message: string, tags?: NtfyTag[]): Promise { + await this.send({ + title, + message, + priority: NtfyPriority.DEFAULT, + tags, + }); + } + + // ===== Event-specific notification helpers ===== + + /** + * Notify about a new project creation + */ + public static async notifyProjectCreated(projectName: string, projectId: string, userId: string): Promise { + await this.sendDefault( + 'New Project Created', + `Project "${projectName}" (${projectId}) was created by user ${userId}`, + [NtfyTag.ROCKET, NtfyTag.SUCCESS], + ); + } + + /** + * Notify about subscription started + */ + public static async notifySubscriptionStarted( + projectName: string, + projectId: string, + subscriptionId: string, + ): Promise { + await this.sendHigh( + 'Subscription Started', + `Project "${projectName}" (${projectId}) started a paid subscription (${subscriptionId})`, + [NtfyTag.MONEY, NtfyTag.SUCCESS], + ); + } + + /** + * Notify about subscription cancelled + */ + public static async notifySubscriptionCancelled( + projectName: string, + projectId: string, + subscriptionId: string, + ): Promise { + await this.sendHigh( + 'Subscription Cancelled', + `Project "${projectName}" (${projectId}) cancelled their subscription (${subscriptionId})`, + [NtfyTag.WARNING, NtfyTag.MONEY], + ); + } + + /** + * Notify about project disabled due to security risk + */ + public static async notifyProjectDisabledForSecurity( + projectName: string, + projectId: string, + violations: string[], + ): Promise { + const violationText = violations.join(', '); + await this.sendUrgent( + 'Project Disabled - Security Risk', + `Project "${projectName}" (${projectId}) was automatically disabled due to: ${violationText}`, + [NtfyTag.SHIELD, NtfyTag.ERROR, NtfyTag.SKULL], + ); + } + + /** + * Notify about payment failure + */ + public static async notifyPaymentFailed(projectName: string, projectId: string): Promise { + await this.sendHigh( + 'Payment Failed', + `Payment failed for project "${projectName}" (${projectId})`, + [NtfyTag.WARNING, NtfyTag.MONEY], + ); + } + + /** + * Notify about successful invoice payment - MIN priority (routine) + */ + public static async notifyInvoicePaid(projectName: string, projectId: string): Promise { + await this.send({ + title: 'Invoice Paid', + message: `Invoice paid for project "${projectName}" (${projectId})`, + priority: NtfyPriority.MIN, + tags: [NtfyTag.MONEY, NtfyTag.SUCCESS], + }); + } + + /** + * Notify about subscription update - LOW priority (minor changes) + */ + public static async notifySubscriptionUpdated(projectName: string, projectId: string): Promise { + await this.send({ + title: 'Subscription Updated', + message: `Subscription updated for project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.MONEY, NtfyTag.INFO], + }); + } + + /** + * Notify about project deletion - DEFAULT priority + */ + public static async notifyProjectDeleted(projectName: string, projectId: string, userId: string): Promise { + await this.sendDefault( + 'Project Deleted', + `Project "${projectName}" (${projectId}) was deleted by user ${userId}`, + [NtfyTag.WARNING], + ); + } + + /** + * Notify about security warning (non-critical) + */ + public static async notifySecurityWarning( + projectName: string, + projectId: string, + warnings: string[], + ): Promise { + const warningText = warnings.join(', '); + await this.sendDefault( + 'Security Warning', + `Project "${projectName}" (${projectId}) has security warnings: ${warningText}`, + [NtfyTag.WARNING, NtfyTag.SHIELD], + ); + } + + /** + * Notify about email bounce - LOW priority (high volume) + */ + public static async notifyEmailBounce( + projectName: string, + projectId: string, + recipientEmail: string, + bounceType?: string, + ): Promise { + const bounceInfo = bounceType ? ` (${bounceType})` : ''; + await this.send({ + title: 'Email Bounced', + message: `Email to ${recipientEmail} bounced${bounceInfo} in project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.WARNING], + }); + } + + /** + * Notify about email complaint - HIGH priority (reputation risk) + */ + public static async notifyEmailComplaint( + projectName: string, + projectId: string, + recipientEmail: string, + ): Promise { + await this.sendHigh( + 'Email Complaint', + `Email complaint received from ${recipientEmail} in project "${projectName}" (${projectId})`, + [NtfyTag.WARNING, NtfyTag.ERROR], + ); + } + + /** + * Notify about new user account created via signup - LOW priority + */ + public static async notifyUserSignup(userEmail: string, userId: string): Promise { + await this.send({ + title: 'New User Signup', + message: `New user registered: ${userEmail} (${userId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS], + }); + } + + /** + * Notify about new user account created via OAuth - LOW priority + */ + public static async notifyUserOAuthSignup( + userEmail: string, + userId: string, + provider: 'GitHub' | 'Google', + ): Promise { + await this.send({ + title: `New User - ${provider} OAuth`, + message: `New user registered via ${provider}: ${userEmail} (${userId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS], + }); + } + + // ===== Campaign event notifications ===== + + /** + * Notify about campaign created (draft) - LOW priority + */ + public static async notifyCampaignCreated( + campaignName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Campaign Created', + message: `Campaign "${campaignName}" created in project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.ROCKET], + }); + } + + /** + * Notify about campaign scheduled - DEFAULT priority + */ + public static async notifyCampaignScheduled( + campaignName: string, + projectName: string, + projectId: string, + scheduledFor: Date, + recipientCount: number, + ): Promise { + await this.sendDefault( + 'Campaign Scheduled', + `Campaign "${campaignName}" scheduled to send to ${recipientCount} recipients at ${scheduledFor.toISOString()} in project "${projectName}" (${projectId})`, + [NtfyTag.ROCKET, NtfyTag.INFO], + ); + } + + /** + * Notify about campaign sending started - LOW priority (informational) + */ + public static async notifyCampaignSendingStarted( + campaignName: string, + projectName: string, + projectId: string, + recipientCount: number, + ): Promise { + await this.send({ + title: 'Campaign Sending Started', + message: `Campaign "${campaignName}" started sending to ${recipientCount} recipients in project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.ROCKET, NtfyTag.CHART], + }); + } + + /** + * Notify about campaign send completed - DEFAULT priority + */ + public static async notifyCampaignSendCompleted( + campaignName: string, + projectName: string, + projectId: string, + recipientCount: number, + ): Promise { + await this.sendDefault( + 'Campaign Send Completed', + `Campaign "${campaignName}" successfully sent to ${recipientCount} recipients in project "${projectName}" (${projectId})`, + [NtfyTag.ROCKET, NtfyTag.SUCCESS], + ); + } + + /** + * Notify about campaign cancelled - LOW priority + */ + public static async notifyCampaignCancelled( + campaignName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Campaign Cancelled', + message: `Campaign "${campaignName}" was cancelled in project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.WARNING], + }); + } + + /** + * Notify about campaign deleted - LOW priority + */ + public static async notifyCampaignDeleted( + campaignName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Campaign Deleted', + message: `Campaign "${campaignName}" was deleted from project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.INFO], + }); + } + + // ===== Workflow event notifications ===== + + /** + * Notify about workflow created - LOW priority + */ + public static async notifyWorkflowCreated( + workflowName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Workflow Created', + message: `Workflow "${workflowName}" created in project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.ROCKET], + }); + } + + /** + * Notify about workflow enabled + */ + public static async notifyWorkflowEnabled( + workflowName: string, + projectName: string, + projectId: string, + ): Promise { + await this.sendDefault( + 'Workflow Enabled', + `Workflow "${workflowName}" is now active in project "${projectName}" (${projectId})`, + [NtfyTag.SUCCESS], + ); + } + + /** + * Notify about workflow disabled + */ + public static async notifyWorkflowDisabled( + workflowName: string, + projectName: string, + projectId: string, + ): Promise { + await this.sendDefault( + 'Workflow Disabled', + `Workflow "${workflowName}" has been disabled in project "${projectName}" (${projectId})`, + [NtfyTag.WARNING], + ); + } + + /** + * Notify about workflow deleted - LOW priority + */ + public static async notifyWorkflowDeleted( + workflowName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Workflow Deleted', + message: `Workflow "${workflowName}" was deleted from project "${projectName}" (${projectId})`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.INFO], + }); + } + + /** + * Notify about workflow execution failed + */ + public static async notifyWorkflowExecutionFailed( + workflowName: string, + projectName: string, + projectId: string, + contactEmail: string, + error: string, + ): Promise { + await this.sendHigh( + 'Workflow Execution Failed', + `Workflow "${workflowName}" failed for contact ${contactEmail} in project "${projectName}" (${projectId}). Error: ${error}`, + [NtfyTag.ERROR, NtfyTag.WARNING], + ); + } + + // ===== Domain verification notifications ===== + + /** + * Notify about domain added + */ + public static async notifyDomainAdded(domain: string, projectName: string, projectId: string): Promise { + await this.sendDefault( + 'Domain Added', + `Domain "${domain}" added for verification in project "${projectName}" (${projectId})`, + [NtfyTag.INFO], + ); + } + + /** + * Notify about domain verified + */ + public static async notifyDomainVerified(domain: string, projectName: string, projectId: string): Promise { + await this.sendDefault( + 'Domain Verified', + `Domain "${domain}" is now verified and ready for use in project "${projectName}" (${projectId})`, + [NtfyTag.SUCCESS, NtfyTag.SHIELD], + ); + } + + /** + * Notify about domain verification failed + */ + public static async notifyDomainVerificationFailed( + domain: string, + projectName: string, + projectId: string, + ): Promise { + await this.sendHigh( + 'Domain Verification Failed', + `Domain "${domain}" failed verification in project "${projectName}" (${projectId}). Email sending may be affected.`, + [NtfyTag.WARNING, NtfyTag.SHIELD], + ); + } + + /** + * Notify about domain removed + */ + public static async notifyDomainRemoved(domain: string, projectName: string, projectId: string): Promise { + await this.sendInfo( + 'Domain Removed', + `Domain "${domain}" was removed from project "${projectName}" (${projectId})`, + [NtfyTag.INFO], + ); + } + + // ===== Billing and usage limit notifications ===== + + /** + * Notify about billing limit approaching (80% threshold) + */ + public static async notifyBillingLimitApproaching( + projectName: string, + projectId: string, + usage: number, + limit: number, + percentage: number, + sourceType: string, + ): Promise { + await this.sendDefault( + 'Billing Limit Warning', + `Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`, + [NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART], + ); + } + + /** + * Notify about billing limit exceeded - MAX priority (blocks operations) + */ + public static async notifyBillingLimitExceeded( + projectName: string, + projectId: string, + usage: number, + limit: number, + sourceType: string, + ): Promise { + await this.sendUrgent( + 'Billing Limit Exceeded', + `Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`, + [NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL], + ); + } + + // ===== API key notifications ===== + + /** + * Notify about API keys regenerated + */ + public static async notifyApiKeysRegenerated( + projectName: string, + projectId: string, + userId: string, + ): Promise { + await this.sendHigh( + 'API Keys Regenerated', + `API keys for project "${projectName}" (${projectId}) were regenerated by user ${userId}`, + [NtfyTag.SHIELD, NtfyTag.WARNING], + ); + } + + // ===== Contact import notifications ===== + + /** + * Notify about contact import started - MIN priority (routine, high volume) + */ + public static async notifyContactImportStarted( + projectName: string, + projectId: string, + filename: string, + totalRows: number, + ): Promise { + await this.send({ + title: 'Contact Import Started', + message: `Importing ${totalRows} contacts from "${filename}" into project "${projectName}" (${projectId})`, + priority: NtfyPriority.MIN, + tags: [NtfyTag.ROCKET], + }); + } + + /** + * Notify about contact import completed + */ + public static async notifyContactImportCompleted( + projectName: string, + projectId: string, + filename: string, + successCount: number, + createdCount: number, + updatedCount: number, + failureCount: number, + ): Promise { + await this.sendDefault( + 'Contact Import Completed', + `Import "${filename}" completed for project "${projectName}" (${projectId}). Success: ${successCount} (${createdCount} new, ${updatedCount} updated), Errors: ${failureCount}`, + [NtfyTag.SUCCESS, NtfyTag.CHART], + ); + } + + /** + * Notify about contact import failed + */ + public static async notifyContactImportFailed( + projectName: string, + projectId: string, + filename: string, + error: string, + ): Promise { + await this.sendHigh( + 'Contact Import Failed', + `Contact import "${filename}" failed for project "${projectName}" (${projectId}). Error: ${error}`, + [NtfyTag.ERROR], + ); + } + + // ===== Segment notifications ===== + + /** + * Notify about segment created - MIN priority + */ + public static async notifySegmentCreated( + segmentName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Segment Created', + message: `Segment "${segmentName}" created in project "${projectName}" (${projectId})`, + priority: NtfyPriority.MIN, + tags: [NtfyTag.INFO], + }); + } + + /** + * Notify about segment membership computed - LOW priority + */ + public static async notifySegmentMembershipComputed( + segmentName: string, + projectName: string, + projectId: string, + memberCount: number, + ): Promise { + await this.send({ + title: 'Segment Membership Updated', + message: `Segment "${segmentName}" in project "${projectName}" (${projectId}) now has ${memberCount} members`, + priority: NtfyPriority.LOW, + tags: [NtfyTag.CHART], + }); + } + + /** + * Notify about segment deleted - MIN priority + */ + public static async notifySegmentDeleted( + segmentName: string, + projectName: string, + projectId: string, + ): Promise { + await this.send({ + title: 'Segment Deleted', + message: `Segment "${segmentName}" was deleted from project "${projectName}" (${projectId})`, + priority: NtfyPriority.MIN, + tags: [NtfyTag.INFO], + }); + } +} diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 03b0873..848bf5e 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -2,6 +2,7 @@ import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {redis} from '../database/redis.js'; +import {NtfyService} from './NtfyService.js'; import {QueueService} from './QueueService.js'; /** @@ -112,6 +113,17 @@ export class SecurityService { } else if (status.warnings.length > 0) { // Log warnings for monitoring signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings); + + // Get project name for notification + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {name: true}, + }); + + if (project) { + // Send notification about security warning + await NtfyService.notifySecurityWarning(project.name, projectId, status.warnings); + } } } catch (error) { // Log error but don't throw - we don't want security checks to break the webhook @@ -331,8 +343,8 @@ export class SecurityService { signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error); } - // TODO: Send notification to project owners about the suspension - // This could be implemented using the notification service or email alert + // Send urgent notification about project suspension + await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations); } catch (error) { signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error); } diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index 899e558..44a1b81 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -16,6 +16,7 @@ import {HttpException} from '../exceptions/index.js'; import {DASHBOARD_URI} from '../app/constants.js'; import {EmailService} from './EmailService.js'; +import {NtfyService} from './NtfyService.js'; import {QueueService} from './QueueService.js'; // Type aliases for workflow execution context @@ -259,14 +260,36 @@ export class WorkflowExecutionService { }); // Mark workflow execution as failed - await prisma.workflowExecution.update({ + const failedExecution = await prisma.workflowExecution.update({ where: {id: executionId}, data: { status: WorkflowExecutionStatus.FAILED, completedAt: new Date(), }, + include: { + workflow: { + select: { + name: true, + project: { + select: {name: true, id: true}, + }, + }, + }, + contact: { + select: {email: true}, + }, + }, }); + // Send notification about workflow execution failure + await NtfyService.notifyWorkflowExecutionFailed( + failedExecution.workflow.name, + failedExecution.workflow.project.name, + failedExecution.workflow.project.id, + failedExecution.contact.email, + error instanceof Error ? error.message : 'Unknown error', + ); + throw error; } } diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 9110d56..f0a314c 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -4,8 +4,9 @@ import {Prisma, WorkflowExecutionStatus} from '@plunk/db'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; -import {EventService} from './EventService.js'; import {ContactService} from './ContactService.js'; +import {EventService} from './EventService.js'; +import {NtfyService} from './NtfyService.js'; import {WorkflowExecutionService} from './WorkflowExecutionService.js'; export interface PaginatedWorkflows { @@ -171,6 +172,17 @@ export class WorkflowService { await EventService.invalidateWorkflowCache(projectId); } + // Get project name for notification + const project = await prisma.project.findUnique({ + where: {id: projectId}, + select: {name: true}, + }); + + if (project) { + // Send notification about workflow creation + await NtfyService.notifyWorkflowCreated(workflow.name, project.name, projectId); + } + return workflow; } @@ -225,6 +237,11 @@ export class WorkflowService { const updated = await prisma.workflow.update({ where: {id: workflowId}, data: updateData, + include: { + project: { + select: {name: true}, + }, + }, }); // Invalidate workflow cache if enabled status changed or workflow is enabled @@ -232,6 +249,15 @@ export class WorkflowService { await EventService.invalidateWorkflowCache(projectId); } + // Send notification if enabled status changed + if (data.enabled !== undefined && data.enabled !== workflow.enabled) { + if (data.enabled) { + await NtfyService.notifyWorkflowEnabled(updated.name, updated.project.name, projectId); + } else { + await NtfyService.notifyWorkflowDisabled(updated.name, updated.project.name, projectId); + } + } + return updated; } @@ -239,8 +265,19 @@ export class WorkflowService { * Delete a workflow */ public static async delete(projectId: string, workflowId: string): Promise { - // Verify workflow exists and belongs to project - const workflow = await this.get(projectId, workflowId); + // Verify workflow exists and belongs to project - get with project name + const workflow = await prisma.workflow.findUnique({ + where: {id: workflowId, projectId}, + include: { + project: { + select: {name: true}, + }, + }, + }); + + if (!workflow) { + throw new HttpException(404, 'Workflow not found'); + } // Check if workflow has active executions const activeExecutions = await this.hasActiveExecutions(workflowId); @@ -260,6 +297,9 @@ export class WorkflowService { if (workflow.enabled) { await EventService.invalidateWorkflowCache(projectId); } + + // Send notification about workflow deletion + await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId); } /** diff --git a/docker-compose.yml b/docker-compose.yml index 593f6f9..1132445 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,28 @@ services: networks: - plunk + ntfy: + image: binwiederhier/ntfy:latest + container_name: plunk-ntfy + restart: unless-stopped + command: serve + environment: + - TZ=UTC + volumes: + - ntfy_cache:/var/cache/ntfy + - ntfy_etc:/etc/ntfy + ports: + # Expose ntfy web UI and API + - "${NTFY_PORT:-8080}:80" + healthcheck: + test: [ "CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - plunk + # ============================================ # Plunk Application # ============================================ @@ -153,6 +175,9 @@ services: S3_PUBLIC_URL: ${S3_PUBLIC_URL:-http://localhost:9000/uploads} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true} + # Notifications (ntfy.sh) + NTFY_URL: ${NTFY_URL:-http://ntfy/plunk-notifications} + # SMTP Server (for sending emails via SMTP) # SMTP_DOMAIN is required to select the correct certificate from Traefik acme.json # If using PEM files, SMTP_DOMAIN is optional @@ -185,6 +210,8 @@ services: condition: service_healthy minio: condition: service_healthy + ntfy: + condition: service_healthy healthcheck: # Check nginx is running and responding @@ -206,6 +233,10 @@ volumes: driver: local plunk_data: driver: local + ntfy_cache: + driver: local + ntfy_etc: + driver: local networks: plunk: