UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

1,021 lines (952 loc) 30.4 kB
/** * Email Service for BackendSDK * * Provides email configuration management, template management, * and email sending functionality. */ import crypto from "crypto"; import nodemailer from "nodemailer"; import { DatabaseConnection, Logger } from "./core"; import { normalizeError } from "./utils/error-handler"; export interface EmailConfig { smtp_host: string; smtp_port: number; smtp_secure: boolean; smtp_username: string; smtp_password: string; from_email: string; from_name: string; } export interface EmailTemplate { id: string; project_id: string; name: string; subject: string; body: string; variables: string[]; created_at: string; updated_at: string; } export interface EmailRequest { project_id: string; to: string | string[]; subject: string; body: string; text?: string; cc?: string | string[]; bcc?: string | string[]; replyTo?: string; attachments?: Array<{ filename?: string; content?: string | Buffer; path?: string; contentType?: string; }>; } export interface EmailResult { success: boolean; message: string; messageId?: string; error?: string; } export interface EmailHistory { id: string; project_id: string; to: string; subject: string; status: "sent" | "failed" | "pending"; sent_at?: string; error_message?: string; template_id?: string; created_at: string; } export interface EmailProvider { id: string; name: string; type: "smtp" | "sendgrid" | "mailgun" | "aws_ses"; config: Record<string, unknown>; is_active: boolean; created_at: string; updated_at: string; } /** * Email Service for BackendSDK * * Provides email configuration management, template management, * and email sending functionality. * * @class EmailService * @example * const emailService = new EmailService(dbConnection, logger); * await emailService.sendEmail({ * project_id: 'project-id', * to: 'user@example.com', * subject: 'Hello', * body: 'World' * }); */ export class EmailService { private db: DatabaseConnection; private logger: Logger; /** * Create a new EmailService instance * * @param {DatabaseConnection} databaseConnection - Database connection * @param {Logger} logger - Logger instance */ constructor(databaseConnection: DatabaseConnection, logger: Logger) { this.db = databaseConnection; this.logger = logger; } /** * Get email configuration for a project * * @param {string} projectId - Project ID * @returns {Promise<EmailConfig | null>} Email configuration or null if not found * @throws {Error} If query fails * * @example * const config = await emailService.getConfig('project-id'); */ async getConfig(projectId: string): Promise<EmailConfig | null> { try { const result = await this.db.query( `SELECT settings->>'email_config' as email_config FROM projects WHERE id = $1`, [projectId] ); const row = result.rows[0] as { email_config?: string }; if (result.rows.length === 0 || !row.email_config) { return null; } return JSON.parse(row.email_config); } catch (error) { this.logger.error("Failed to get email config:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getEmailConfig", }); } } /** * Update email configuration for a project * * @param {string} projectId - Project ID * @param {EmailConfig} config - Email configuration * @returns {Promise<EmailConfig | null>} Updated configuration or null if project not found * @throws {Error} If update fails * * @example * const config = await emailService.updateConfig('project-id', { * smtp_host: 'smtp.example.com', * smtp_port: 587, * smtp_secure: false, * smtp_username: 'user', * smtp_password: 'pass', * from_email: 'noreply@example.com', * from_name: 'KRAPI' * }); */ async updateConfig( projectId: string, config: EmailConfig ): Promise<EmailConfig | null> { try { // Add timeout to prevent hanging const timeoutPromise = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Email config update timeout")), 5000) ); // First get current settings with timeout const currentResult = await Promise.race([ this.db.query( "SELECT settings FROM projects WHERE id = $1", [projectId] ), timeoutPromise, ]); if (currentResult.rows.length === 0) { return null; } // Parse current settings and update email_config const currentRow = currentResult.rows[0] as { settings?: string }; let settings: Record<string, unknown> = {}; if (currentRow.settings) { try { settings = typeof currentRow.settings === "string" ? JSON.parse(currentRow.settings) : currentRow.settings; } catch { settings = {}; } } settings.email_config = config; // Update with merged settings (SQLite-compatible) with timeout const now = new Date().toISOString(); await Promise.race([ this.db.query( `UPDATE projects SET settings = $1, updated_at = $2 WHERE id = $3`, [JSON.stringify(settings), now, projectId] ), timeoutPromise, ]); return config; } catch (error) { this.logger.error("Failed to update email config:", error); // Return null instead of throwing to prevent hanging // This allows the application to continue even if email config update fails return null; } } /** * Test email configuration * * Tests the email configuration by attempting to create a test transporter. * * @param {string} projectId - Project ID * @returns {Promise<EmailResult>} Test result * @throws {Error} If configuration not found or test fails * * @example * const result = await emailService.testConfig('project-id'); * if (result.success) { * console.log('Email configuration is valid'); * } */ async testConfig(projectId: string): Promise<EmailResult> { try { const config = await this.getConfig(projectId); if (!config) { return { success: false, message: "Email configuration not found for this project", }; } // Test the email configuration by creating a test transporter const transporter = nodemailer.createTransport({ host: config.smtp_host, port: config.smtp_port, secure: config.smtp_secure, auth: { user: config.smtp_username, pass: config.smtp_password, }, }); // Verify the connection await transporter.verify(); return { success: true, message: "Email configuration is valid and connection verified", }; } catch (error) { this.logger.error("Email config test failed:", error); return { success: false, message: error instanceof Error ? error.message : "Failed to test email configuration", }; } } /** * Get all email templates for a project * * Retrieves all email templates associated with a project. * * @param {string} projectId - Project ID * @returns {Promise<EmailTemplate[]>} Array of email templates * @throws {Error} If query fails * * @example * const templates = await emailService.getTemplates('project-id'); */ async getTemplates(projectId: string): Promise<EmailTemplate[]> { try { const result = await this.db.query( `SELECT * FROM email_templates WHERE project_id = $1 ORDER BY created_at DESC`, [projectId] ); return result.rows as EmailTemplate[]; } catch (error) { this.logger.error("Failed to get email templates:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getEmailTemplates", }); } } /** * Get email template by ID * * Retrieves a single email template by its ID. * * @param {string} templateId - Template ID * @returns {Promise<EmailTemplate | null>} Template or null if not found * @throws {Error} If query fails * * @example * const template = await emailService.getTemplate('template-id'); */ async getTemplate(templateId: string): Promise<EmailTemplate | null> { try { const result = await this.db.query( `SELECT * FROM email_templates WHERE id = $1`, [templateId] ); return result.rows.length > 0 ? (result.rows[0] as EmailTemplate) : null; } catch (error) { this.logger.error("Failed to get email template:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getEmailTemplate", }); } } /** * Create a new email template * * Creates a new email template with subject, body, and variable definitions. * * @param {Omit<EmailTemplate, "id" | "created_at" | "updated_at">} templateData - Template data * @param {string} templateData.project_id - Project ID * @param {string} templateData.name - Template name * @param {string} templateData.subject - Email subject (supports variables) * @param {string} templateData.body - Email body HTML (supports variables) * @param {string[]} [templateData.variables] - Available template variables * @returns {Promise<EmailTemplate>} Created template * @throws {Error} If creation fails * * @example * const template = await emailService.createTemplate({ * project_id: 'project-id', * name: 'welcome', * subject: 'Welcome {{name}}!', * body: '<h1>Welcome {{name}}</h1><p>Your account is ready.</p>', * variables: ['name', 'email'] * }); */ async createTemplate( templateData: Omit<EmailTemplate, "id" | "created_at" | "updated_at"> ): Promise<EmailTemplate> { try { const { name, subject, body, variables, project_id } = templateData; // Generate template ID (SQLite doesn't support RETURNING *) const templateId = crypto.randomUUID(); const now = new Date().toISOString(); // SQLite-compatible INSERT (no RETURNING *) await this.db.query( `INSERT INTO email_templates (id, project_id, name, subject, body, variables, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [templateId, project_id, name, subject, body, JSON.stringify(variables || []), now, now] ); // Query back the inserted row const result = await this.db.query( "SELECT * FROM email_templates WHERE id = $1", [templateId] ); return result.rows[0] as EmailTemplate; } catch (error) { this.logger.error("Failed to create email template:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createEmailTemplate", }); } } /** * Update an email template * * Updates email template subject, body, or variables. * * @param {string} templateId - Template ID * @param {Partial<EmailTemplate>} templateData - Template updates * @param {string} [templateData.name] - New template name * @param {string} [templateData.subject] - Updated subject * @param {string} [templateData.body] - Updated body * @param {string[]} [templateData.variables] - Updated variables * @returns {Promise<EmailTemplate | null>} Updated template or null if not found * @throws {Error} If update fails * * @example * const updated = await emailService.updateTemplate('template-id', { * subject: 'Updated Welcome {{name}}!', * body: '<h1>Welcome {{name}}</h1>' * }); */ async updateTemplate( templateId: string, templateData: Partial<EmailTemplate> ): Promise<EmailTemplate | null> { try { const fields: string[] = []; const values: unknown[] = []; let paramCount = 1; if (templateData.name !== undefined) { fields.push(`name = $${paramCount++}`); values.push(templateData.name); } if (templateData.subject !== undefined) { fields.push(`subject = $${paramCount++}`); values.push(templateData.subject); } if (templateData.body !== undefined) { fields.push(`body = $${paramCount++}`); values.push(templateData.body); } if (templateData.variables !== undefined) { fields.push(`variables = $${paramCount++}`); values.push(JSON.stringify(templateData.variables)); } if (fields.length === 0) { return this.getTemplate(templateId); } // SQLite-compatible: use CURRENT_TIMESTAMP instead of NOW() fields.push(`updated_at = $${paramCount++}`); values.push(new Date().toISOString()); values.push(templateId); // SQLite doesn't support RETURNING *, so update and query back separately await this.db.query( `UPDATE email_templates SET ${fields.join(", ")} WHERE id = $${paramCount}`, values ); // Query back the updated row const result = await this.db.query( "SELECT * FROM email_templates WHERE id = $1", [templateId] ); return result.rows.length > 0 ? (result.rows[0] as EmailTemplate) : null; } catch (error) { this.logger.error("Failed to update email template:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "updateEmailTemplate", }); } } /** * Delete an email template * * Permanently deletes an email template. * * @param {string} templateId - Template ID * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails * * @example * const deleted = await emailService.deleteTemplate('template-id'); */ async deleteTemplate(templateId: string): Promise<boolean> { try { const result = await this.db.query( `DELETE FROM email_templates WHERE id = $1`, [templateId] ); return result.rowCount > 0; } catch (error) { this.logger.error("Failed to delete email template:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "deleteEmailTemplate", }); } } /** * Send an email * * Sends an email using the project's email configuration. * Validates configuration and required fields before sending. * * @param {EmailRequest} emailData - Email data * @param {string} emailData.project_id - Project ID * @param {string | string[]} emailData.to - Recipient email(s) * @param {string} emailData.subject - Email subject * @param {string} emailData.body - Email body (HTML) * @param {string} [emailData.text] - Plain text version * @param {string | string[]} [emailData.cc] - CC recipients * @param {string | string[]} [emailData.bcc] - BCC recipients * @param {string} [emailData.replyTo] - Reply-to address * @param {Array} [emailData.attachments] - Email attachments * @returns {Promise<EmailResult>} Email sending result * @throws {Error} If sending fails or configuration missing * * @example * const result = await emailService.sendEmail({ * project_id: 'project-id', * to: 'user@example.com', * subject: 'Hello', * body: '<h1>Hello World</h1>' * }); */ async sendEmail(emailData: EmailRequest): Promise<EmailResult> { try { // Get the project to check if email is configured const projectResult = await this.db.query( "SELECT id FROM projects WHERE id = $1", [emailData.project_id] ); if (projectResult.rows.length === 0) { return { success: false, message: "Project not found", }; } // Check if email configuration exists const emailConfig = await this.getConfig(emailData.project_id); if (!emailConfig) { return { success: false, message: "Email configuration not found for this project", }; } // Validate required email data const { to, subject, body } = emailData; if (!to || !subject || !body) { return { success: false, message: "To, subject, and body are required for sending emails", }; } // Create transporter and send email const transporter = nodemailer.createTransport({ host: emailConfig.smtp_host, port: emailConfig.smtp_port, secure: emailConfig.smtp_secure, auth: { user: emailConfig.smtp_username, pass: emailConfig.smtp_password, }, }); const result = await transporter.sendMail({ from: `"${emailConfig.from_name}" <${emailConfig.from_email}>`, to: Array.isArray(to) ? to.join(", ") : to, subject, html: body, text: emailData.text || body, cc: emailData.cc, bcc: emailData.bcc, replyTo: emailData.replyTo, attachments: emailData.attachments, }); return { success: true, message: `Email sent successfully. Message ID: ${ result.messageId || "unknown" }`, messageId: result.messageId, }; } catch (error) { this.logger.error("Send email error:", error); return { success: false, message: error instanceof Error ? error.message : "Failed to send email", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * Send email (convenience method) * * Alias for sendEmail. Sends an email with project context. * * @param {EmailRequest} emailWithProject - Email request with project_id * @returns {Promise<EmailResult>} Email sending result * @throws {Error} If sending fails * * @example * const result = await emailService.sendEmailRequest({ * project_id: 'project-id', * to: 'user@example.com', * subject: 'Hello', * body: 'World' * }); */ async sendEmailRequest(emailWithProject: EmailRequest): Promise<EmailResult> { return this.sendEmail(emailWithProject); } /** * Send email using a template * * @param {string} projectId - Project ID * @param {string} templateId - Template ID * @param {Object} emailData - Email data with variables * @returns {Promise<{ success: boolean; data?: { message_id?: string; sent_at: string } }>} Send result */ async sendTemplateEmail( projectId: string, templateId: string, emailData: { to: string | string[]; variables?: Record<string, unknown>; cc?: string | string[]; bcc?: string | string[]; subject?: string; from_email?: string; from_name?: string; } ): Promise<{ success: boolean; data?: { message_id?: string; sent_at: string } }> { try { const template = await this.getTemplate(templateId); if (!template) { return { success: false }; } // Replace template variables let subject = emailData.subject || template.subject; let body = template.body; if (emailData.variables) { Object.entries(emailData.variables).forEach(([key, value]) => { const regex = new RegExp(`{{${key}}}`, "g"); subject = subject.replace(regex, String(value)); body = body.replace(regex, String(value)); }); } const emailRequest: EmailRequest = { project_id: projectId, to: emailData.to, subject, body, }; if (emailData.cc !== undefined) { emailRequest.cc = emailData.cc; } if (emailData.bcc !== undefined) { emailRequest.bcc = emailData.bcc; } const result = await this.sendEmail(emailRequest); const response: { success: boolean; data?: { message_id?: string; sent_at: string }; } = { success: result.success, data: { sent_at: new Date().toISOString(), }, }; if (result.messageId !== undefined) { if (response.data) { response.data.message_id = result.messageId; } } return response; } catch (error) { this.logger.error("Failed to send template email:", error); return { success: false }; } } /** * Send bulk emails * * @param {string} projectId - Project ID * @param {Object} bulkRequest - Bulk email request * @returns {Promise<{ success: boolean; data?: { message_ids: string[]; sent_at: string } }>} Send result */ async sendBulkEmail( projectId: string, bulkRequest: { template_id?: string; recipients: Array<{ email: string; name?: string; variables?: Record<string, unknown>; }>; subject?: string; from_email?: string; from_name?: string; scheduled_at?: string; } ): Promise<{ success: boolean; data?: { message_ids: string[]; sent_at: string } }> { try { const messageIds: string[] = []; const config = await this.getConfig(projectId); if (!config) { return { success: false }; } for (const recipient of bulkRequest.recipients) { let subject = bulkRequest.subject || "Email"; let body = "Email body"; if (bulkRequest.template_id) { const template = await this.getTemplate(bulkRequest.template_id); if (template) { subject = template.subject; body = template.body; if (recipient.variables) { Object.entries(recipient.variables).forEach(([key, value]) => { const regex = new RegExp(`{{${key}}}`, "g"); subject = subject.replace(regex, String(value)); body = body.replace(regex, String(value)); }); } } } const result = await this.sendEmail({ project_id: projectId, to: recipient.email, subject, body, }); if (result.messageId) { messageIds.push(result.messageId); } } return { success: true, data: { message_ids: messageIds, sent_at: new Date().toISOString(), }, }; } catch (error) { this.logger.error("Failed to send bulk email:", error); return { success: false }; } } /** * Get email history * * @param {string} projectId - Project ID * @param {Object} options - Query options * @returns {Promise<{ success: boolean; data?: EmailHistory[] }>} Email history */ async getEmailHistory( projectId: string, options?: { limit?: number; offset?: number; status?: "sent" | "failed" | "bounced" | "delivered"; recipient?: string; template_id?: string; sent_after?: string; sent_before?: string; } ): Promise<{ success: boolean; data?: EmailHistory[] }> { try { let query = `SELECT * FROM email_history WHERE project_id = $1`; const params: unknown[] = [projectId]; let paramCount = 2; if (options?.status) { query += ` AND status = $${paramCount++}`; params.push(options.status); } if (options?.recipient) { query += ` AND "to" = $${paramCount++}`; params.push(options.recipient); } if (options?.template_id) { query += ` AND template_id = $${paramCount++}`; params.push(options.template_id); } if (options?.sent_after) { query += ` AND sent_at >= $${paramCount++}`; params.push(options.sent_after); } if (options?.sent_before) { query += ` AND sent_at <= $${paramCount++}`; params.push(options.sent_before); } query += ` ORDER BY sent_at DESC`; if (options?.limit) { query += ` LIMIT $${paramCount++}`; params.push(options.limit); } if (options?.offset) { query += ` OFFSET $${paramCount++}`; params.push(options.offset); } const result = await this.db.query(query, params); return { success: true, data: result.rows as EmailHistory[], }; } catch (error) { this.logger.error("Failed to get email history:", error); return { success: false, data: [] }; } } /** * Get email details * * @param {string} projectId - Project ID * @param {string} messageId - Message ID * @returns {Promise<{ success: boolean; data?: EmailHistory }>} Email details */ async getEmailDetails( projectId: string, messageId: string ): Promise<{ success: boolean; data?: EmailHistory }> { try { const result = await this.db.query( `SELECT * FROM email_history WHERE project_id = $1 AND id = $2`, [projectId, messageId] ); const response: { success: boolean; data?: EmailHistory; } = { success: true, }; if (result.rows.length > 0) { response.data = result.rows[0] as EmailHistory; } return response; } catch (error) { this.logger.error("Failed to get email details:", error); return { success: false }; } } /** * Get email analytics * * @param {string} projectId - Project ID * @param {Object} options - Analytics options * @returns {Promise<{ success: boolean; data?: unknown }>} Analytics data */ async getEmailAnalytics( projectId: string, options?: { period?: "day" | "week" | "month" | "year"; start_date?: string; end_date?: string; template_id?: string; } ): Promise<{ success: boolean; data?: unknown }> { try { // Basic analytics implementation const historyOptions: { limit?: number; offset?: number; status?: "failed" | "sent" | "bounced" | "delivered"; recipient?: string; template_id?: string; sent_after?: string; sent_before?: string; } = {}; if (options?.start_date) { historyOptions.sent_after = options.start_date; } if (options?.end_date) { historyOptions.sent_before = options.end_date; } if (options?.template_id) { historyOptions.template_id = options.template_id; } const history = await this.getEmailHistory(projectId, historyOptions); const emails = history.data || []; const totalSent = emails.length; const totalDelivered = emails.filter((e) => e.status === "sent").length; const totalBounced = emails.filter((e) => e.status === "failed").length; return { success: true, data: { period: options?.period || "month", start_date: options?.start_date || new Date().toISOString(), end_date: options?.end_date || new Date().toISOString(), total_sent: totalSent, total_delivered: totalDelivered, total_bounced: totalBounced, total_opened: 0, total_clicked: 0, total_complained: 0, delivery_rate: totalSent > 0 ? totalDelivered / totalSent : 0, open_rate: 0, click_rate: 0, bounce_rate: totalSent > 0 ? totalBounced / totalSent : 0, complaint_rate: 0, daily_stats: [], }, }; } catch (error) { this.logger.error("Failed to get email analytics:", error); return { success: false }; } } /** * Unsubscribe email * * @param {string} projectId - Project ID * @param {string} email - Email address to unsubscribe * @returns {Promise<{ success: boolean; message?: string }>} Unsubscribe result */ async unsubscribeEmail( projectId: string, email: string ): Promise<{ success: boolean; message?: string }> { try { // Add to unsubscribe list (SQLite-compatible) const now = new Date().toISOString(); await this.db.query( `INSERT INTO email_unsubscribes (project_id, email, created_at) VALUES ($1, $2, $3) ON CONFLICT (project_id, email) DO NOTHING`, [projectId, email, now] ); return { success: true, message: "Email unsubscribed successfully", }; } catch (error) { this.logger.error("Failed to unsubscribe email:", error); return { success: false, message: error instanceof Error ? error.message : "Failed to unsubscribe", }; } } /** * Validate email address * * @param {string} email - Email address to validate * @returns {Promise<{ success: boolean; data?: unknown }>} Validation result */ async validateEmail( email: string ): Promise<{ success: boolean; data?: { valid: boolean; format_valid: boolean; domain_valid: boolean; disposable: boolean; role: boolean; free_email: boolean; suggestions?: string[]; }; }> { try { // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const formatValid = emailRegex.test(email); const domain = email.split("@")[1]; const domainValid = formatValid && domain && domain.includes("."); const emailParts = email.split("@"); const localPart = emailParts[0]; const domainLower = domain?.toLowerCase(); return { success: true, data: { valid: Boolean(formatValid && domainValid), format_valid: Boolean(formatValid), domain_valid: Boolean(domainValid), disposable: false, role: Boolean(localPart && (localPart.toLowerCase().includes("noreply") || localPart.toLowerCase().includes("no-reply"))), free_email: Boolean(domainLower && ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com"].includes(domainLower)), }, }; } catch (error) { this.logger.error("Failed to validate email:", error); return { success: false, data: { valid: false, format_valid: false, domain_valid: false, disposable: false, role: false, free_email: false, }, }; } } }