Plan: Newsletter Robustness & Progress Tracking for 3000+ Subscribers
Context
Critical Bug: Thread Pool Overflow (P0)
Root cause: NewsletterEmailService.sendNewsletter() loops over all subscribers and calls emailService.sendHtmlEmail() (which is @Async) for each one. The taskExecutor (AsyncConfig) is configured with:
- Core: 5 threads, Max: 10, Queue capacity: 100
With 3000 subscribers, only the first ~110 submissions fit in the pool. After that, TaskRejectedException is thrown per subscriber (caught by the try-catch as βfailedβ). ~2890 subscribers never receive the email.
All issues found:
| # | Issue | Severity |
|---|---|---|
| 1 | Thread pool overflow: only ~110/3000 emails actually sent | P0 CRITICAL |
| 2 | No duplicate-send protection: double-clicking βSendβ emails everyone twice | P1 HIGH |
| 3 | No resume after interruption (ECS Fargate Spot interruptions are mentioned as common) | P1 HIGH |
| 4 | HTTP request blocks for entire loop duration; risk of timeout | P1 HIGH |
| 5 | SES rate limit not respected (default: 14/s) | P2 MEDIUM |
| 6 | No progress visibility for organizer | P2 MEDIUM |
Recommended Approach: Async Background Job + Progress Polling
Architectural Change
Before:
HTTP POST /send β loop 3000 async dispatches β response (broken: only ~110 sent)
After:
HTTP POST /send β create send record (PENDING) β launch @Async background job β return {sendId, status=PENDING}
Background thread β paginate 50/batch β sendHtmlEmailSync() β update sentCount every batch
Frontend β polls GET /sends/{sendId}/status every 3s β shows progress bar β handles COMPLETED/PARTIAL/FAILED
DB Changes (New Migration V74)
File: services/event-management-service/src/main/resources/db/migration/V74__add_newsletter_send_status.sql
ALTER TABLE newsletter_sends
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED',
ADD COLUMN sent_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN failed_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN started_at TIMESTAMPTZ,
ADD COLUMN completed_at TIMESTAMPTZ;
-- Backfill: treat all existing rows as completed
UPDATE newsletter_sends SET
status = 'COMPLETED',
started_at = sent_at,
completed_at = sent_at,
sent_count = COALESCE(recipient_count, 0);
Backend Changes
1. shared-kernel β Add sync email method
File: shared-kernel/src/main/java/ch/batbern/shared/service/EmailService.java
Add sendHtmlEmailSync(String to, String subject, String htmlBody) β identical implementation to sendHtmlEmail() but without @Async. Called from within an already-background thread to avoid inner thread pool dispatch.
2. NewsletterSend.java β Add status fields
File: services/event-management-service/src/main/java/ch/batbern/events/domain/NewsletterSend.java
Add fields: status (String, default "PENDING"), sentCount (int), failedCount (int), startedAt (Instant), completedAt (Instant).
3. NewsletterSendRepository.java β Add queries
File: services/event-management-service/src/main/java/ch/batbern/events/repository/NewsletterSendRepository.java
Add:
Optional<NewsletterSend> findFirstByEventIdAndStatus(UUID eventId, String status)β for duplicate-send preventionOptional<NewsletterSend> findByIdAndEventId(UUID id, UUID eventId)β for status endpoint
4. NewsletterSubscriberRepository.java β Add paginated query
File: services/event-management-service/src/main/java/ch/batbern/events/repository/NewsletterSubscriberRepository.java
Add: Page<NewsletterSubscriber> findByUnsubscribedAtIsNull(Pageable pageable) β to process subscribers in pages of 50 instead of loading all 3000 into memory at once.
5. NewsletterEmailService.java β Core redesign
File: services/event-management-service/src/main/java/ch/batbern/events/service/NewsletterEmailService.java
sendNewsletter() β change to fire-and-forget:
1. Check for IN_PROGRESS send on this event β throw 409 Conflict if found
2. Create send record with status='PENDING' (committed in own @Transactional)
3. Call executeNewsletterSendAsync(sendId, event, isReminder, locale, sentByUsername, effectiveKey)
4. Return immediately with {sendId, status=PENDING, recipientCount=activeCount}
New executeNewsletterSendAsync(...) β @Async background method:
1. Update send: status=IN_PROGRESS, startedAt=now (own @Transactional)
2. page=0, size=50; sentCount=0; failedCount=0
3. Loop: fetch page of active subscribers
4. For each subscriber in page:
- Build per-recipient vars (unsubscribeLink)
- Render HTML
- Call emailService.sendHtmlEmailSync(to, subject, html)
- recordRecipient(sendId, email, "sent" or "failed")
- increment sentCount or failedCount
- sleep 70ms (β14 emails/sec, respects SES default rate limit)
5. After each page: updateSendProgress(sendId, sentCount, failedCount) (own @Transactional)
6. If page had results β next page; else break
7. Final status:
- failedCount == 0 β COMPLETED
- failedCount > 0 && sentCount > 0 β PARTIAL
- sentCount == 0 β FAILED
8. Update send: status, completedAt=now (own @Transactional)
9. Log result
Exception handler wrapping step 3-8: On uncaught exception β mark send as FAILED, log error.
New @Transactional updateSendProgress(sendId, sentCount, failedCount) β updates counts in DB mid-send for progress polling.
6. NewsletterSendStatusResponse.java β New DTO
File: services/event-management-service/src/main/java/ch/batbern/events/dto/NewsletterSendStatusResponse.java
Fields: id, status, sentCount, failedCount, totalCount (=recipientCount), startedAt, completedAt, percentComplete (derived).
7. NewsletterSendResponse.java β Extend
File: services/event-management-service/src/main/java/ch/batbern/events/dto/NewsletterSendResponse.java
Add: status, sentCount, failedCount, startedAt, completedAt.
8. NewsletterController.java β Add status endpoint
File: services/event-management-service/src/main/java/ch/batbern/events/controller/NewsletterController.java
Add: GET /api/v1/events/{eventCode}/newsletter/sends/{sendId}/status (ORGANIZER role)
- Returns
NewsletterSendStatusResponse - Validates that
sendIdbelongs toeventCode(security: no cross-event leakage)
OpenAPI Spec Changes (ADR-006: spec first)
File: docs/api/events-api.openapi.yml
- Update
NewsletterSendResponseschema: addstatus,sentCount,failedCount,startedAt,completedAt - Add
NewsletterSendStatusResponseschema withid,status,sentCount,failedCount,totalCount,percentComplete,startedAt,completedAt - Add new path:
GET /events/{eventCode}/newsletter/sends/{sendId}/status - Regenerate TypeScript types:
npm run generate:api-types
Frontend Changes
useNewsletter.ts
File: web-frontend/src/hooks/useNewsletter/useNewsletter.ts
Add useSendStatus(eventCode, sendId) query:
enabledwhensendId != null && status is not terminalrefetchInterval: 3000(poll every 3s while IN_PROGRESS/PENDING)- Stops polling when status is COMPLETED/PARTIAL/FAILED
newsletterService.ts
File: web-frontend/src/services/newsletterService.ts
Add getSendStatus(eventCode, sendId): Promise<NewsletterSendStatusResponse>
EventNewsletterTab.tsx β Add progress UI
File: web-frontend/src/components/organizer/EventPage/EventNewsletterTab.tsx
Changes:
- After successful βSend Newsletterβ / βSend Reminderβ call β store
sendIdin local state - Render progress section while
status === 'IN_PROGRESS' | 'PENDING':Use shadcnSending newsletter... 847 / 3000 recipients (28%) [ββββββββββββββββββββββββ]Progresscomponent - On COMPLETED β success toast, clear progress, refresh history table
- On PARTIAL β warning toast: βSent to 2998 / 3000 subscribers (2 failed)β
- On FAILED β error toast
- Disable Send/Reminder buttons while a send is PENDING or IN_PROGRESS (check
newsletter_sendshistory for active send on load too)
i18n additions
Files: web-frontend/public/locales/en/events.json, de/events.json
Add keys under eventPage.newsletter.*:
sendInProgress,sendProgress,sendCompleted,sendPartial,sendFailed
Retry Failed Recipients
Backend
NewsletterController.java β add:
POST /api/v1/events/{eventCode}/newsletter/sends/{sendId}/retry (ORGANIZER)
- Validates send exists and belongs to
eventCode - Validates
statusisPARTIALorFAILED(returns 409 if COMPLETED or IN_PROGRESS) - Delegates to
NewsletterEmailService.retryFailedRecipients(sendId, event, sentByUsername)
NewsletterEmailService.java β add retryFailedRecipients():
- Load
newsletter_recipients WHERE send_id=? AND delivery_status='failed'(viaNewsletterRecipientRepository) - Load subscriber records for those emails to get their
unsubscribeToken - Update send:
status=IN_PROGRESS, startedAt=now(own@Transactional) - Launch
@Asyncloop over failed recipients only β same paginated/rate-limited logic as the main send - For each success: update recipient row
delivery_status='sent', incrementsentCounton the send record - For remaining failures: leave as
failed, incrementfailedCount - Terminal status: all cleared β
COMPLETED; some remain βPARTIAL; none cleared βFAILED
Key constraint: Retry re-uses the SAME newsletter_sends row (updates in place) so history table shows one clean final row, not a confusing duplicate.
New NewsletterRecipientRepository query:
List<NewsletterRecipient> findBySendIdAndDeliveryStatus(UUID sendId, String deliveryStatus)
Frontend (EventNewsletterTab.tsx)
- In the send history table, show a βRetry Failedβ button on rows with
status=PARTIALorFAILED - Clicking it calls
POST /sends/{sendId}/retrythen starts the same progress-polling flow - After retry completes, history table refreshes
OpenAPI
Add POST /events/{eventCode}/newsletter/sends/{sendId}/retry path to events-api.openapi.yml.
Tests (TDD β Red-Green-Refactor)
Backend unit tests
NewsletterEmailServiceTest.java β add:
sendNewsletter_returnsImmediately_withPendingStatussendNewsletter_throws409_whenSendAlreadyInProgressexecuteNewsletterSendAsync_updatesStatusToCompleted_onSuccessexecuteNewsletterSendAsync_updatesStatusToPartial_onSomeFailedexecuteNewsletterSendAsync_updatesStatusToFailed_onAllFailedexecuteNewsletterSendAsync_processesInPages_notAllAtOnce(verifies Pageable used)
Backend integration tests
NewsletterControllerIntegrationTest.java β add:
sendNewsletter_returns202_withSendIdgetSendStatus_returns200_withProgress(after send)sendNewsletter_returns409_whenSendInProgressretryFailedRecipients_returns409_whenSendIsCompletedretryFailedRecipients_resends_onlyToFailedRecipients
Frontend tests
EventNewsletterTab.test.tsx β add:
- Progress bar renders when status=IN_PROGRESS
- Polls status endpoint while IN_PROGRESS
- Success toast shown on COMPLETED
- Warning toast shown on PARTIAL with counts
- Send button disabled while IN_PROGRESS
Files to Modify
| File | Change |
|---|---|
docs/api/events-api.openapi.yml |
Add status endpoint + new/updated schemas (FIRST) |
shared-kernel/.../EmailService.java |
Add sendHtmlEmailSync() |
V74__add_newsletter_send_status.sql |
NEW β add status columns |
NewsletterSend.java |
Add status, sentCount, failedCount, startedAt, completedAt |
NewsletterSendRepository.java |
Add findFirstByEventIdAndStatus, findByIdAndEventId |
NewsletterSubscriberRepository.java |
Add paginated findByUnsubscribedAtIsNull |
NewsletterEmailService.java |
Redesign sendNewsletter() + add executeNewsletterSendAsync() |
NewsletterSendResponse.java |
Add status + count fields |
NewsletterSendStatusResponse.java |
NEW DTO |
NewsletterController.java |
Add GET /sends/{sendId}/status + POST /sends/{sendId}/retry |
NewsletterRecipientRepository.java |
Add findBySendIdAndDeliveryStatus |
newsletterService.ts |
Add getSendStatus() |
useNewsletter.ts |
Add useSendStatus() with polling |
EventNewsletterTab.tsx |
Add progress bar + polling logic |
en/events.json, de/events.json |
Add progress i18n keys |
NewsletterEmailServiceTest.java |
Add async/status/pagination tests |
NewsletterControllerIntegrationTest.java |
Add status endpoint tests |
EventNewsletterTab.test.tsx |
Add progress UI tests |
Verification
- Unit tests:
./gradlew :services:event-management-service:test --tests NewsletterEmailServiceTest - Integration tests:
./gradlew :services:event-management-service:test --tests NewsletterControllerIntegrationTest - Frontend tests:
cd web-frontend && npx vitest run --reporter=verbose EventNewsletterTab - Manual verify:
- Start native services; send newsletter to test subscribers
- Confirm
newsletter_sendsrow shows status=COMPLETED with correct counts - Confirm UI shows progress bar during send
- Confirm double-click returns 409
- Kill and restart service mid-send (simulate Fargate Spot): confirm send resumes with correct partial counts visible
- Type-check:
cd web-frontend && npm run type-checkβ zero errors - Checkstyle:
./gradlew :services:event-management-service:checkstyleMainβ zero violations