Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
03658ce
Add database migrations for MailBluster integration
Apr 10, 2026
288237e
Add MailblusterService with API wrapper
Apr 10, 2026
f2a2036
Add MailBluster initializer with API key configuration
Apr 10, 2026
2272e27
Auto-sync teacher to MailBluster on approval
Apr 10, 2026
d76e088
Auto-sync to MailBluster when personal email is added
Apr 10, 2026
e24948b
Add routes for MailBluster sync actions
Apr 10, 2026
56dfba3
Add sync_mailbluster and sync_all_mailbluster controller actions
Apr 10, 2026
9d59f9d
Add 'Sync All to MailBluster' button on teachers index page
Apr 10, 2026
48f399f
Add MailBluster sync card to teacher show page
Apr 10, 2026
fb7f136
Add MailBluster helpers to Teacher model and update CSV export
Apr 10, 2026
17a1bea
Add delivery tracking scopes and methods to EmailAddress model
Apr 10, 2026
3dbaae5
Update TeacherHelper with bounce badge and sync status helpers
Apr 10, 2026
84e69ad
Add MailBluster sync status column to teachers table
Apr 10, 2026
13531b1
Add comprehensive RSpec tests for MailblusterService
Apr 10, 2026
3f1e0d1
Add controller tests for MailBluster sync actions
Apr 10, 2026
103e20c
Add model tests for MailBluster and email delivery tracking
Apr 10, 2026
50801fc
Add EmailAddressesController tests for MailBluster sync on email add
Apr 10, 2026
b491938
Add Cucumber feature tests for MailBluster sync UI
Apr 10, 2026
f32a065
Fix Cucumber tests: resolve email fixture collision and button assert…
Apr 10, 2026
8fc59c9
Add additional Cucumber scenarios for MailBluster integration
Apr 10, 2026
6f9bb6f
Add Rake tasks for MailBluster sync management
Apr 10, 2026
91f5071
Improve error handling in MailblusterService#sync_teacher
Apr 10, 2026
5e50b9a
Auto-sync to MailBluster when application status changes
Apr 10, 2026
8e3a2f0
Delete MailBluster lead when destroying a teacher
Apr 10, 2026
ffcb252
Add MailBluster integration documentation to README
Apr 10, 2026
868730e
Re-sync to MailBluster when email addresses are deleted
Apr 10, 2026
683bc4e
Add rate limit delay between bulk MailBluster API calls
Apr 10, 2026
27e0088
Link synced badge to MailBluster profile in teacher table
Apr 10, 2026
1afabde
Add helper specs for MailBluster sync status and email labels
Apr 10, 2026
41e4b42
Update model annotations for email_address_spec
Apr 10, 2026
8ca29ea
Fix RuboCop offenses in MailblusterService
Apr 12, 2026
019481a
Fix test data leak from before(:all) loading seeds
Apr 12, 2026
1d3c266
Expand seed data with diverse teachers and schools
Apr 12, 2026
9677917
Hide MailBluster column on index
Apr 18, 2026
02b1060
Remove MB Sync column in dashboard view
kienthuynh Apr 23, 2026
3c2864e
Edit seed data for future integration
kienthuynh Apr 23, 2026
8785523
update primary and bounced email tags
kienthuynh Apr 23, 2026
5dbaaf0
lint
kienthuynh Apr 23, 2026
6be173b
Add SES bounce count columns and ses_delivery_events table
kienthuynh Apr 29, 2026
bf07ade
Add SesDeliveryEvent model and ses_delivery_events association to Ema…
kienthuynh Apr 29, 2026
76b8b51
Add SnsMessageVerifier service with SNS signature verification and to…
kienthuynh Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ S3_BUCKET_NAME=[NEW ONE]
S3_REGION=us-west-2
S3_SECRET_ACCESS_KEY=[NEW ONE]
SECRET_KEY_BASE=[NEW ONE]
MAILBLUSTER_API_KEY=[NEW ONE]
SNS_ALLOWED_TOPIC_ARNS=arn:aws:sns:us-west-2:123456789012:ses-events
SENTRY_DSN=
SNAP_CLIENT_SECRET='ignore'
SNAP_CLIENT_URL='https://forum.snap.berkeley.edu/session/sso_provider'
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ gem "liquid"
# Store uploaded files
gem "aws-sdk-s3", require: false

# Verify signatures on SNS webhook deliveries
gem "aws-sdk-sns", "~> 1", require: false

