Skip to content

Commit fc17328

Browse files
authored
Merge pull request #101 from cs169/feature/mailbluster
Feature/mailbluster
2 parents 9e66c9e + 3c2864e commit fc17328

33 files changed

Lines changed: 1747 additions & 122 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ S3_BUCKET_NAME=[NEW ONE]
2020
S3_REGION=us-west-2
2121
S3_SECRET_ACCESS_KEY=[NEW ONE]
2222
SECRET_KEY_BASE=[NEW ONE]
23+
MAILBLUSTER_API_KEY=[NEW ONE]
2324
SENTRY_DSN=
2425
SNAP_CLIENT_SECRET='ignore'
2526
SNAP_CLIENT_URL='https://forum.snap.berkeley.edu/session/sso_provider'

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,44 @@ If bundler install runs successfully, continue with the following commands to co
159159
- `heroku config:set ...` for each of the environment variables.
160160
- `heroku open`
161161
162+
## MailBluster Integration
163+
164+
The app integrates with [MailBluster](https://mailbluster.com/) for email marketing and newsletter management.
165+
166+
### Configuration
167+
168+
Set the `MAILBLUSTER_API_KEY` environment variable:
169+
170+
```bash
171+
# Local development
172+
export MAILBLUSTER_API_KEY=your_api_key_here
173+
174+
# Heroku
175+
heroku config:set MAILBLUSTER_API_KEY=your_api_key_here
176+
```
177+
178+
### Features
179+
180+
- **Auto-sync on approval**: When a teacher is validated, their info is synced to MailBluster as a lead
181+
- **Auto-sync on status change**: Updating a teacher's application status triggers a MailBluster sync
182+
- **Auto-sync on email add**: Adding a new email address to a validated teacher triggers sync
183+
- **Manual sync**: Admins can sync individual teachers or all validated teachers from the UI
184+
- **Lead cleanup**: Deleting a teacher removes their lead from MailBluster
185+
- **Delivery tracking**: Email addresses track `emails_sent`, `emails_delivered`, and `bounced` status
186+
187+
### Rake Tasks
188+
189+
```bash
190+
# Sync all validated teachers to MailBluster
191+
bundle exec rake mailbluster:sync_all
192+
193+
# Sync a single teacher by ID
194+
bundle exec rake mailbluster:sync_teacher[123]
195+
196+
# Check sync status
197+
bundle exec rake mailbluster:status
198+
```
199+
162200

163201

164202
### CodeClimate Local Test

app/controllers/email_addresses_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ def create
1313
end
1414

1515
@teacher.email_addresses.create!(email:, primary: false)
16+
# Sync teacher to MailBluster when a new email is added
17+
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured? && @teacher.validated?
1618
redirect_to teacher_path(@teacher), notice: "Personal email addresses added successfully."
1719
rescue ActiveRecord::RecordInvalid => e
1820
error_message = e.record&.errors&.full_messages&.join(", ")
@@ -33,6 +35,8 @@ def destroy
3335
end
3436

3537
email.destroy!
38+
# Re-sync to MailBluster since email list changed
39+
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured? && @teacher.validated?
3640
redirect_to teacher_path(@teacher), notice: "Email address deleted successfully."
3741
rescue ActiveRecord::RecordNotFound
3842
redirect_to teacher_path(@teacher), alert: "Email address not found."

app/controllers/teachers_controller.rb

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ class TeachersController < ApplicationController
1010
include CsvProcess
1111

1212
before_action :load_pages, only: [:new, :create, :edit, :update]
13-
before_action :load_teacher, except: [:new, :index, :create, :import, :search]
13+
before_action :load_teacher, except: [:new, :index, :create, :import, :search, :sync_all_mailbluster]
1414
before_action :sanitize_params, only: [:new, :create, :edit, :update]
1515
before_action :require_login, except: [:new, :create]
16-
before_action :require_admin, only: [:validate, :deny, :destroy, :index, :show, :search]
16+
before_action :require_admin, only: [:validate, :deny, :destroy, :index, :show, :search, :sync_mailbluster, :sync_all_mailbluster]
1717
before_action :require_edit_permission, only: [:edit, :update, :resend_welcome_email]
1818

1919
rescue_from ActiveRecord::RecordNotUnique, with: :deny_access
@@ -131,6 +131,7 @@ def update
131131

132132
attach_new_files_if_any
133133
send_email_if_application_status_changed_and_email_resend_enabled
134+
sync_to_mailbluster_if_status_changed
134135

135136
if fail_to_update
136137
return
@@ -162,6 +163,19 @@ def send_email_if_application_status_changed_and_email_resend_enabled
162163
end
163164
end
164165

166+
def sync_to_mailbluster_if_status_changed
167+
return unless MailblusterService.configured?
168+
return unless @teacher.application_status_changed?
169+
170+
if @teacher.validated?
171+
MailblusterService.create_or_update_lead(@teacher)
172+
elsif @teacher.application_status_was == "validated"
173+
# If teacher was validated but status changed away, update MailBluster
174+
# to mark them as unsubscribed
175+
MailblusterService.create_or_update_lead(@teacher)
176+
end
177+
end
178+
165179
def request_info
166180
@teacher.info_needed!
167181
if !params[:skip_email].present?
@@ -173,6 +187,7 @@ def request_info
173187
def validate
174188
@teacher.validated!
175189
TeacherMailer.welcome_email(@teacher).deliver_now
190+
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured?
176191
redirect_to root_path
177192
end
178193

@@ -185,6 +200,9 @@ def deny
185200
end
186201

187202
def destroy
203+
if MailblusterService.configured? && @teacher.primary_email.present?
204+
MailblusterService.delete_lead(@teacher.primary_email)
205+
end
188206
@teacher.destroy!
189207
flash[:info] = "Deleted #{@teacher.full_name} successfully."
190208
redirect_to teachers_path
@@ -208,6 +226,34 @@ def import
208226
redirect_to teachers_path
209227
end
210228

229+
def sync_mailbluster
230+
unless MailblusterService.configured?
231+
redirect_to teacher_path(@teacher), alert: "MailBluster API key is not configured."
232+
return
233+
end
234+
235+
result = MailblusterService.sync_teacher(@teacher)
236+
if result[:success]
237+
redirect_to teacher_path(@teacher), notice: "Successfully synced #{@teacher.full_name} to MailBluster."
238+
else
239+
redirect_to teacher_path(@teacher), alert: "Failed to sync #{@teacher.full_name} to MailBluster. #{result[:error]}"
240+
end
241+
end
242+
243+
def sync_all_mailbluster
244+
unless MailblusterService.configured?
245+
redirect_to teachers_path, alert: "MailBluster API key is not configured."
246+
return
247+
end
248+
249+
results = MailblusterService.sync_all_teachers
250+
flash[:notice] = "MailBluster sync complete: #{results[:synced]} synced, #{results[:failed]} failed, #{results[:skipped]} skipped."
251+
if results[:errors].any?
252+
flash[:alert] = "Errors: #{results[:errors].first(5).join('; ')}"
253+
end
254+
redirect_to teachers_path
255+
end
256+
211257
private
212258
def load_teacher
213259
@teachers = Teacher.all

app/helpers/teacher_helper.rb

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,23 @@ def ip_history_display(teacher)
1313
end
1414

1515
def email_address_label(email)
16-
return unless email.primary?
17-
'&nbsp; <span class="badge badge-pill badge-primary h6">primary</span>'.html_safe
16+
labels = []
17+
labels << '<span class="badge badge-pill badge-primary h6">primary</span>' if email.primary?
18+
labels << '<span class="badge badge-pill badge-danger h6">bounced</span>' if email.bounced?
19+
return nil if labels.empty?
20+
"&nbsp; #{labels.join(' ')}".html_safe
21+
end
22+
23+
def mailbluster_sync_status(teacher)
24+
if teacher.mailbluster_synced?
25+
url = teacher.mailbluster_profile_url
26+
if url
27+
link_to('<span class="badge badge-success">Synced</span>'.html_safe, url, target: "_blank", title: "View in MailBluster")
28+
else
29+
'<span class="badge badge-success">Synced</span>'.html_safe
30+
end
31+
else
32+
'<span class="badge badge-secondary">Not Synced</span>'.html_safe
33+
end
1834
end
1935
end

app/models/email_address.rb

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
#
55
# Table name: email_addresses
66
#
7-
# id :bigint not null, primary key
8-
# email :string not null
9-
# primary :boolean default(FALSE), not null
10-
# created_at :datetime not null
11-
# updated_at :datetime not null
12-
# teacher_id :bigint not null
7+
# id :bigint not null, primary key
8+
# bounced :boolean default(FALSE), not null
9+
# email :string not null
10+
# emails_delivered :integer default(0), not null
11+
# emails_sent :integer default(0), not null
12+
# primary :boolean default(FALSE), not null
13+
# created_at :datetime not null
14+
# updated_at :datetime not null
15+
# teacher_id :bigint not null
1316
#
1417
# Indexes
1518
#
@@ -31,6 +34,19 @@ class EmailAddress < ApplicationRecord
3134
before_save :normalize_email
3235
before_save :flag_teacher_if_email_changed
3336

37+
scope :bounced, -> { where(bounced: true) }
38+
scope :with_undelivered, -> { where("emails_sent > emails_delivered") }
39+
40+
# Number of emails that were sent but not delivered.
41+
def undelivered_count
42+
[emails_sent - emails_delivered, 0].max
43+
end
44+
45+
# Whether this email has any undelivered emails.
46+
def has_undelivered?
47+
undelivered_count > 0
48+
end
49+
3450
private
3551
def only_one_primary_email_per_teacher
3652
if primary? && EmailAddress.where(teacher_id:, primary: true).where.not(id:).exists?

app/models/teacher.rb

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,35 @@
44
#
55
# Table name: teachers
66
#
7-
# id :integer not null, primary key
8-
# admin :boolean default(FALSE)
9-
# application_status :string default("not_reviewed")
10-
# education_level :integer default(NULL)
11-
# email :string
12-
# first_name :string
13-
# ip_history :inet default([]), is an Array
14-
# languages :string default(["\"English\""]), is an Array
15-
# last_name :string
16-
# last_session_at :datetime
17-
# more_info :string
18-
# personal_email :string
19-
# personal_website :string
20-
# session_count :integer default(0)
21-
# snap :string
22-
# status :integer
23-
# created_at :datetime
24-
# updated_at :datetime
25-
# school_id :integer
7+
# id :integer not null, primary key
8+
# admin :boolean default(FALSE)
9+
# application_status :string default("not_reviewed")
10+
# education_level :integer default(NULL)
11+
# email :string
12+
# first_name :string
13+
# ip_history :inet default([]), is an Array
14+
# languages :string default(["\"English\""]), is an Array
15+
# last_name :string
16+
# last_session_at :datetime
17+
# mailbluster_synced_at :datetime
18+
# more_info :string
19+
# personal_email :string
20+
# personal_website :string
21+
# session_count :integer default(0)
22+
# snap :string
23+
# status :integer
24+
# verification_notes :text
25+
# created_at :datetime
26+
# updated_at :datetime
27+
# mailbluster_id :integer
28+
# school_id :integer
2629
#
2730
# Indexes
2831
#
2932
# index_teachers_on_email (email) UNIQUE
3033
# index_teachers_on_email_and_first_name (email,first_name)
3134
# index_teachers_on_email_and_personal_email (email,personal_email) UNIQUE
35+
# index_teachers_on_mailbluster_id (mailbluster_id) UNIQUE
3236
# index_teachers_on_school_id (school_id)
3337
# index_teachers_on_snap (snap) UNIQUE WHERE ((snap)::text <> ''::text)
3438
# index_teachers_on_status (status)
@@ -311,17 +315,46 @@ def self.csv_export
311315
school_website
312316
school_grade_level
313317
school_type
318+
mailbluster_id
319+
primary_email_sent
320+
primary_email_delivered
321+
primary_email_bounced
314322
|
315323

316324
CSV.generate(headers: true) do |csv|
317325
csv << attributes
318326

319-
Teacher.where(admin: false).find_each do |user|
327+
Teacher.where(admin: false).includes(:email_addresses).find_each do |user|
320328
csv << attributes.map { |attr| user.send(attr) }
321329
end
322330
end
323331
end
324332

333+
def mailbluster_synced?
334+
mailbluster_id.present?
335+
end
336+
337+
def mailbluster_profile_url
338+
return nil unless mailbluster_id.present?
339+
"https://app.mailbluster.com/leads/#{mailbluster_id}"
340+
end
341+
342+
def primary_email_address
343+
email_addresses.find_by(primary: true)
344+
end
345+
346+
def primary_email_sent
347+
primary_email_address&.emails_sent || 0
348+
end
349+
350+
def primary_email_delivered
351+
primary_email_address&.emails_delivered || 0
352+
end
353+
354+
def primary_email_bounced
355+
primary_email_address&.bounced? ? "Yes" : "No"
356+
end
357+
325358
private
326359
def non_primary_emails
327360
email_addresses.where(primary: false)&.pluck(:email)

0 commit comments

Comments
 (0)