This solution implements automated cleanup for the failed_emails table to prevent unbounded growth of permanently-failed email records. The cleanup process removes old failed email entries that are unlikely to be useful after the underlying issues have been resolved.
Creates the failed_emails table with the following structure:
id: Primary keyrecipient: Email address that failedsubject: Email subject linebody: Email contenterror_message: Failure reasonretry_count: Number of retry attemptsfirst_attempt: Initial send attempt timestamplast_attempt: Last retry attempt timestampcreated_at: Record creation timestamp (used for retention)
Includes an index on created_at for efficient pruning queries.
-
Function:
cleanupFailedEmails(retentionDays)- Removes failed email records older than the specified retention period
- Returns the number of deleted records
- Logs cleanup progress and results
-
Function:
startFailedEmailCleanupJob()- Schedules the cleanup job as a daily cron task (2:00 AM)
- Uses configurable retention period from environment variables
- Handles job errors gracefully
New environment variable added to .env.example:
# Failed email cleanup retention (optional — defaults to 7 days)
FAILED_EMAIL_RETENTION_DAYS=7This follows the project's existing pattern of configurable retention periods (similar to database backup retention).
The cleanup job is automatically started when the server boots via backend/src/index.js, alongside the existing subscription processing job.
Comprehensive test suite (backend/tests/cleanup-failed-emails.test.js) covering:
- Basic cleanup functionality
- Date calculations for various retention periods
- Environment variable configuration
- Error handling
- Edge cases (0-day retention, invalid configurations)
- Logging verification
Once deployed, the cleanup job runs automatically:
- Schedule: Daily at 2:00 AM
- Default Retention: 7 days
- Configurable: Via
FAILED_EMAIL_RETENTION_DAYSenvironment variable
For immediate cleanup or testing, you can manually trigger the cleanup:
const { cleanupFailedEmails } = require('./src/jobs/cleanupFailedEmails');
// Clean up records older than 7 days (default)
cleanupFailedEmails().then(deletedCount => {
console.log(`Cleaned up ${deletedCount} records`);
});
// Clean up records older than 30 days
cleanupFailedEmails(30).then(deletedCount => {
console.log(`Cleaned up ${deletedCount} records`);
});If automated cleanup needs to be performed manually:
-
Connect to your database (SQLite or PostgreSQL)
-
Check current failed email count:
SELECT COUNT(*) FROM failed_emails; SELECT COUNT(*) FROM failed_emails WHERE created_at < datetime('now', '-7 days');
-
Manual cleanup query (removes records older than 7 days):
DELETE FROM failed_emails WHERE created_at < datetime('now', '-7 days');
-
Custom retention period (example: 30 days):
DELETE FROM failed_emails WHERE created_at < datetime('now', '-30 days');
-
Verify cleanup:
SELECT COUNT(*) FROM failed_emails;
| Environment Variable | Default | Description |
|---|---|---|
FAILED_EMAIL_RETENTION_DAYS |
7 | Number of days to retain failed email records |
The cleanup job logs its activity:
- Start of cleanup with retention period and cutoff date
- Number of records pruned (if any)
- "No old records to prune" message when table is already clean
- Error messages if cleanup fails
Example log output:
[cleanup-failed-emails] Cleanup job scheduled (daily at 2:00 AM, 7-day retention)
[cleanup-failed-emails] Pruning failed emails older than 7 days (before 2026-07-20T02:00:00.000Z)
[cleanup-failed-emails] Pruned 42 old failed email record(s)
Run the test suite:
npm test -- --testPathPattern=cleanup-failed-emailsThe tests verify:
- Correct SQL query generation
- Accurate date calculations for retention periods
- Environment variable handling
- Error scenarios
- Integration with the cron scheduler
To apply the database migration:
npm run migrateTo rollback (removes the failed_emails table):
npm run migrate:rollback- The cleanup job only removes old records; it doesn't expose sensitive email content
- Database queries use parameterized statements to prevent injection
- Failed email content in the database should already be sanitized by the mailer system
- The cleanup runs during low-traffic hours (2:00 AM) to minimize performance impact
- Daily cleanup queries are efficient due to the indexed
created_atcolumn - Cleanup runs during off-peak hours
- The query execution time scales with the number of old records (typically minimal for daily cleanup)
- Index overhead is minimal (single column, simple DATE comparison)
- Works with both SQLite (development) and PostgreSQL (production)
- Uses standard SQL DATE functions compatible with both database systems
- Follows existing project patterns for job scheduling and database access