# Render images for file uploads in pages
gem "image_processing", ">= 1.2"

Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ GEM
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sdk-sns (1.55.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.4.0)
aws-eventstream (~> 1, >= 1.0.2)
axe-core-api (4.3.2)
Expand Down Expand Up @@ -569,6 +572,7 @@ DEPENDENCIES
activerecord-import
annotate
aws-sdk-s3
aws-sdk-sns (~> 1)
axe-core-cucumber
axe-core-rspec
bootsnap (>= 1.4.4)
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,44 @@ If bundler install runs successfully, continue with the following commands to co
- `heroku config:set ...` for each of the environment variables.
- `heroku open`

## MailBluster Integration

The app integrates with [MailBluster](https://mailbluster.com/) for email marketing and newsletter management.

### Configuration

Set the `MAILBLUSTER_API_KEY` environment variable:

```bash
# Local development
export MAILBLUSTER_API_KEY=your_api_key_here

# Heroku
heroku config:set MAILBLUSTER_API_KEY=your_api_key_here
```

### Features

- **Auto-sync on approval**: When a teacher is validated, their info is synced to MailBluster as a lead
- **Auto-sync on status change**: Updating a teacher's application status triggers a MailBluster sync
- **Auto-sync on email add**: Adding a new email address to a validated teacher triggers sync
- **Manual sync**: Admins can sync individual teachers or all validated teachers from the UI
- **Lead cleanup**: Deleting a teacher removes their lead from MailBluster
- **Delivery tracking**: Email addresses track `emails_sent`, `emails_delivered`, and `bounced` status

### Rake Tasks

```bash
# Sync all validated teachers to MailBluster
bundle exec rake mailbluster:sync_all

# Sync a single teacher by ID
bundle exec rake mailbluster:sync_teacher[123]

# Check sync status
bundle exec rake mailbluster:status
```



### CodeClimate Local Test
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/email_addresses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def create
end

@teacher.email_addresses.create!(email:, primary: false)
# Sync teacher to MailBluster when a new email is added
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured? && @teacher.validated?
redirect_to teacher_path(@teacher), notice: "Personal email addresses added successfully."
rescue ActiveRecord::RecordInvalid => e
error_message = e.record&.errors&.full_messages&.join(", ")
Expand All @@ -28,6 +30,8 @@ def destroy
end

email.destroy!
# Re-sync to MailBluster since email list changed
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured? && @teacher.validated?
redirect_to teacher_path(@teacher), notice: "Email address deleted successfully."
rescue ActiveRecord::RecordNotFound
redirect_to teacher_path(@teacher), alert: "Email address not found."
Expand Down
50 changes: 48 additions & 2 deletions app/controllers/teachers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ class TeachersController < ApplicationController
include CsvProcess

before_action :load_pages, only: [:new, :create, :edit, :update]
before_action :load_teacher, except: [:new, :index, :create, :import, :search]
before_action :load_teacher, except: [:new, :index, :create, :import, :search, :sync_all_mailbluster]
before_action :sanitize_params, only: [:new, :create, :edit, :update]
before_action :require_login, except: [:new, :create]
before_action :require_admin, only: [:validate, :deny, :destroy, :index, :show, :search]
before_action :require_admin, only: [:validate, :deny, :destroy, :index, :show, :search, :sync_mailbluster, :sync_all_mailbluster]
before_action :require_edit_permission, only: [:edit, :update, :resend_welcome_email]

rescue_from ActiveRecord::RecordNotUnique, with: :deny_access
Expand Down Expand Up @@ -131,6 +131,7 @@ def update

attach_new_files_if_any
send_email_if_application_status_changed_and_email_resend_enabled
sync_to_mailbluster_if_status_changed

if fail_to_update
return
Expand Down Expand Up @@ -162,6 +163,19 @@ def send_email_if_application_status_changed_and_email_resend_enabled
end
end

def sync_to_mailbluster_if_status_changed
return unless MailblusterService.configured?
return unless @teacher.application_status_changed?

if @teacher.validated?
MailblusterService.create_or_update_lead(@teacher)
elsif @teacher.application_status_was == "validated"
# If teacher was validated but status changed away, update MailBluster
# to mark them as unsubscribed
MailblusterService.create_or_update_lead(@teacher)
end
end

def request_info
@teacher.info_needed!
if !params[:skip_email].present?
Expand All @@ -173,6 +187,7 @@ def request_info
def validate
@teacher.validated!
TeacherMailer.welcome_email(@teacher).deliver_now
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured?
redirect_to root_path
end

Expand All @@ -185,6 +200,9 @@ def deny
end

def destroy
if MailblusterService.configured? && @teacher.primary_email.present?
MailblusterService.delete_lead(@teacher.primary_email)
end
@teacher.destroy!
flash[:info] = "Deleted #{@teacher.full_name} successfully."
redirect_to teachers_path
Expand All @@ -208,6 +226,34 @@ def import
redirect_to teachers_path
end

def sync_mailbluster
unless MailblusterService.configured?
redirect_to teacher_path(@teacher), alert: "MailBluster API key is not configured."
return
end

result = MailblusterService.sync_teacher(@teacher)
if result[:success]
redirect_to teacher_path(@teacher), notice: "Successfully synced #{@teacher.full_name} to MailBluster."
else
redirect_to teacher_path(@teacher), alert: "Failed to sync #{@teacher.full_name} to MailBluster. #{result[:error]}"
end
end

def sync_all_mailbluster
unless MailblusterService.configured?
redirect_to teachers_path, alert: "MailBluster API key is not configured."
return
end

results = MailblusterService.sync_all_teachers
flash[:notice] = "MailBluster sync complete: #{results[:synced]} synced, #{results[:failed]} failed, #{results[:skipped]} skipped."
if results[:errors].any?
flash[:alert] = "Errors: #{results[:errors].first(5).join('; ')}"
end
redirect_to teachers_path
end

private
def load_teacher
@teachers = Teacher.all
Expand Down
20 changes: 18 additions & 2 deletions app/helpers/teacher_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,23 @@ def ip_history_display(teacher)
end

def email_address_label(email)
return unless email.primary?
'&nbsp; <span class="badge badge-pill badge-primary h6">primary</span>'.html_safe
labels = []
labels << '<span class="primary-email-dot" data-toggle="tooltip" data-placement="top" title="Primary email"></span>' if email.primary?
labels << '<span class="bounced-email-dot" data-toggle="tooltip" data-placement="top" title="Bounced email"></span>' if email.bounced?
return nil if labels.empty?
labels.join("").html_safe
end

def mailbluster_sync_status(teacher)
if teacher.mailbluster_synced?
url = teacher.mailbluster_profile_url
if url
link_to('<span class="badge badge-success">Synced</span>'.html_safe, url, target: "_blank", title: "View in MailBluster")
else
'<span class="badge badge-success">Synced</span>'.html_safe
end
else
'<span class="badge badge-secondary">Not Synced</span>'.html_safe
end
end
end
19 changes: 19 additions & 0 deletions app/javascript/styles/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ footer a:active {
}
}

