@superdangerous/app-framework 4.9.2 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +8 -2
  2. package/dist/api/logsRouter.d.ts +4 -1
  3. package/dist/api/logsRouter.d.ts.map +1 -1
  4. package/dist/api/logsRouter.js +100 -118
  5. package/dist/api/logsRouter.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/middleware/validation.d.ts +48 -43
  11. package/dist/middleware/validation.d.ts.map +1 -1
  12. package/dist/middleware/validation.js +48 -43
  13. package/dist/middleware/validation.js.map +1 -1
  14. package/dist/services/emailService.d.ts +146 -0
  15. package/dist/services/emailService.d.ts.map +1 -0
  16. package/dist/services/emailService.js +649 -0
  17. package/dist/services/emailService.js.map +1 -0
  18. package/dist/services/index.d.ts +2 -0
  19. package/dist/services/index.d.ts.map +1 -1
  20. package/dist/services/index.js +2 -0
  21. package/dist/services/index.js.map +1 -1
  22. package/dist/services/websocketServer.d.ts +7 -4
  23. package/dist/services/websocketServer.d.ts.map +1 -1
  24. package/dist/services/websocketServer.js +22 -16
  25. package/dist/services/websocketServer.js.map +1 -1
  26. package/dist/types/index.d.ts +7 -8
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/package.json +11 -2
  29. package/src/api/logsRouter.ts +119 -138
  30. package/src/index.ts +14 -0
  31. package/src/middleware/validation.ts +82 -90
  32. package/src/services/emailService.ts +812 -0
  33. package/src/services/index.ts +14 -0
  34. package/src/services/websocketServer.ts +37 -23
  35. package/src/types/index.ts +7 -8
  36. package/ui/data-table/components/BatchActionsBar.tsx +53 -0
  37. package/ui/data-table/components/ColumnVisibility.tsx +111 -0
  38. package/ui/data-table/components/DataTablePage.tsx +238 -0
  39. package/ui/data-table/components/Pagination.tsx +203 -0
  40. package/ui/data-table/components/PaginationControls.tsx +122 -0
  41. package/ui/data-table/components/TableFilters.tsx +139 -0
  42. package/ui/data-table/components/index.ts +27 -0
  43. package/ui/data-table/hooks/index.ts +17 -0
  44. package/ui/data-table/hooks/useColumnOrder.ts +233 -0
  45. package/ui/data-table/hooks/useColumnVisibility.ts +128 -0
  46. package/ui/data-table/hooks/usePagination.ts +160 -0
  47. package/ui/data-table/hooks/useResizableColumns.ts +280 -0
  48. package/ui/data-table/index.ts +74 -0
  49. package/ui/dist/index.d.mts +207 -5
  50. package/ui/dist/index.d.ts +207 -5
  51. package/ui/dist/index.js +36 -43
  52. package/ui/dist/index.js.map +1 -1
  53. package/ui/dist/index.mjs +36 -43
  54. package/ui/dist/index.mjs.map +1 -1
