diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index cbac9f4..7e2a5d2 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -3,6 +3,8 @@ import { Donation } from './donations/donation.entity'; import { User } from './users/user.entity'; import * as dotenv from 'dotenv'; import { Goal } from './donations/goal.entity'; +import { EmailTemplate } from './emails/email-template.entity'; +import { EmailSubscriber } from './emails/email-subscriber.entity'; dotenv.config(); @@ -13,7 +15,7 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - entities: [User, Donation, Goal], + entities: [User, Donation, Goal, EmailTemplate, EmailSubscriber], migrations: [], // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: true, diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index f375efc..be2d973 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Donation } from './donation.entity'; import { DonationsController } from './donations.controller'; @@ -9,9 +9,13 @@ import { AuthService } from '../auth/auth.service'; import { UsersService } from '../users/users.service'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { Goal } from './goal.entity'; +import { EmailsModule } from '../emails/emails.module'; @Module({ - imports: [TypeOrmModule.forFeature([Donation, Goal, User])], + imports: [ + TypeOrmModule.forFeature([Donation, Goal, User]), + forwardRef(() => EmailsModule), + ], controllers: [DonationsController], providers: [ DonationsService, diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 46e5363..f5daf2c 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -13,6 +13,7 @@ import { Readable } from 'stream'; import { DonationsRepository } from './donations.repository'; import { Goal } from './goal.entity'; import { UpdateGoalDto } from './dtos'; +import { EmailsService } from '../emails/emails.service'; interface PaymentIntentSyncPayload { donationId?: number; @@ -39,6 +40,7 @@ export class DonationsService { private goalRepository: Repository, private readonly donationsRepository: DonationsRepository, + private readonly emailsService: EmailsService, ) {} async create( @@ -278,12 +280,37 @@ export class DonationsService { return; } + const previousStatus = donation.status; donation.status = status; if (transactionId) { donation.transactionId = transactionId; } await this.donationRepository.save(donation); + + // Send donation response email if status changed to succeeded + if ( + previousStatus !== DonationStatus.SUCCEEDED && + status === DonationStatus.SUCCEEDED + ) { + try { + const donorName = `${donation.firstName} ${donation.lastName}`; + await this.emailsService.sendDonationResponseEmail( + donation.email, + donorName, + donation.amount, + ); + this.logger.log( + `Donation Response email sent to ${donation.email} for donation ${donation.id}`, + ); + } catch (error) { + this.logger.error( + `Failed to send Donation Response email for donation ${donation.id}`, + error, + ); + // caught error cause we dont want email failure to break the payment sync + } + } } async getLapsedDonors(numMonths = 6): Promise<{ emails: string[] }> { diff --git a/apps/backend/src/emails/bulk-send.dto.ts b/apps/backend/src/emails/bulk-send.dto.ts new file mode 100644 index 0000000..d258e14 --- /dev/null +++ b/apps/backend/src/emails/bulk-send.dto.ts @@ -0,0 +1,17 @@ +import { IsEnum, IsString } from 'class-validator'; + +export enum EmailTargetGroup { + RELAPSED_DONORS = 'relapsed_donors', + EMAIL_SUBSCRIBERS = 'email_subscribers', +} + +export class BulkSendDto { + @IsEnum(EmailTargetGroup) + targetGroup: EmailTargetGroup; + + @IsString() + subject: string; + + @IsString() + bodyHtml: string; +} diff --git a/apps/backend/src/emails/email-subscriber.entity.ts b/apps/backend/src/emails/email-subscriber.entity.ts new file mode 100644 index 0000000..2f5bfba --- /dev/null +++ b/apps/backend/src/emails/email-subscriber.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('email_subscribers') +export class EmailSubscriber { + @PrimaryGeneratedColumn('identity', { + generatedIdentity: 'ALWAYS', + }) + id: number; + + @Column({ unique: true }) + email: string; + + @Column({ nullable: true }) + firstName: string | null; + + @Column({ nullable: true }) + lastName: string | null; + + @Column({ default: true }) + isSubscribed: boolean; + + @Column({ nullable: true }) + unsubscribedAt: Date | null; + + @CreateDateColumn({ type: 'timestamp', default: () => 'now()' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp', default: () => 'now()' }) + updatedAt: Date; +} diff --git a/apps/backend/src/emails/email-template.entity.ts b/apps/backend/src/emails/email-template.entity.ts new file mode 100644 index 0000000..2b5a7c6 --- /dev/null +++ b/apps/backend/src/emails/email-template.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum TemplateType { + DONATION_RESPONSE = 'donation_response', + RELAPSED_DONOR = 'relapsed_donor', + EMAIL_SUBSCRIBERS = 'email_subscribers', +} + +@Entity('email_templates') +export class EmailTemplate { + @PrimaryGeneratedColumn('identity', { + generatedIdentity: 'ALWAYS', + }) + id: number; + + @Column({ type: 'varchar', unique: true }) + type: TemplateType; + + @Column() + subject: string; + + @Column({ type: 'text' }) + bodyHtml: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn({ type: 'timestamp', default: () => 'now()' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp', default: () => 'now()' }) + updatedAt: Date; +} diff --git a/apps/backend/src/emails/emails.controller.ts b/apps/backend/src/emails/emails.controller.ts index 4825821..67fef08 100644 --- a/apps/backend/src/emails/emails.controller.ts +++ b/apps/backend/src/emails/emails.controller.ts @@ -1,13 +1,28 @@ -import { Controller, Post, Body, UseGuards } from '@nestjs/common'; +import { + Controller, + Post, + Get, + Body, + UseGuards, + BadRequestException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; import { EmailsService } from './emails.service'; import { CreateEmailDto } from './create-email.dto'; +import { SaveTemplateDto } from './save-template.dto'; +import { BulkSendDto, EmailTargetGroup } from './bulk-send.dto'; +import { DonationsService } from '../donations/donations.service'; @Controller('emails') export class EmailsController { - constructor(private readonly emailService: EmailsService) {} + constructor( + private readonly emailService: EmailsService, + private readonly donationsService: DonationsService, + ) {} @Post('send-email') - // @UseGuards(JwtAuthGuard) (should use auth, not implemented rn) + // TODO: re-enable auth guard temp disabled for local debugging + // @UseGuards(AuthGuard('jwt')) async sendVerificationEmail(@Body() body: CreateEmailDto) { await this.emailService.sendEmail( body.email, @@ -16,4 +31,74 @@ export class EmailsController { ); return { message: 'email sent' }; } + + @Get('template') + async getTemplates() { + return this.emailService.getAllTemplates(); + } + + @Get('subscribers') + // TODO: re-enable auth guard temp disabled for local debugging + // @UseGuards(AuthGuard('jwt')) + async getSubscribers() { + const emails = await this.emailService.getSubscribers(); + return { emails, count: emails.length }; + } + + @Post('template') + // TODO: re-enable auth guard temp disabled for local debugging + // @UseGuards(AuthGuard('jwt')) + async saveTemplate(@Body() body: SaveTemplateDto) { + const template = await this.emailService.saveTemplate( + body.type, + body.subject, + body.bodyHtml, + ); + return { + message: 'Template saved successfully', + template: { + id: template.id, + type: template.type, + subject: template.subject, + updatedAt: template.updatedAt, + }, + }; + } + + @Post('bulk-send') + // TODO: re-enable auth guard temp disabled for local debugging + // @UseGuards(AuthGuard('jwt')) + async bulkSend(@Body() body: BulkSendDto) { + let recipientEmails: string[] = []; + + if (body.targetGroup === EmailTargetGroup.RELAPSED_DONORS) { + const lapsedResult = await this.donationsService.getLapsedDonors(6); + recipientEmails = lapsedResult.emails; + } else if (body.targetGroup === EmailTargetGroup.EMAIL_SUBSCRIBERS) { + recipientEmails = await this.emailService.getSubscribers(); + } else { + throw new BadRequestException('Invalid target group'); + } + + if (recipientEmails.length === 0) { + return { + message: 'No recipients found for the target group', + sent: 0, + targetGroup: body.targetGroup, + }; + } + + // Send bulk emails + const result = await this.emailService.sendBulkEmail( + recipientEmails, + body.subject, + body.bodyHtml, + ); + + return { + message: 'Bulk email campaign sent successfully', + sent: result.sent, + targetGroup: body.targetGroup, + }; + } } diff --git a/apps/backend/src/emails/emails.module.ts b/apps/backend/src/emails/emails.module.ts index e0e3dd6..6334e1e 100644 --- a/apps/backend/src/emails/emails.module.ts +++ b/apps/backend/src/emails/emails.module.ts @@ -1,12 +1,20 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { EmailsController } from './emails.controller'; import { EmailsService } from './emails.service'; import { AmazonSESWrapper, AMAZON_SES_WRAPPER } from './amazon-ses.wrapper'; import { amazonSESClientFactory } from './amazon-ses-client.factory'; import { UsersModule } from '../users/users.module'; +import { DonationsModule } from '../donations/donations.module'; +import { EmailTemplate } from './email-template.entity'; +import { EmailSubscriber } from './email-subscriber.entity'; @Module({ - imports: [UsersModule], + imports: [ + TypeOrmModule.forFeature([EmailTemplate, EmailSubscriber]), + UsersModule, + forwardRef(() => DonationsModule), + ], controllers: [EmailsController], providers: [ EmailsService, diff --git a/apps/backend/src/emails/emails.service.ts b/apps/backend/src/emails/emails.service.ts index 3a7f633..325c8ab 100644 --- a/apps/backend/src/emails/emails.service.ts +++ b/apps/backend/src/emails/emails.service.ts @@ -1,5 +1,9 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { AMAZON_SES_WRAPPER } from './amazon-ses.wrapper'; +import { EmailTemplate, TemplateType } from './email-template.entity'; +import { EmailSubscriber } from './email-subscriber.entity'; @Injectable() export class EmailsService { @@ -7,6 +11,10 @@ export class EmailsService { constructor( @Inject(AMAZON_SES_WRAPPER) private readonly amazonSESWrapper: any, + @InjectRepository(EmailTemplate) + private readonly emailTemplateRepository: Repository, + @InjectRepository(EmailSubscriber) + private readonly emailSubscriberRepository: Repository, ) {} /** @@ -34,4 +42,151 @@ export class EmailsService { throw error; } } + + /** + * Sends bulk emails to multiple recipients. + * + * @param recipientEmails array of recipient email addresses + * @param subject the subject of the email + * @param bodyHTML the HTML body of the email + * @resolves with the number of emails sent + * @rejects if sending fails + */ + public async sendBulkEmail( + recipientEmails: string[], + subject: string, + bodyHTML: string, + ): Promise<{ sent: number }> { + try { + // Send emails in batches to avoid rate limiting + const batchSize = 50; // AWS SES recommends batch sizes + const batches: string[][] = []; + + for (let i = 0; i < recipientEmails.length; i += batchSize) { + batches.push(recipientEmails.slice(i, i + batchSize)); + } + + let sentCount = 0; + for (const batch of batches) { + await this.amazonSESWrapper.sendEmail(batch, subject, bodyHTML); + sentCount += batch.length; + this.logger.log(`Sent batch of ${batch.length} emails`); + } + + this.logger.log( + `Successfully sent ${sentCount} emails with subject: ${subject}`, + ); + return { sent: sentCount }; + } catch (error) { + this.logger.error('Error sending bulk email', error); + throw error; + } + } + + /** + * Saves or updates an email template. + * + * @param type the template type + * @param subject the email subject + * @param bodyHtml the HTML body + * @returns the saved template + */ + public async saveTemplate( + type: TemplateType, + subject: string, + bodyHtml: string, + ): Promise { + try { + let template = await this.emailTemplateRepository.findOne({ + where: { type }, + }); + + if (template) { + template.subject = subject; + template.bodyHtml = bodyHtml; + template.updatedAt = new Date(); + } else { + template = this.emailTemplateRepository.create({ + type, + subject, + bodyHtml, + isActive: true, + }); + } + + return await this.emailTemplateRepository.save(template); + } catch (error) { + this.logger.error(`Error saving template ${type}`, error); + throw error; + } + } + + /** + * Gets an email template by type. + * + * @param type the template type + * @returns the template or null if not found + */ + public async getTemplate(type: TemplateType): Promise { + return await this.emailTemplateRepository.findOne({ + where: { type, isActive: true }, + }); + } + + /** + * Gets all active email subscribers. + * + * @returns array of subscribed email addresses + */ + public async getSubscribers(): Promise { + const subscribers = await this.emailSubscriberRepository.find({ + where: { isSubscribed: true }, + select: ['email'], + }); + + return subscribers.map((sub) => sub.email); + } + + public async getAllTemplates(): Promise { + return await this.emailTemplateRepository.find(); + } + + /** + * Sends the Donation Response email to a donor using the stored template. + * + * @param recipientEmail the donor's email address + * @param donorName the donor's name for personalization + * @param amount the donation amount + * @resolves if the email was sent successfully + * @rejects if the template doesn't exist or sending fails + */ + public async sendDonationResponseEmail( + recipientEmail: string, + donorName: string, + amount: number, + ): Promise { + try { + const template = await this.getTemplate(TemplateType.DONATION_RESPONSE); + + if (!template) { + this.logger.warn( + 'Donation Response template not found, skipping email', + ); + return; + } + + const bodyHTML = template.bodyHtml + .replace(/\{\{donorName\}\}/g, donorName) + .replace(/\{\{amount\}\}/g, amount.toString()); + + await this.sendEmail(recipientEmail, template.subject, bodyHTML); + + this.logger.log( + `Sent Donation Response email to ${recipientEmail} for amount $${amount}`, + ); + } catch (error) { + this.logger.error('Error sending Donation Response email', error); + throw error; + } + } } diff --git a/apps/backend/src/emails/save-template.dto.ts b/apps/backend/src/emails/save-template.dto.ts new file mode 100644 index 0000000..5f93633 --- /dev/null +++ b/apps/backend/src/emails/save-template.dto.ts @@ -0,0 +1,13 @@ +import { IsEnum, IsString } from 'class-validator'; +import { TemplateType } from './email-template.entity'; + +export class SaveTemplateDto { + @IsEnum(TemplateType) + type: TemplateType; + + @IsString() + subject: string; + + @IsString() + bodyHtml: string; +} diff --git a/apps/backend/src/migrations/1778800000001-add_email_templates.ts b/apps/backend/src/migrations/1778800000001-add_email_templates.ts new file mode 100644 index 0000000..1237c19 --- /dev/null +++ b/apps/backend/src/migrations/1778800000001-add_email_templates.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEmailTemplates1778800000001 implements MigrationInterface { + name = 'AddEmailTemplates1778800000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "email_templates" ("id" integer GENERATED ALWAYS AS IDENTITY NOT NULL, "type" character varying NOT NULL, "subject" character varying NOT NULL, "bodyHtml" text NOT NULL, "isActive" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_email_template_type" UNIQUE ("type"), CONSTRAINT "PK_email_templates" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "email_templates"`); + } +} diff --git a/apps/backend/src/migrations/1778800000002-add_email_subscribers.ts b/apps/backend/src/migrations/1778800000002-add_email_subscribers.ts new file mode 100644 index 0000000..ff5c8ff --- /dev/null +++ b/apps/backend/src/migrations/1778800000002-add_email_subscribers.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEmailSubscribers1778800000002 implements MigrationInterface { + name = 'AddEmailSubscribers1778800000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "email_subscribers" ("id" integer GENERATED ALWAYS AS IDENTITY NOT NULL, "email" character varying NOT NULL, "firstName" character varying, "lastName" character varying, "isSubscribed" boolean NOT NULL DEFAULT true, "unsubscribedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_email_subscriber_email" UNIQUE ("email"), CONSTRAINT "PK_email_subscribers" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "email_subscribers"`); + } +} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 27b4b7d..99fe02f 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -86,6 +86,40 @@ export type UpdateGoalRequest = { endDate: string; }; +export type SaveTemplateRequest = { + type: 'donation_response' | 'relapsed_donor' | 'email_subscribers'; + subject: string; + bodyHtml: string; +}; + +export type SaveTemplateResponse = { + message: string; + template: { + id: number; + type: string; + subject: string; + bodyHtml: string; + updatedAt: string; + }; +}; + +export type BulkSendRequest = { + targetGroup: 'relapsed_donors' | 'email_subscribers'; + subject: string; + bodyHtml: string; +}; + +export type BulkSendResponse = { + message: string; + sent: number; + targetGroup: string; +}; + +export type EmailSubscribersResponse = { + emails: string[]; + count: number; +}; + export type CreatePaymentIntentRequest = { amount: number; // in cents currency: string; @@ -350,6 +384,46 @@ export class ApiClient { } } + public async saveEmailTemplate( + body: SaveTemplateRequest, + ): Promise { + try { + const res = await this.axiosInstance.post('/api/emails/template', body); + return res.data as SaveTemplateResponse; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to save email template'); + } + } + + public async getEmailTemplates(): Promise< + SaveTemplateResponse['template'][] + > { + try { + const res = await this.axiosInstance.get('/api/emails/template'); + return res.data; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to fetch email templates'); + } + } + + public async bulkSendEmail(body: BulkSendRequest): Promise { + try { + const res = await this.axiosInstance.post('/api/emails/bulk-send', body); + return res.data as BulkSendResponse; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to send bulk email'); + } + } + + public async getEmailSubscribers(): Promise { + try { + const res = await this.axiosInstance.get('/api/emails/subscribers'); + return res.data as EmailSubscribersResponse; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to fetch email subscribers'); + } + } + private async get(path: string): Promise { return this.axiosInstance.get(path).then((response) => response.data); } diff --git a/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx b/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx index c37e450..df7a80e 100644 --- a/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx +++ b/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import EmailEditorCard from './EmailEditorCard'; import EmailPreviewPanel from './EmailPreviewPanel'; import type { TabId, EmailData, EmailsState, Signature } from './types'; @@ -9,6 +9,7 @@ import { TAB_CONFIG, buildSignatureHTML, } from './types'; +import apiClient from '../../api/apiClient'; export function EmailEditor() { const [activeTab, setActiveTab] = useState('donation'); @@ -19,6 +20,36 @@ export function EmailEditor() { const [ctaText, setCtaText] = useState('DONATE AT OUR SITE!'); const [ctaLink, setCtaLink] = useState('https://fenwaycommunitycenter.org/'); + useEffect(() => { + const loadTemplates = async () => { + try { + const templates = await apiClient.getEmailTemplates(); + if (templates && templates.length > 0) { + setEmails((prev) => { + const next = { ...prev }; + templates.forEach((t: any) => { + let tab: TabId | null = null; + if (t.type === 'donation_response') tab = 'donation'; + else if (t.type === 'relapsed_donor') tab = 'relapsed'; + else if (t.type === 'email_subscribers') tab = 'mass'; + + if (tab) { + next[tab] = { + subject: t.subject, + body: t.bodyHtml, + }; + } + }); + return next; + }); + } + } catch (error) { + console.error('[EmailEditor] Failed to load templates:', error); + } + }; + loadTemplates(); + }, []); + const handleEmailChange = ( tab: TabId, field: keyof EmailData, @@ -27,33 +58,81 @@ export function EmailEditor() { setEmails((prev) => ({ ...prev, [tab]: { ...prev[tab], [field]: value } })); }; - const handleSave = () => { - console.log('[EmailEditorOverviewPage] Save payload:', { - tab: activeTab, - subject: emails[activeTab].subject, - body: emails[activeTab].body, - signature: sig, - }); - setSaved(true); - setTimeout(() => setSaved(false), 2500); - }; - - const handleSend = () => { + const buildFullHTML = () => { const email = emails[activeTab]; - const fullHTML = ` + return ` ${email.body} ${buildSignatureHTML(sig)} - ${ctaText ? `` : ''} + ${ctaText ? `` : ''} `; - console.log('[EmailEditorOverviewPage] Send payload:', { - to: '[recipient]', - subject: email.subject, - html: fullHTML, - }); - setSent(true); - setTimeout(() => setSent(false), 2500); + }; + + const handleSave = async () => { + try { + const email = emails[activeTab]; + const fullHTML = buildFullHTML(); + + let type: 'donation_response' | 'relapsed_donor' | 'email_subscribers'; + if (activeTab === 'donation') { + type = 'donation_response'; + } else if (activeTab === 'relapsed') { + type = 'relapsed_donor'; + } else { + type = 'email_subscribers'; + } + + await apiClient.saveEmailTemplate({ + type, + subject: email.subject, + bodyHtml: fullHTML, + }); + + setSaved(true); + setTimeout(() => setSaved(false), 2500); + } catch (error) { + console.error('[EmailEditorOverviewPage] Save failed:', error); + alert( + `Failed to save: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + }; + + const handleSend = async () => { + try { + const email = emails[activeTab]; + const fullHTML = buildFullHTML(); + + let targetGroup: 'relapsed_donors' | 'email_subscribers'; + if (activeTab === 'relapsed') { + targetGroup = 'relapsed_donors'; + } else if (activeTab === 'mass') { + targetGroup = 'email_subscribers'; + } else { + alert('Use "Save Changes" for Donation Response emails'); + return; + } + + const result = await apiClient.bulkSendEmail({ + targetGroup, + subject: email.subject, + bodyHtml: fullHTML, + }); + + console.log('[EmailEditorOverviewPage] Bulk email sent:', result); + alert(`Successfully sent ${result.sent} emails to ${result.targetGroup}`); + + setSent(true); + setTimeout(() => setSent(false), 2500); + } catch (error) { + console.error('[EmailEditorOverviewPage] Send failed:', error); + alert( + `Failed to send: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } }; return (