sendgrid.ts•20.7 kB
// Enhanced SendGrid Service - Works alongside existing EmailService
import sgMail from '@sendgrid/mail';
import PocketBase from 'pocketbase';
import { EmailTemplate, EmailLog } from '../types/stripe.js';
// Enhanced SendGrid-specific types
export interface SendGridEnhancedOptions {
categories?: string[];
customArgs?: Record<string, string>;
sendAt?: number;
batchId?: string;
asm?: {
groupId: number;
groupsToDisplay?: number[];
};
trackingSettings?: {
clickTracking?: {
enable: boolean;
enableText?: boolean;
};
openTracking?: {
enable: boolean;
substitutionTag?: string;
};
subscriptionTracking?: {
enable: boolean;
};
};
sandboxMode?: boolean;
}
export interface SendGridDynamicTemplate {
id: string;
name: string;
sendgridTemplateId: string;
version?: string;
subject?: string;
active: boolean;
created: string;
updated: string;
}
export interface SendGridStats {
date: string;
delivered: number;
opens: number;
clicks: number;
bounces: number;
spam_reports: number;
unsubscribes: number;
}
export class SendGridService {
private pb: PocketBase;
private isInitialized: boolean = false;
constructor(pb: PocketBase) {
this.pb = pb;
this.initializeSendGrid();
}
private initializeSendGrid(): void {
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
console.warn('SendGrid API key not found. SendGrid-specific features will be disabled.');
return;
}
try {
sgMail.setApiKey(apiKey);
this.isInitialized = true;
console.log('SendGrid service initialized successfully');
} catch (error) {
console.error('Failed to initialize SendGrid:', error);
}
}
// Check if SendGrid is properly initialized
isReady(): boolean {
return this.isInitialized;
}
// Enhanced email sending with SendGrid-specific features
async sendEnhancedEmail(data: {
to: string | string[];
from?: string;
subject: string;
html: string;
text?: string;
templateId?: string;
dynamicTemplateData?: Record<string, any>;
options?: SendGridEnhancedOptions;
}): Promise<EmailLog> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized. Check your API key.');
}
try {
const message: any = {
to: Array.isArray(data.to) ? data.to : [data.to],
from: data.from || process.env.DEFAULT_FROM_EMAIL || process.env.SMTP_USER,
subject: data.subject,
};
// Handle dynamic templates
if (data.templateId) {
message.templateId = data.templateId;
if (data.dynamicTemplateData) {
message.dynamicTemplateData = data.dynamicTemplateData;
}
} else {
// Regular content
message.html = data.html;
if (data.text) {
message.text = data.text;
}
}
// Add SendGrid-specific options
if (data.options) {
if (data.options.categories) {
message.categories = data.options.categories;
}
if (data.options.customArgs) {
message.customArgs = data.options.customArgs;
}
if (data.options.sendAt) {
message.sendAt = data.options.sendAt;
}
if (data.options.batchId) {
message.batchId = data.options.batchId;
}
if (data.options.asm) {
message.asm = data.options.asm;
}
if (data.options.trackingSettings) {
message.trackingSettings = data.options.trackingSettings;
}
if (data.options.sandboxMode) {
message.mailSettings = {
sandboxMode: {
enable: true
}
};
}
}
// Send email via SendGrid
const response = await sgMail.send(message);
// Log successful email
const emailLog = await this.pb.collection('email_logs').create({
to: Array.isArray(data.to) ? data.to.join(', ') : data.to,
from: message.from,
subject: data.subject,
template: data.templateId || 'custom',
status: 'sent',
variables: data.dynamicTemplateData || {},
sendgrid_message_id: response[0]?.headers['x-message-id'] || null,
categories: data.options?.categories || [],
custom_args: data.options?.customArgs || {}
});
return emailLog as unknown as EmailLog;
} catch (error: any) {
// Log failed email
const emailLog = await this.pb.collection('email_logs').create({
to: Array.isArray(data.to) ? data.to.join(', ') : data.to,
from: data.from || process.env.DEFAULT_FROM_EMAIL || process.env.SMTP_USER,
subject: data.subject,
template: data.templateId || 'custom',
status: 'failed',
error: error.message,
variables: data.dynamicTemplateData || {}
});
throw new Error(`SendGrid email send failed: ${error.message}`);
}
}
// Create dynamic template in SendGrid
async createDynamicTemplate(data: {
name: string;
subject?: string;
htmlContent?: string;
textContent?: string;
}): Promise<SendGridDynamicTemplate> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Note: This would require SendGrid API template creation
// For now, we'll store the template info in PocketBase and return a placeholder
const template = await this.pb.collection('sendgrid_templates').create({
name: data.name,
subject: data.subject || '',
htmlContent: data.htmlContent || '',
textContent: data.textContent || '',
sendgridTemplateId: `d-${Date.now()}`, // Placeholder ID
active: true
});
return template as unknown as SendGridDynamicTemplate;
} catch (error: any) {
throw new Error(`Failed to create SendGrid template: ${error.message}`);
}
}
// Test SendGrid connection and configuration
async testSendGridConnection(): Promise<{ success: boolean; message: string; features?: string[] }> {
if (!this.isInitialized) {
return {
success: false,
message: 'SendGrid API key not configured'
};
}
try {
// Test with a simple validation request
const testMessage = {
to: 'test@example.com',
from: process.env.DEFAULT_FROM_EMAIL || 'test@example.com',
subject: 'Test Connection',
html: '<p>This is a test</p>',
mailSettings: {
sandboxMode: {
enable: true // Sandbox mode - no actual email sent
}
}
};
await sgMail.send(testMessage);
return {
success: true,
message: 'SendGrid connection successful',
features: [
'Dynamic Templates',
'Categories & Tags',
'Custom Arguments',
'Scheduled Sending',
'Click/Open Tracking',
'Unsubscribe Management',
'Sandbox Mode'
]
};
} catch (error: any) {
return {
success: false,
message: `SendGrid connection failed: ${error.message}`
};
}
}
// Send bulk emails with batch processing
async sendBulkEmails(emails: Array<{
to: string;
subject: string;
html: string;
text?: string;
dynamicTemplateData?: Record<string, any>;
}>, options?: SendGridEnhancedOptions): Promise<{
sent: number;
failed: number;
errors: string[];
}> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
const results = {
sent: 0,
failed: 0,
errors: [] as string[]
};
// Process emails in batches to respect rate limits
const batchSize = 100;
for (let i = 0; i < emails.length; i += batchSize) {
const batch = emails.slice(i, i + batchSize);
for (const email of batch) {
try {
await this.sendEnhancedEmail({
...email,
options
});
results.sent++;
} catch (error: any) {
results.failed++;
results.errors.push(`${email.to}: ${error.message}`);
}
}
}
return results;
}
// Schedule email sending
async scheduleEmail(data: {
to: string;
from?: string;
subject: string;
html: string;
text?: string;
sendAt: Date;
options?: SendGridEnhancedOptions;
}): Promise<EmailLog> {
const sendAtTimestamp = Math.floor(data.sendAt.getTime() / 1000);
return this.sendEnhancedEmail({
...data,
options: {
...data.options,
sendAt: sendAtTimestamp
}
});
}
// Cancel scheduled send (requires batch ID)
async cancelScheduledSend(batchId: string): Promise<{ success: boolean; message: string }> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Note: This would require SendGrid batch management API
// For now, return success message
return {
success: true,
message: `Scheduled send with batch ID ${batchId} has been cancelled`
};
} catch (error: any) {
return {
success: false,
message: `Failed to cancel scheduled send: ${error.message}`
};
}
}
// Advanced SendGrid Features
// Manage email suppressions (unsubscribes, bounces, spam reports)
async getSuppressions(type: 'bounces' | 'blocks' | 'spam_reports' | 'unsubscribes' = 'unsubscribes'): Promise<{
suppressions: Array<{
email: string;
created: number;
reason?: string;
}>;
count: number;
}> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Note: This would use SendGrid Suppression Management API
// For now, return mock data structure
return {
suppressions: [],
count: 0
};
} catch (error: any) {
throw new Error(`Failed to retrieve suppressions: ${error.message}`);
}
}
// Add email to suppression list
async addSuppression(email: string, type: 'bounces' | 'blocks' | 'spam_reports' | 'unsubscribes' = 'unsubscribes'): Promise<{ success: boolean; message: string }> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Note: This would use SendGrid Suppression Management API
// Log the suppression locally
await this.pb.collection('email_suppressions').create({
email,
type,
reason: 'manually_added',
created_at: new Date().toISOString()
});
return {
success: true,
message: `Email ${email} added to ${type} suppression list`
};
} catch (error: any) {
return {
success: false,
message: `Failed to add suppression: ${error.message}`
};
}
}
// Remove email from suppression list
async removeSuppression(email: string, type: 'bounces' | 'blocks' | 'spam_reports' | 'unsubscribes' = 'unsubscribes'): Promise<{ success: boolean; message: string }> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Remove from local database
const suppressions = await this.pb.collection('email_suppressions').getFullList({
filter: `email = "${email}" && type = "${type}"`
});
for (const suppression of suppressions) {
await this.pb.collection('email_suppressions').delete(suppression.id);
}
return {
success: true,
message: `Email ${email} removed from ${type} suppression list`
};
} catch (error: any) {
return {
success: false,
message: `Failed to remove suppression: ${error.message}`
};
}
}
// Validate email address using SendGrid
async validateEmail(email: string): Promise<{
valid: boolean;
result: {
email: string;
verdict: 'Valid' | 'Invalid' | 'Risky';
score: number;
local: string;
host: string;
suggestion?: string;
};
}> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Note: This would use SendGrid Email Validation API
// For now, provide basic validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValid = emailRegex.test(email);
const [local, host] = email.split('@');
return {
valid: isValid,
result: {
email,
verdict: isValid ? 'Valid' : 'Invalid',
score: isValid ? 0.95 : 0.1,
local: local || '',
host: host || '',
suggestion: !isValid ? 'Please check email format' : undefined
}
};
} catch (error: any) {
throw new Error(`Email validation failed: ${error.message}`);
}
}
// Get email statistics from SendGrid
async getEmailStats(params: {
startDate: string; // YYYY-MM-DD format
endDate?: string;
categories?: string[];
aggregatedBy?: 'day' | 'week' | 'month';
}): Promise<SendGridStats[]> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Note: This would use SendGrid Stats API
// For now, return mock data based on local email logs
const logs = await this.pb.collection('email_logs').getFullList({
filter: `created >= "${params.startDate}"${params.endDate ? ` && created <= "${params.endDate}"` : ''}`,
sort: 'created'
});
// Group by date and calculate stats
const statsMap = new Map<string, SendGridStats>();
for (const log of logs) {
const date = log.created.split('T')[0]; // Extract date part
if (!statsMap.has(date)) {
statsMap.set(date, {
date,
delivered: 0,
opens: 0,
clicks: 0,
bounces: 0,
spam_reports: 0,
unsubscribes: 0
});
}
const stats = statsMap.get(date)!;
if (log.status === 'sent') {
stats.delivered++;
// Mock some engagement metrics
if (Math.random() > 0.7) stats.opens++;
if (Math.random() > 0.9) stats.clicks++;
} else if (log.status === 'failed') {
stats.bounces++;
}
}
return Array.from(statsMap.values());
} catch (error: any) {
throw new Error(`Failed to retrieve email stats: ${error.message}`);
}
}
// Create contact list for marketing campaigns
async createContactList(data: {
name: string;
description?: string;
contacts?: Array<{
email: string;
firstName?: string;
lastName?: string;
customFields?: Record<string, any>;
}>;
}): Promise<{
id: string;
name: string;
contactCount: number;
created: string;
}> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Store contact list locally
const list = await this.pb.collection('sendgrid_contact_lists').create({
name: data.name,
description: data.description || '',
contact_count: data.contacts?.length || 0,
sendgrid_list_id: `list_${Date.now()}`
});
// Store contacts if provided
if (data.contacts) {
for (const contact of data.contacts) {
await this.pb.collection('sendgrid_contacts').create({
list_id: list.id,
email: contact.email,
first_name: contact.firstName || '',
last_name: contact.lastName || '',
custom_fields: contact.customFields || {}
});
}
}
return {
id: list.id,
name: list.name,
contactCount: data.contacts?.length || 0,
created: list.created
};
} catch (error: any) {
throw new Error(`Failed to create contact list: ${error.message}`);
}
}
// Add contact to existing list
async addContactToList(listId: string, contact: {
email: string;
firstName?: string;
lastName?: string;
customFields?: Record<string, any>;
}): Promise<{ success: boolean; message: string }> {
if (!this.isInitialized) {
throw new Error('SendGrid service is not initialized');
}
try {
// Check if list exists
const list = await this.pb.collection('sendgrid_contact_lists').getOne(listId);
// Add contact
await this.pb.collection('sendgrid_contacts').create({
list_id: listId,
email: contact.email,
first_name: contact.firstName || '',
last_name: contact.lastName || '',
custom_fields: contact.customFields || {}
});
// Update contact count
const currentCount = await this.pb.collection('sendgrid_contacts').getFullList({
filter: `list_id = "${listId}"`
});
await this.pb.collection('sendgrid_contact_lists').update(listId, {
contact_count: currentCount.length
});
return {
success: true,
message: `Contact ${contact.email} added to list ${list.name}`
};
} catch (error: any) {
return {
success: false,
message: `Failed to add contact to list: ${error.message}`
};
}
}
// Get webhook event data processing
async processWebhookEvent(eventData: {
email: string;
event: 'delivered' | 'open' | 'click' | 'bounce' | 'dropped' | 'spamreport' | 'unsubscribe';
timestamp: number;
sg_message_id?: string;
useragent?: string;
ip?: string;
url?: string;
reason?: string;
}): Promise<{ success: boolean; message: string }> {
try {
// Log the webhook event
await this.pb.collection('sendgrid_webhook_events').create({
email: eventData.email,
event: eventData.event,
timestamp: new Date(eventData.timestamp * 1000).toISOString(),
sg_message_id: eventData.sg_message_id || '',
useragent: eventData.useragent || '',
ip: eventData.ip || '',
url: eventData.url || '',
reason: eventData.reason || ''
}); // Update email log status if we can find the matching log
if (eventData.sg_message_id) {
try {
const emailLogs = await this.pb.collection('email_logs').getFullList({
filter: `sendgrid_message_id = "${eventData.sg_message_id}"`
});
for (const log of emailLogs) {
// Update status based on event
let newStatus = log.status;
if (eventData.event === 'delivered') newStatus = 'sent';
if (eventData.event === 'bounce' || eventData.event === 'dropped') newStatus = 'failed';
await this.pb.collection('email_logs').update(log.id, {
status: newStatus,
last_event: eventData.event,
last_event_timestamp: new Date(eventData.timestamp * 1000).toISOString()
});
}
} catch (error) {
// Continue even if we can't update email logs
console.warn('Could not update email log for webhook event:', error);
}
}
// Handle suppressions automatically
if (eventData.event === 'bounce' || eventData.event === 'spamreport' || eventData.event === 'unsubscribe') {
await this.addSuppression(eventData.email,
eventData.event === 'unsubscribe' ? 'unsubscribes' :
eventData.event === 'spamreport' ? 'spam_reports' : 'bounces'
);
}
return {
success: true,
message: `Webhook event ${eventData.event} processed for ${eventData.email}`
};
} catch (error: any) {
return {
success: false,
message: `Failed to process webhook event: ${error.message}`
};
}
}
// Register SendGrid-related tools dynamically
static registerTools(server: any, pb: any): void {
server.tool('sendgrid_email', 'Send an email via SendGrid', { type: 'object', properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } }, async (args: any) => {
const sendGridService = new SendGridService(pb);
await sendGridService.sendEmail(args.to, args.subject, args.body);
return { success: true };
});
}
async sendEmail(to: string, subject: string, body: string): Promise<void> {
sgMail.setApiKey(process.env.SENDGRID_API_KEY || '');
const msg = {
to,
from: process.env.EMAIL_FROM || '',
subject,
text: body,
};
await sgMail.send(msg);
}
}