@@ -0,0 +1,812 @@
1
+ /**
2
+ * Email Service supporting Resend API and Nodemailer SMTP
3
+ * A generic, reusable email service for the app framework.
4
+ * Resend is preferred when API key is configured.
5
+ */
6
+
7
+ import { EventEmitter } from "events";
8
+ import nodemailer from "nodemailer";
9
+ import type { Transporter } from "nodemailer";
10
+ import { Resend } from "resend";
11
+ import { createLogger } from "../core/index.js";
12
+
13
+ let logger: ReturnType<typeof createLogger>;
14
+
15
+ function ensureLogger() {
16
+ if (!logger) {
17
+ logger = createLogger("EmailService");
18
+ }
19
+ return logger;
20
+ }
21
+
22
+ // ============================================================================
23
+ // Types
24
+ // ============================================================================
25
+
26
+ export interface EmailOptions {
27
+ to: string | string[];
28
+ subject: string;
29
+ text?: string;
30
+ html?: string;
31
+ attachments?: Array<{
32
+ filename: string;
33
+ content?: string | Buffer;
34
+ path?: string;
35
+ }>;
36
+ }
37
+
38
+ export interface EmailConfig {
39
+ enabled?: boolean;
40
+ provider?: "resend" | "smtp";
41
+ resend?: {
42
+ apiKey: string;
43
+ };
44
+ smtp?: {
45
+ host: string;
46
+ port: number;
47
+ secure?: boolean;
48
+ auth?: {
49
+ user: string;
50
+ pass: string;
51
+ };
52
+ };
53
+ from?: string;
54
+ to?: string[];
55
+ appName?: string;
56
+ appTitle?: string;
57
+ logoUrl?: string;
58
+ brandColor?: string;
59
+ footerText?: string;
60
+ footerLink?: string;
61
+ }
62
+
63
+ export type NotificationEventType =
64
+ | "startup"
65
+ | "shutdown"
66
+ | "error"
67
+ | "warning"
68
+ | "info"
69
+ | "success"
70
+ | "custom";
71
+
72
+ export interface NotificationEvent {
73
+ type: NotificationEventType;
74
+ title?: string;
75
+ data: Record<string, unknown>;
76
+ timestamp: Date;
77
+ }
78
+
79
+ export interface EmailServiceStatus {
80
+ enabled: boolean;
81
+ provider: "Resend" | "SMTP" | "None";
82
+ recipients: string[];
83
+ }
84
+
85
+ // ============================================================================
86
+ // Email Service
87
+ // ============================================================================
88
+
89
+ export class EmailService extends EventEmitter {
90
+ private transporter?: Transporter;
91
+ private resend?: Resend;
92
+ private enabled: boolean = false;
93
+ private useResend: boolean = false;
94
+ private notificationQueue: NotificationEvent[] = [];
95
+ private processingInterval?: NodeJS.Timeout;
96
+ private fromAddress: string = "App <noreply@example.com>";
97
+ private defaultRecipients: string[] = [];
98
+ private config: EmailConfig;
99
+
100
+ // Branding configuration
101
+ private appName: string = "App";
102
+ private appTitle: string = "App";
103
+ private logoUrl?: string;
104
+ private brandColor: string = "#6c5ce7";
105
+ private footerText?: string;
106
+ private footerLink?: string;
107
+
108
+ constructor(config: EmailConfig = {}) {
109
+ super();
110
+ this.config = config;
111
+ this.enabled = config.enabled ?? false;
112
+ this.defaultRecipients = config.to || [];
113
+ this.fromAddress = config.from || this.fromAddress;
114
+ this.appName = config.appName || this.appName;
115
+ this.appTitle = config.appTitle || config.appName || this.appTitle;
116
+ this.logoUrl = config.logoUrl;
117
+ this.brandColor = config.brandColor || this.brandColor;
118
+ this.footerText = config.footerText;
119
+ this.footerLink = config.footerLink;
120
+
121
+ ensureLogger().debug("EmailService created", {
122
+ enabled: this.enabled,
123
+ provider: config.provider,
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Initialize the email service
129
+ */
130
+ async initialize(): Promise<void> {
131
+ if (!this.enabled) {
132
+ ensureLogger().info("Email service disabled in configuration");
133
+ return;
134
+ }
135
+
136
+ // Try Resend first (preferred)
137
+ const resendApiKey =
138
+ this.config.resend?.apiKey || process.env.RESEND_API_KEY;
139
+ if (resendApiKey) {
140
+ try {
141
+ this.resend = new Resend(resendApiKey);
142
+ this.useResend = true;
143
+ ensureLogger().info("Email service initialized with Resend API");
144
+ this.startNotificationProcessor();
145
+ this.emit("initialized", { provider: "Resend" });
146
+ return;
147
+ } catch (error) {
148
+ ensureLogger().warn(
149
+ "Failed to initialize Resend, falling back to SMTP:",
150
+ error,
151
+ );
152
+ }
153
+ }
154
+
155
+ // Fall back to SMTP/Nodemailer
156
+ if (!this.config.smtp) {
157
+ ensureLogger().warn(
158
+ "No email provider configured (no Resend API key or SMTP config)",
159
+ );
160
+ this.enabled = false;
161
+ return;
162
+ }
163
+
164
+ try {
165
+ this.transporter = nodemailer.createTransport({
166
+ host: this.config.smtp.host,
167
+ port: this.config.smtp.port,
168
+ secure: this.config.smtp.secure,
169
+ auth: this.config.smtp.auth
170
+ ? {
171
+ user: this.config.smtp.auth.user,
172
+ pass: this.config.smtp.auth.pass,
173
+ }
174
+ : undefined,
175
+ });
176
+
177
+ await this.transporter.verify();
178
+ ensureLogger().info("Email service initialized with SMTP");
179
+ this.startNotificationProcessor();
180
+ this.emit("initialized", { provider: "SMTP" });
181
+ } catch (error) {
182
+ ensureLogger().error("Failed to initialize email service:", error);
183
+ this.enabled = false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Update branding configuration
189
+ */
190
+ setBranding(options: {
191
+ appName?: string;
192
+ appTitle?: string;
193
+ logoUrl?: string;
194
+ brandColor?: string;
195
+ footerText?: string;
196
+ footerLink?: string;
197
+ }): void {
198
+ if (options.appName) this.appName = options.appName;
199
+ if (options.appTitle) this.appTitle = options.appTitle;
200
+ if (options.logoUrl) this.logoUrl = options.logoUrl;
201
+ if (options.brandColor) this.brandColor = options.brandColor;
202
+ if (options.footerText) this.footerText = options.footerText;
203
+ if (options.footerLink) this.footerLink = options.footerLink;
204
+ }
205
+
206
+ /**
207
+ * Send an email
208
+ */
209
+ async sendEmail(options: EmailOptions): Promise<void> {
210
+ if (!this.enabled) {
211
+ ensureLogger().debug("Email service not available, skipping email");
212
+ return;
213
+ }
214
+
215
+ const recipients = Array.isArray(options.to) ? options.to : [options.to];
216
+ const toAddresses =
217
+ recipients.length > 0 ? recipients : this.defaultRecipients;
218
+
219
+ if (toAddresses.length === 0) {
220
+ ensureLogger().warn("No email recipients configured");
221
+ return;
222
+ }
223
+
224
+ try {
225
+ if (this.useResend && this.resend) {
226
+ const { data, error } = await this.resend.emails.send({
227
+ from: this.fromAddress,
228
+ to: toAddresses,
229
+ subject: options.subject,
230
+ text: options.text || "No content",
231
+ html: options.html,
232
+ });
233
+
234
+ if (error) {
235
+ throw new Error(error.message);
236
+ }
237
+
238
+ ensureLogger().info(`Email sent via Resend: ${data?.id}`);
239
+ this.emit("sent", { id: data?.id, provider: "Resend" });
240
+ } else if (this.transporter) {
241
+ const info = await this.transporter.sendMail({
242
+ from: this.fromAddress,
243
+ to: toAddresses.join(", "),
244
+ subject: options.subject,
245
+ text: options.text,
246
+ html: options.html,
247
+ attachments: options.attachments,
248
+ });
249
+
250
+ ensureLogger().info(`Email sent via SMTP: ${info.messageId}`);
251
+ this.emit("sent", { id: info.messageId, provider: "SMTP" });
252
+ }
253
+ } catch (error) {
254
+ ensureLogger().error("Failed to send email:", error);
255
+ this.emit("error", error);
256
+ throw error;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Queue a notification event
262
+ */
263
+ queueNotification(event: NotificationEvent): void {
264
+ if (!this.enabled) {
265
+ return;
266
+ }
267
+
268
+ this.notificationQueue.push(event);
269
+ ensureLogger().debug(`Queued ${event.type} notification`);
270
+ this.emit("queued", event);
271
+ }
272
+
273
+ /**
274
+ * Send immediate notification (bypasses queue)
275
+ */
276
+ async sendImmediateNotification(event: NotificationEvent): Promise<void> {
277
+ if (!this.enabled) {
278
+ return;
279
+ }
280
+
281
+ try {
282
+ await this.sendNotificationEmail(event.type, [event]);
283
+ ensureLogger().info(`Sent immediate ${event.type} notification`);
284
+ } catch (error) {
285
+ ensureLogger().error(
286
+ `Failed to send immediate ${event.type} notification:`,
287
+ error,
288
+ );
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Send a notification with custom type and data
294
+ */
295
+ async notify(
296
+ type: NotificationEventType,
297
+ title: string,
298
+ data: Record<string, unknown>,
299
+ immediate: boolean = true,
300
+ ): Promise<void> {
301
+ const event: NotificationEvent = {
302
+ type,
303
+ title,
304
+ data,
305
+ timestamp: new Date(),
306
+ };
307
+
308
+ if (immediate) {
309
+ await this.sendImmediateNotification(event);
310
+ } else {
311
+ this.queueNotification(event);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Send startup notification
317
+ */
318
+ async notifyStartup(
319
+ data: Record<string, unknown> = {},
320
+ ): Promise<void> {
321
+ const uptime = process.uptime();
322
+ const nodeVersion = process.version;
323
+ const platform = process.platform;
324
+
325
+ await this.sendImmediateNotification({
326
+ type: "startup",
327
+ title: "Application Started",
328
+ data: {
329
+ nodeVersion,
330
+ platform,
331
+ startupTime: `${uptime.toFixed(2)}s`,
332
+ timestamp: new Date().toISOString(),
333
+ ...data,
334
+ },
335
+ timestamp: new Date(),
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Send error notification
341
+ */
342
+ async notifyError(
343
+ errorMessage: string,
344
+ details?: Record<string, unknown>,
345
+ ): Promise<void> {
346
+ await this.sendImmediateNotification({
347
+ type: "error",
348
+ title: "Error Alert",
349
+ data: {
350
+ errorMessage,
351
+ ...details,
352
+ },
353
+ timestamp: new Date(),
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Send test email
359
+ */
360
+ async sendTestEmail(): Promise<void> {
361
+ const provider = this.useResend ? "Resend API" : "SMTP";
362
+
363
+ await this.sendEmail({
364
+ to: this.defaultRecipients,
365
+ subject: `[${this.appTitle}] Test Email - Configuration Verified`,
366
+ text: `This is a test email from ${this.appTitle}.\n\nEmail Provider: ${provider}\n\nIf you received this email, your email configuration is working correctly.`,
367
+ html: this.generateTestEmailHtml(provider),
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Get service status
373
+ */
374
+ getStatus(): EmailServiceStatus {
375
+ return {
376
+ enabled: this.enabled,
377
+ provider: this.useResend ? "Resend" : this.transporter ? "SMTP" : "None",
378
+ recipients: this.defaultRecipients,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Shutdown the service
384
+ */
385
+ async shutdown(): Promise<void> {
386
+ if (this.processingInterval) {
387
+ clearInterval(this.processingInterval);
388
+ }
389
+
390
+ if (this.notificationQueue.length > 0) {
391
+ await this.processNotificationQueue();
392
+ }
393
+
394
+ if (this.transporter) {
395
+ this.transporter.close();
396
+ }
397
+
398
+ ensureLogger().info("Email service shut down");
399
+ this.emit("shutdown");
400
+ }
401
+
402
+ // ============================================================================
403
+ // Private Methods
404
+ // ============================================================================
405
+
406
+ private startNotificationProcessor(): void {
407
+ // Process queue every 5 minutes
408
+ this.processingInterval = setInterval(() => {
409
+ this.processNotificationQueue();
410
+ }, 300000);
411
+ }
412
+
413
+ private async processNotificationQueue(): Promise<void> {
414
+ if (this.notificationQueue.length === 0) {
415
+ return;
416
+ }
417
+
418
+ const grouped = this.groupNotificationsByType();
419
+
420
+ for (const [type, events] of Object.entries(grouped)) {
421
+ try {
422
+ await this.sendNotificationEmail(type as NotificationEventType, events);
423
+ } catch (error) {
424
+ ensureLogger().error(`Failed to send ${type} notification:`, error);
425
+ }
426
+ }
427
+
428
+ this.notificationQueue = [];
429
+ }
430
+
431
+ private groupNotificationsByType(): Record<string, NotificationEvent[]> {
432
+ const grouped: Record<string, NotificationEvent[]> = {};
433
+
434
+ for (const event of this.notificationQueue) {
435
+ if (!grouped[event.type]) {
436
+ grouped[event.type] = [];
437
+ }
438
+ grouped[event.type].push(event);
439
+ }
440
+
441
+ return grouped;
442
+ }
443
+
444
+ private async sendNotificationEmail(
445
+ type: NotificationEventType,
446
+ events: NotificationEvent[],
447
+ ): Promise<void> {
448
+ const subject = this.getNotificationSubject(type, events);
449
+ const html = this.formatNotificationHtml(type, events);
450
+ const text = this.formatNotificationText(type, events);
451
+
452
+ await this.sendEmail({
453
+ to: this.defaultRecipients,
454
+ subject,
455
+ text,
456
+ html,
457
+ });
458
+ }
459
+
460
+ private getNotificationSubject(
461
+ type: NotificationEventType,
462
+ events: NotificationEvent[],
463
+ ): string {
464
+ const count = events.length;
465
+ const customTitle = events[0]?.title;
466
+
467
+ switch (type) {
468
+ case "startup":
469
+ return `[${this.appTitle}] Application Started`;
470
+ case "shutdown":
471
+ return `[${this.appTitle}] Application Shutdown`;
472
+ case "error":
473
+ return `[${this.appTitle}] Error Alert${count > 1 ? ` (${count})` : ""}`;
474
+ case "warning":
475
+ return `[${this.appTitle}] Warning${count > 1 ? ` (${count})` : ""}`;
476
+ case "info":
477
+ return `[${this.appTitle}] Information`;
478
+ case "success":
479
+ return `[${this.appTitle}] Success`;
480
+ case "custom":
481
+ return customTitle
482
+ ? `[${this.appTitle}] ${customTitle}`
483
+ : `[${this.appTitle}] Notification`;
484
+ default:
485
+ return `[${this.appTitle}] Notification`;
486
+ }
487
+ }
488
+
489
+ private getNotificationIcon(type: NotificationEventType): string {
490
+ switch (type) {
491
+ case "startup":
492
+ return "🚀";
493
+ case "shutdown":
494
+ return "🛑";
495
+ case "error":
496
+ return "❌";
497
+ case "warning":
498
+ return "⚠️";
499
+ case "info":
500
+ return "ℹ️";
501
+ case "success":
502
+ return "✅";
503
+ case "custom":
504
+ return "📧";
505
+ default:
506
+ return "📧";
507
+ }
508
+ }
509
+
510
+ private getAccentColor(type: NotificationEventType): string {
511
+ switch (type) {
512
+ case "error":
513
+ return "#e74c3c";
514
+ case "warning":
515
+ return "#f39c12";
516
+ case "success":
517
+ return "#27ae60";
518
+ case "startup":
519
+ return "#3498db";
520
+ case "shutdown":
521
+ return "#95a5a6";
522
+ default:
523
+ return this.brandColor;
524
+ }
525
+ }
526
+
527
+ private formatDateTime(date: Date): string {
528
+ const pad = (n: number) => n.toString().padStart(2, "0");
529
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
530
+ }
531
+
532
+ private formatEventDataHtml(data: Record<string, unknown>): string {
533
+ if (!data || typeof data !== "object") {
534
+ return `<tr><td colspan="2">${data || "No details"}</td></tr>`;
535
+ }
536
+
537
+ const formatValue = (val: unknown): string => {
538
+ if (val instanceof Date) return this.formatDateTime(val);
539
+ if (typeof val === "object") return JSON.stringify(val);
540
+ return String(val ?? "-");
541
+ };
542
+
543
+ return Object.entries(data)
544
+ .map(([key, val]) => {
545
+ const label = key
546
+ .replace(/([A-Z])/g, " $1")
547
+ .replace(/^./, (s) => s.toUpperCase());
548
+ return `<tr><td style="font-weight:600;color:#495057;padding:4px 8px;">${label}:</td><td style="padding:4px 8px;">${formatValue(val)}</td></tr>`;
549
+ })
550
+ .join("");
551
+ }
552
+
553
+ private formatNotificationHtml(
554
+ type: NotificationEventType,
555
+ events: NotificationEvent[],
556
+ ): string {
557
+ const accentColor = this.getAccentColor(type);
558
+ const icon = this.getNotificationIcon(type);
559
+ const title = events[0]?.title || this.getNotificationTitle(type);
560
+
561
+ let html = `
562
+ <!DOCTYPE html>
563
+ <html>
564
+ <head>
565
+ <meta charset="utf-8">
566
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
567
+ <title>${this.getNotificationSubject(type, events)}</title>
568
+ </head>
569
+ <body style="margin: 0; padding: 0; background-color: #f6f9fc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, sans-serif;">
570
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f6f9fc; padding: 40px 20px;">
571
+ <tr>
572
+ <td align="center">
573
+ <table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
574
+
575
+ <!-- Header -->
576
+ <tr>
577
+ <td style="padding: 32px 40px 24px 40px; border-bottom: 1px solid #e9ecef;">
578
+ <table width="100%" cellpadding="0" cellspacing="0">
579
+ <tr>
580
+ <td>
581
+ ${this.logoUrl ? `<img src="${this.logoUrl}" alt="${this.appName}" width="150" style="display: block; border: 0;" />` : `<span style="font-size: 24px; font-weight: 700; color: ${this.brandColor};">${this.appName}</span>`}
582
+ </td>
583
+ <td align="right">
584
+ <span style="font-size: 12px; color: #6c757d; text-transform: uppercase; letter-spacing: 1px;">${this.appTitle}</span>
585
+ </td>
586
+ </tr>
587
+ </table>
588
+ </td>
589
+ </tr>
590
+
591
+ <!-- Main content -->
592
+ <tr>
593
+ <td style="padding: 32px 40px;">
594
+ <!-- Title section -->
595
+ <div style="margin-bottom: 24px;">
596
+ <span style="font-size: 32px; margin-right: 12px;">${icon}</span>
597
+ <h1 style="display: inline; font-size: 24px; font-weight: 600; color: #2d3436; margin: 0; vertical-align: middle;">${title}</h1>
598
+ </div>
599
+
600
+ <!-- Status badge -->
601
+ <div style="margin-bottom: 24px;">
602
+ <span style="display: inline-block; background-color: ${accentColor}15; color: ${accentColor}; font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 16px; text-transform: uppercase; letter-spacing: 0.5px;">
603
+ ${type.replace(/_/g, " ")}
604
+ </span>
605
+ </div>
606
+ `;
607
+
608
+ for (const event of events) {
609
+ html += `
610
+ <!-- Event content -->
611
+ <div style="background-color: #f8f9fa; border-radius: 8px; padding: 24px; margin-bottom: 24px;">
612
+ <div style="font-size: 12px; color: #6c757d; margin-bottom: 16px; text-transform: uppercase; letter-spacing: 0.5px;">
613
+ ${this.formatDateTime(event.timestamp)}
614
+ </div>
615
+ <table width="100%" cellpadding="0" cellspacing="0">
616
+ ${this.formatEventDataHtml(event.data)}
617
+ </table>
618
+ </div>
619
+ `;
620
+ }
621
+
622
+ html += `
623
+ </td>
624
+ </tr>
625
+
626
+ <!-- Footer -->
627
+ <tr>
628
+ <td style="padding: 24px 40px; background-color: #f8f9fa; border-top: 1px solid #e9ecef;">
629
+ <p style="margin: 0; font-size: 12px; color: #6c757d; line-height: 1.5;">
630
+ This is an automated notification from ${this.appTitle}.${this.footerText ? `<br>${this.footerText}` : ""}${this.footerLink ? `<br><a href="${this.footerLink}" style="color: ${this.brandColor}; text-decoration: none;">${this.footerLink}</a>` : ""}
631
+ </p>
632
+ </td>
633
+ </tr>
634
+
635
+ </table>
636
+ </td>
637
+ </tr>
638
+ </table>
639
+ </body>
640
+ </html>
641
+ `;
642
+
643
+ return html;
644
+ }
645
+
646
+ private formatNotificationText(
647
+ type: NotificationEventType,
648
+ events: NotificationEvent[],
649
+ ): string {
650
+ const timestamp = this.formatDateTime(new Date());
651
+ const title = events[0]?.title || this.getNotificationTitle(type);
652
+
653
+ let text = `${this.appTitle} - ${title}\n`;
654
+ text += `Generated: ${timestamp}\n`;
655
+ text += "=".repeat(60) + "\n\n";
656
+
657
+ for (const event of events) {
658
+ text += `Time: ${this.formatDateTime(event.timestamp)}\n`;
659
+ text += `Details:\n${this.formatEventDataText(event.data)}\n`;
660
+ text += "-".repeat(40) + "\n\n";
661
+ }
662
+
663
+ text += `This is an automated notification from ${this.appTitle}.\n`;
664
+ if (this.footerText) text += `${this.footerText}\n`;
665
+
666
+ return text;
667
+ }
668
+
669
+ private formatEventDataText(data: Record<string, unknown>): string {
670
+ if (!data || typeof data !== "object") {
671
+ return String(data || "No details");
672
+ }
673
+
674
+ const formatValue = (val: unknown): string => {
675
+ if (val instanceof Date) return this.formatDateTime(val);
676
+ if (typeof val === "object") return JSON.stringify(val);
677
+ return String(val ?? "-");
678
+ };
679
+
680
+ return Object.entries(data)
681
+ .map(([key, val]) => {
682
+ const label = key
683
+ .replace(/([A-Z])/g, " $1")
684
+ .replace(/^./, (s) => s.toUpperCase());
685
+ return ` ${label}: ${formatValue(val)}`;
686
+ })
687
+ .join("\n");
688
+ }
689
+
690
+ private getNotificationTitle(type: NotificationEventType): string {
691
+ switch (type) {
692
+ case "startup":
693
+ return "Application Started";
694
+ case "shutdown":
695
+ return "Application Shutdown";
696
+ case "error":
697
+ return "Error Alert";
698
+ case "warning":
699
+ return "Warning";
700
+ case "info":
701
+ return "Information";
702
+ case "success":
703
+ return "Success";
704
+ case "custom":
705
+ return "Notification";
706
+ default:
707
+ return "Notification";
708
+ }
709
+ }
710
+
711
+ private generateTestEmailHtml(provider: string): string {
712
+ return `
713
+ <!DOCTYPE html>
714
+ <html>
715
+ <head>
716
+ <meta charset="utf-8">
717
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
718
+ <title>Email Configuration Verified</title>
719
+ </head>
720
+ <body style="margin: 0; padding: 0; background-color: #f6f9fc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, sans-serif;">
721
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f6f9fc; padding: 40px 20px;">
722
+ <tr>
723
+ <td align="center">
724
+ <table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
725
+
726
+ <!-- Header -->
727
+ <tr>
728
+ <td style="padding: 32px 40px 24px 40px; border-bottom: 1px solid #e9ecef;">
729
+ <table width="100%" cellpadding="0" cellspacing="0">
730
+ <tr>
731
+ <td>
732
+ ${this.logoUrl ? `<img src="${this.logoUrl}" alt="${this.appName}" width="150" style="display: block; border: 0;" />` : `<span style="font-size: 24px; font-weight: 700; color: ${this.brandColor};">${this.appName}</span>`}
733
+ </td>
734
+ <td align="right">
735
+ <span style="font-size: 12px; color: #6c757d; text-transform: uppercase; letter-spacing: 1px;">${this.appTitle}</span>
736
+ </td>
737
+ </tr>
738
+ </table>
739
+ </td>
740
+ </tr>
741
+
742
+ <!-- Main content -->
743
+ <tr>
744
+ <td style="padding: 32px 40px; text-align: center;">
745
+ <div style="width: 64px; height: 64px; background-color: #d4edda; border-radius: 50%; margin: 0 auto 24px; display: flex; align-items: center; justify-content: center;">
746
+ <span style="font-size: 32px; line-height: 64px;">✅</span>
747
+ </div>
748
+ <h1 style="font-size: 24px; font-weight: 600; color: #2d3436; margin: 0 0 16px 0;">Email Configuration Verified</h1>
749
+ <p style="font-size: 16px; color: #6c757d; margin: 0 0 32px 0;">Your email notifications are configured correctly.</p>
750
+
751
+ <!-- Info card -->
752
+ <div style="background-color: #f8f9fa; border-radius: 8px; padding: 24px; text-align: left;">
753
+ <div style="display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #e9ecef;">
754
+ <span style="color: #6c757d; font-size: 14px;">Provider</span>
755
+ <span style="font-weight: 600; color: #2d3436; font-size: 14px;">${provider}</span>
756
+ </div>
757
+ <div style="display: flex; justify-content: space-between; padding: 12px 0;">
758
+ <span style="color: #6c757d; font-size: 14px;">App</span>
759
+ <span style="font-weight: 600; color: #2d3436; font-size: 14px;">${this.appTitle}</span>
760
+ </div>
761
+ </div>
762
+ </td>
763
+ </tr>
764
+
765
+ <!-- Footer -->
766
+ <tr>
767
+ <td style="padding: 24px 40px; background-color: #f8f9fa; border-top: 1px solid #e9ecef; text-align: center;">
768
+ <p style="margin: 0; font-size: 12px; color: #6c757d; line-height: 1.5;">
769
+ This is an automated test from ${this.appTitle}.${this.footerText ? `<br>${this.footerText}` : ""}${this.footerLink ? `<br><a href="${this.footerLink}" style="color: ${this.brandColor}; text-decoration: none;">${this.footerLink}</a>` : ""}
770
+ </p>
771
+ </td>
772
+ </tr>
773
+
774
+ </table>
775
+ </td>
776
+ </tr>
777
+ </table>
778
+ </body>
779
+ </html>
780
+ `;
781
+ }
782
+ }
783
+
784
+ // ============================================================================
785
+ // Singleton Pattern
786
+ // ============================================================================
787
+
788
+ let emailServiceInstance: EmailService | null = null;
789
+
790
+ /**
791
+ * Get the email service instance (singleton)
792
+ */
793
+ export function getEmailService(): EmailService | null {
794
+ return emailServiceInstance;
795
+ }
796
+
797
+ /**
798
+ * Create and initialize the email service
799
+ */
800
+ export async function createEmailService(
801
+ config: EmailConfig,
802
+ ): Promise<EmailService> {
803
+ if (emailServiceInstance) {
804
+ return emailServiceInstance;
805
+ }
806
+
807
+ emailServiceInstance = new EmailService(config);
808
+ await emailServiceInstance.initialize();
809
+ return emailServiceInstance;
810
+ }
811
+
812
+ export default EmailService;