.primary-email-dot,
.bounced-email-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
vertical-align: middle;
margin-right: 4px;
cursor: default;
}

.primary-email-dot {
background-color: #007bff;
}

.bounced-email-dot {
background-color: #dc3545;
}

.trapezoid {
position: absolute;
bottom: -11px;
Expand Down
32 changes: 26 additions & 6 deletions app/models/email_address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
#
# Table name: email_addresses
#
# id :bigint not null, primary key
# email :string not null
# primary :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# teacher_id :bigint not null
# id :bigint not null, primary key
# bounced :boolean default(FALSE), not null
# email :string not null
# emails_delivered :integer default(0), not null
# emails_sent :integer default(0), not null
# hard_bounce_count :integer default(0), not null
# last_ses_event_at :datetime
# primary :boolean default(FALSE), not null
# soft_bounce_count :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# teacher_id :bigint not null
#
# Indexes
#
Expand All @@ -23,6 +29,7 @@
#
class EmailAddress < ApplicationRecord
belongs_to :teacher
has_many :ses_delivery_events, dependent: :destroy

# Rail's bulit-in validation for email format regex
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
Expand All @@ -31,6 +38,19 @@ class EmailAddress < ApplicationRecord
before_save :normalize_email
before_save :flag_teacher_if_email_changed

scope :bounced, -> { where(bounced: true) }
scope :with_undelivered, -> { where("emails_sent > emails_delivered") }

# Number of emails that were sent but not delivered.
def undelivered_count
[emails_sent - emails_delivered, 0].max
end

# Whether this email has any undelivered emails.
def has_undelivered?
undelivered_count > 0
end

private
def only_one_primary_email_per_teacher
if primary? && EmailAddress.where(teacher_id:, primary: true).where.not(id:).exists?
Expand Down
15 changes: 15 additions & 0 deletions app/models/ses_delivery_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

class SesDeliveryEvent < ApplicationRecord
EVENT_TYPES = %w[Send Delivery Bounce Complaint].freeze

belongs_to :email_address, optional: true

validates :sns_message_id, presence: true
validates :event_type, presence: true
validates :recipient_email, presence: true
validates :event_occurred_at, presence: true

scope :hard_bounces, -> { where("event_type = 'Complaint' OR (event_type = 'Bounce' AND bounce_type <> 'Transient')") }
scope :soft_bounces, -> { where(event_type: "Bounce", bounce_type: "Transient") }
end
Loading
Loading