Email Campaigns
The email campaign system automates the delivery of newsletters and marketing emails through SendGrid's Single Send API, with real-time Slack notifications for delivery status.
Architecture Overview
Campaign Scheduling
Campaigns support three delivery modes:
Delivery Modes
| Mode | Field | Behavior |
|---|---|---|
| Immediate | Neither field set | Delivery triggered via API call |
| Scheduled | deliver_at | One-time delivery at specified date/time |
| Recurring | schedule (cron) | Automatic delivery on cron schedule |
deliver_at and schedule are mutually exclusive. A campaign can be either scheduled OR recurring, never both.
Recurring Campaigns
Recurring campaigns use cron expressions to define delivery frequency. The system uses sidekiq-cron to manage scheduled jobs.
Common cron patterns:
| Pattern | Description |
|---|---|
0 6 * * * | Every day at 6:00 AM |
0 8 * * 1-5 | Weekdays at 8:00 AM |
0 9 * * 1 | Every Monday at 9:00 AM |
0 */6 * * * | Every 6 hours |
All cron expressions run in the application timezone (Europe/Rome). A schedule of 0 6 * * * means 6:00 AM Rome time.
How it works:
- When a campaign with
scheduleis saved, a cron job is registered in Sidekiq - At each scheduled time,
DeliverEmailCampaignJobis automatically triggered - Content is fetched fresh from
content_urlfor each delivery - A new
EmailCampaignDeliveryrecord is created for each send
Viewing scheduled jobs:
Cron jobs can be viewed in the Sidekiq Web UI at /jobs/cron. Each campaign creates a job named EmailCampaign@{id}.
Scheduled Campaigns (One-Time)
For one-time scheduled deliveries:
- Set the
deliver_atfield to the desired date/time - Save the campaign
- The system schedules
DeliverEmailCampaignJobto run at that time
Schedule changes:
If you modify deliver_at after initial save:
- The original scheduled job is superseded
- A new job is scheduled for the updated time
- The system uses
scheduled_job_referenceto ensure only the latest schedule executes
Viewing Campaigns in Avo
The Avo admin panel at /resources/email_campaigns provides read-only access to campaigns:
- View campaign configuration (name, locale, sender, list, schedule)
- Preview email content via the content URL link
- View delivery history for each campaign
Avo uses ReadonlyPolicy for email campaigns. All CRUD operations must go through the API.
Campaign Management
Campaigns are managed by the WordPress backend via JWT-authenticated API calls. WordPress handles:
- Creation: When an editor configures a new newsletter in WordPress
- Updates: When campaign settings (schedule, sender, list) are modified
- Deletion: When a campaign is removed from WordPress
- Immediate delivery: When an editor triggers a manual send
- Test delivery: When an editor sends a preview to specific email addresses
Job Flow
1. DeliverEmailCampaignJob
Prepares the campaign for delivery:
- Loads the
EmailCampaignrecord - Validates schedule (skips if campaign changed)
- Creates an
EmailCampaignDeliveryrecord with:- Rendered subject and content
- Sender ID from SendGrid verified senders
- Test recipients (if test mode)
- Enqueues
PerformEmailCampaignDeliveryJob
2. PerformEmailCampaignDeliveryJob
Executes the actual delivery:
Test Mode:
- Sends preview to specified test emails via SendGrid test endpoint
- Does NOT create a Single Send
Production Mode:
- Creates a Single Send in SendGrid (first attempt only)
- Stores
single_send_idin the delivery record - Triggers immediate delivery via
single_send_now
Retry Policy
Both jobs use EmailCampaignJobConcern which configures:
| Setting | Value | Description |
|---|---|---|
| Queue | email | Dedicated queue for email operations |
| Max Attempts | 8 | Approximately 3 hours of retries |
| Retry Strategy | Exponential backoff | wait: :exponentially_longer |
| Sidekiq Retry | Disabled | Uses ActiveJob retry instead |
Discarded Exceptions:
ActiveRecord::RecordNotFound- Campaign or delivery deletedActiveJob::DeserializationError- Record can't be loaded
These exceptions are silently discarded without retry or notification.
Slack Notifications
Configuration
Slack notifications are sent via Incoming Webhooks. Each environment uses a separate webhook:
| Environment | Config File | Slack Channel |
|---|---|---|
| Production | infra/ga-reports.env | #newsletter-notifications |
| Staging | infra/ga-reports-common.env | #test-channel (private) |
The SLACK_CAMPAIGNS_WEBHOOK variable must be set. If missing, notifications will fail silently with a KeyError.
Notification Events
| Event | ActiveJob Hook | Slack Message |
|---|---|---|
| Job Started | perform_start.active_job | Starting delivery message |
| Job Success | perform.active_job | Green attachment with stats |
| Job Retry | enqueue_retry.active_job | Warning attachment with next retry time |
| Job Failed | retry_stopped.active_job | Danger attachment with error |
Test Mode Behavior
When delivery.test_mode? is true:
- Slack notifications are skipped (via
unless: :test?condition) - Only the starting message is sent (before test mode is evaluated)
ActiveSupport Instrumentation
The notification system uses ActiveSupport's instrumentation to listen for ActiveJob events:
# config/initializers/active_job_notifications.rb
ActiveSupport::Notifications.subscribe(/.*\.active_job/) do |*args|
EmailCampaignNotification.enqueue_notifications(
ActiveSupport::Notifications::Event.new(*args)
)
end
Event Payload
Each event includes:
| Key | Type | Description |
|---|---|---|
job | ActiveJob::Base | The job instance |
exception | Exception | Error (only on failure) |
error | Exception | Same as exception |
wait | Integer | Seconds until next retry |
Database Schema
EmailCampaign
Defines the campaign configuration:
name- Campaign identifierlocale- Target locale (en, fr, it, etc.)list_id- SendGrid marketing list IDcontent_url- Source URL for contentdeliver_at- One-time scheduled delivery time (mutually exclusive withschedule)schedule- Cron expression for recurring delivery (mutually exclusive withdeliver_at)scheduled_job_reference- ActiveJob ID for deduplication
EmailCampaignDelivery
Records each delivery attempt:
campaign_id- Parent campaignsubject- Rendered email subjectcontent- Rendered HTML contentsender_id- SendGrid verified sender IDsingle_send_id- SendGrid Single Send ID (null until created)test_mode- Boolean flag for test deliveriestest_emails- Array of test recipients
Monitoring
Expected Logs
Successful campaign delivery produces these log entries:
[INFO] Created delivery 154602 for campaign "Newsletter EN"
subject: "Weekly Update"
sender: "noreply@aleteia.org"
[INFO] Created single send abc123-def456 in sendgrid
[INFO] Sending abc123-def456 to sendgrid now
subject: "Weekly Update"
test_mode: false
Datadog Queries
Find all campaign deliveries:
service:reports.aleteia.org @job.class:PerformEmailCampaignDeliveryJob "Sending * to sendgrid now"
Find failed campaigns:
service:reports.aleteia.org @job.class:PerformEmailCampaignDeliveryJob status:error
Find specific campaign by name:
service:reports.aleteia.org "Campaign Name" @job.class:(DeliverEmailCampaignJob OR PerformEmailCampaignDeliveryJob)
Troubleshooting
No Slack Notifications
-
Check ENV variable:
ENV['SLACK_CAMPAIGNS_WEBHOOK'].present? -
Test webhook directly:
require 'net/http'
uri = URI.parse(ENV['SLACK_CAMPAIGNS_WEBHOOK'])
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
req.body = { text: "Test message" }.to_json
http.request(req) -
Verify delivery is not in test mode:
EmailCampaignDelivery.last.test_mode?
Missing Deliveries
Check for deliveries without SendGrid ID:
EmailCampaignDelivery.where(single_send_id: nil, test_mode: false)
.where("created_at > ?", 1.week.ago)
.pluck(:id, :subject, :created_at)
No Retry Events
If enqueue_retry.active_job events aren't appearing:
- Jobs may be succeeding without errors
- Errors may be caught by
discard_on(RecordNotFound, DeserializationError) - Jobs may have a
rescueblock that swallows exceptions
Check Sidekiq dead set for failed jobs:
require 'sidekiq/api'
Sidekiq::DeadSet.new.select { |j|
j.klass.in?(%w[DeliverEmailCampaignJob PerformEmailCampaignDeliveryJob])
}.map { |j|
{ class: j.klass, error: j.item['error_message'] }
}
Slack App Configuration
The Slack integration uses an Incoming Webhook app:
- App URL: https://api.slack.com/apps/A02TB1HBG1L
- Webhook Type: Incoming Webhooks
- Workspace: Aleteia
Creating a New Webhook
- Go to the Slack App settings
- Navigate to "Incoming Webhooks"
- Click "Add New Webhook to Workspace"
- Select the target channel
- Copy the webhook URL
- Add to the appropriate env file using SOPS:
sops infra/ga-reports.env # Production (#newsletter-notifications)
# or
sops infra/ga-reports-common.env # Staging (#test-channel)
Webhook URL Format
https://hooks.slack.com/services/T04PF07RN/BXXXXXXXX/xxxxxxxxxxxxxxxxxxxxxxxx
T04PF07RN- Workspace IDBXXXXXXXX- App/Bot ID (unique per webhook)xxxxxxxx...- Secret token