Skip to main content

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

ModeFieldBehavior
ImmediateNeither field setDelivery triggered via API call
Scheduleddeliver_atOne-time delivery at specified date/time
Recurringschedule (cron)Automatic delivery on cron schedule
Mutual Exclusivity

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:

PatternDescription
0 6 * * *Every day at 6:00 AM
0 8 * * 1-5Weekdays at 8:00 AM
0 9 * * 1Every Monday at 9:00 AM
0 */6 * * *Every 6 hours
Timezone

All cron expressions run in the application timezone (Europe/Rome). A schedule of 0 6 * * * means 6:00 AM Rome time.

How it works:

  1. When a campaign with schedule is saved, a cron job is registered in Sidekiq
  2. At each scheduled time, DeliverEmailCampaignJob is automatically triggered
  3. Content is fetched fresh from content_url for each delivery
  4. A new EmailCampaignDelivery record 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:

  1. Set the deliver_at field to the desired date/time
  2. Save the campaign
  3. The system schedules DeliverEmailCampaignJob to 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_reference to 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
Read-Only

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:

  1. Loads the EmailCampaign record
  2. Validates schedule (skips if campaign changed)
  3. Creates an EmailCampaignDelivery record with:
    • Rendered subject and content
    • Sender ID from SendGrid verified senders
    • Test recipients (if test mode)
  4. 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_id in the delivery record
  • Triggers immediate delivery via single_send_now

Retry Policy

Both jobs use EmailCampaignJobConcern which configures:

SettingValueDescription
QueueemailDedicated queue for email operations
Max Attempts8Approximately 3 hours of retries
Retry StrategyExponential backoffwait: :exponentially_longer
Sidekiq RetryDisabledUses ActiveJob retry instead

Discarded Exceptions:

  • ActiveRecord::RecordNotFound - Campaign or delivery deleted
  • ActiveJob::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:

EnvironmentConfig FileSlack Channel
Productioninfra/ga-reports.env#newsletter-notifications
Staginginfra/ga-reports-common.env#test-channel (private)
Environment Variable

The SLACK_CAMPAIGNS_WEBHOOK variable must be set. If missing, notifications will fail silently with a KeyError.

Notification Events

EventActiveJob HookSlack Message
Job Startedperform_start.active_jobStarting delivery message
Job Successperform.active_jobGreen attachment with stats
Job Retryenqueue_retry.active_jobWarning attachment with next retry time
Job Failedretry_stopped.active_jobDanger 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:

KeyTypeDescription
jobActiveJob::BaseThe job instance
exceptionExceptionError (only on failure)
errorExceptionSame as exception
waitIntegerSeconds until next retry

Database Schema

EmailCampaign

Defines the campaign configuration:

  • name - Campaign identifier
  • locale - Target locale (en, fr, it, etc.)
  • list_id - SendGrid marketing list ID
  • content_url - Source URL for content
  • deliver_at - One-time scheduled delivery time (mutually exclusive with schedule)
  • schedule - Cron expression for recurring delivery (mutually exclusive with deliver_at)
  • scheduled_job_reference - ActiveJob ID for deduplication

EmailCampaignDelivery

Records each delivery attempt:

  • campaign_id - Parent campaign
  • subject - Rendered email subject
  • content - Rendered HTML content
  • sender_id - SendGrid verified sender ID
  • single_send_id - SendGrid Single Send ID (null until created)
  • test_mode - Boolean flag for test deliveries
  • test_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: "[email protected]"

[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

  1. Check ENV variable:

    ENV['SLACK_CAMPAIGNS_WEBHOOK'].present?
  2. 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)
  3. 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:

  1. Jobs may be succeeding without errors
  2. Errors may be caught by discard_on (RecordNotFound, DeserializationError)
  3. Jobs may have a rescue block 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:

Creating a New Webhook

  1. Go to the Slack App settings
  2. Navigate to "Incoming Webhooks"
  3. Click "Add New Webhook to Workspace"
  4. Select the target channel
  5. Copy the webhook URL
  6. 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 ID
  • BXXXXXXXX - App/Bot ID (unique per webhook)
  • xxxxxxxx... - Secret token