Fixes by Claude
This commit is contained in:
parent
eca5238abd
commit
107531385b
8 changed files with 413 additions and 69 deletions
|
|
@ -45,6 +45,7 @@
|
|||
//= require selectize
|
||||
//= require bootstrap-select
|
||||
//= require osem-survey
|
||||
//= require osem-bulk-email
|
||||
|
||||
$(document).ready(function() {
|
||||
$('a[disabled=disabled]').click(function(event){
|
||||
|
|
|
|||
143
app/assets/javascripts/osem-bulk-email.js
Normal file
143
app/assets/javascripts/osem-bulk-email.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
$(document).ready(function() {
|
||||
// Bulk Email Recipient Management
|
||||
|
||||
// Select All button
|
||||
$('#select-all-btn').click(function() {
|
||||
$('.recipient-checkbox').prop('checked', true);
|
||||
updateRecipientCount();
|
||||
});
|
||||
|
||||
// Deselect All button
|
||||
$('#deselect-all-btn').click(function() {
|
||||
$('.recipient-checkbox').prop('checked', false);
|
||||
updateRecipientCount();
|
||||
});
|
||||
|
||||
// Remove Selected button
|
||||
$('#remove-selected-btn').click(function() {
|
||||
$('.recipient-checkbox:checked').each(function() {
|
||||
$(this).closest('.recipient-item').slideUp(300, function() {
|
||||
$(this).remove();
|
||||
updateRecipientCount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Update count when individual checkboxes are changed
|
||||
$(document).on('change', '.recipient-checkbox', function() {
|
||||
updateRecipientCount();
|
||||
});
|
||||
|
||||
// Update recipient count display
|
||||
function updateRecipientCount() {
|
||||
var checkedCount = $('.recipient-checkbox:checked').length;
|
||||
var totalCount = $('.recipient-checkbox').length;
|
||||
$('#recipient-count').text(totalCount);
|
||||
|
||||
// Update the next button state
|
||||
if (checkedCount === 0) {
|
||||
$('#next-compose-btn').prop('disabled', true).text('Select Recipients First');
|
||||
} else {
|
||||
$('#next-compose-btn').prop('disabled', false).text('Next: Compose Email (' + checkedCount + ' selected)');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize count on page load
|
||||
if ($('.recipient-checkbox').length > 0) {
|
||||
updateRecipientCount();
|
||||
}
|
||||
|
||||
// Form validation for recipients step
|
||||
$('#recipients-form').submit(function(e) {
|
||||
var checkedCount = $('.recipient-checkbox:checked').length;
|
||||
if (checkedCount === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please select at least one recipient before proceeding.');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Dynamic recipient filtering (if needed for future enhancement)
|
||||
$('#filter-recipients-input').on('keyup', function() {
|
||||
var value = $(this).val().toLowerCase();
|
||||
$('.recipient-item').filter(function() {
|
||||
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1);
|
||||
});
|
||||
});
|
||||
|
||||
// Preview email functionality
|
||||
$('#preview-email-btn').click(function() {
|
||||
var subject = $('#subject').val();
|
||||
var body = $('#body').val();
|
||||
|
||||
if (subject.trim() === '' || body.trim() === '') {
|
||||
alert('Please enter both subject and body before previewing.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview modal (assuming Bootstrap modal)
|
||||
var modalHtml = '<div class="modal fade" id="email-preview-modal" tabindex="-1">' +
|
||||
'<div class="modal-dialog modal-lg">' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-header">' +
|
||||
'<button type="button" class="close" data-dismiss="modal">×</button>' +
|
||||
'<h4 class="modal-title">Email Preview</h4>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<p><strong>Subject:</strong> ' + subject + '</p>' +
|
||||
'<hr>' +
|
||||
'<div style="white-space: pre-wrap;">' + body + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$('body').append(modalHtml);
|
||||
$('#email-preview-modal').modal('show').on('hidden.bs.modal', function() {
|
||||
$(this).remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Character counter for email body
|
||||
$('#body').on('input', function() {
|
||||
var length = $(this).val().length;
|
||||
var counter = $('#char-counter');
|
||||
if (counter.length === 0) {
|
||||
$(this).after('<small id="char-counter" class="text-muted">Characters: 0</small>');
|
||||
counter = $('#char-counter');
|
||||
}
|
||||
counter.text('Characters: ' + length);
|
||||
});
|
||||
|
||||
// Auto-save draft functionality (localStorage)
|
||||
var draftKey = 'bulk-email-draft-' + window.location.pathname;
|
||||
|
||||
// Load draft on page load
|
||||
if (localStorage.getItem(draftKey)) {
|
||||
var draft = JSON.parse(localStorage.getItem(draftKey));
|
||||
if (draft.subject) $('#subject').val(draft.subject);
|
||||
if (draft.body) $('#body').val(draft.body);
|
||||
|
||||
if (draft.subject || draft.body) {
|
||||
$('<div class="alert alert-info">').text('Draft restored from previous session.').insertBefore('form');
|
||||
}
|
||||
}
|
||||
|
||||
// Save draft as user types
|
||||
$('#subject, #body').on('input', function() {
|
||||
var draft = {
|
||||
subject: $('#subject').val(),
|
||||
body: $('#body').val(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
});
|
||||
|
||||
// Clear draft on successful send
|
||||
$('form[action*="send_bulk"]').submit(function() {
|
||||
localStorage.removeItem(draftKey);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,8 +24,38 @@ module Admin
|
|||
|
||||
def bulk
|
||||
authorize! :index, @conference.email_settings
|
||||
@registrations = @conference.registrations.includes(:user, :qanswers)
|
||||
@questions = @conference.questions.includes(:qanswers)
|
||||
@step = params[:step] || 'filter'
|
||||
|
||||
case @step
|
||||
when 'filter'
|
||||
@registrations = @conference.registrations.includes(:user, :qanswers)
|
||||
@questions = @conference.questions.includes(:qanswers)
|
||||
when 'recipients'
|
||||
@selected_recipients = filtered_recipients(params[:filter_type], params[:search_term])
|
||||
@filter_type = params[:filter_type]
|
||||
@search_term = params[:search_term]
|
||||
when 'compose'
|
||||
@recipient_emails = params[:recipient_emails] || []
|
||||
@filter_type = params[:filter_type]
|
||||
@search_term = params[:search_term]
|
||||
end
|
||||
end
|
||||
|
||||
def recipients
|
||||
authorize! :index, @conference.email_settings
|
||||
|
||||
recipients = filtered_recipients(params[:filter_type], params[:search_term])
|
||||
|
||||
render json: {
|
||||
recipients: recipients.map do |user|
|
||||
{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email
|
||||
}
|
||||
end,
|
||||
count: recipients.count
|
||||
}
|
||||
end
|
||||
|
||||
def send_bulk
|
||||
|
|
@ -33,47 +63,91 @@ module Admin
|
|||
|
||||
subject = params[:subject]
|
||||
body = params[:body]
|
||||
filter_type = params[:filter_type]
|
||||
recipient_emails = params[:recipient_emails] || []
|
||||
|
||||
if subject.blank? || body.blank?
|
||||
redirect_to bulk_admin_conference_emails_path(@conference.short_title), alert: 'Subject and body are required.'
|
||||
redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'compose'),
|
||||
alert: 'Subject and body are required.'
|
||||
return
|
||||
end
|
||||
|
||||
recipients = get_filtered_recipients(filter_type)
|
||||
|
||||
if recipients.empty?
|
||||
redirect_to bulk_admin_conference_emails_path(@conference.short_title), alert: 'No recipients found with the selected filter.'
|
||||
if recipient_emails.empty?
|
||||
redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'recipients'),
|
||||
alert: 'No recipients selected.'
|
||||
return
|
||||
end
|
||||
|
||||
recipients = User.where(email: recipient_emails)
|
||||
|
||||
recipients.each do |user|
|
||||
Mailbot.bulk_mail(@conference, user, subject, body).deliver_later
|
||||
end
|
||||
|
||||
redirect_to bulk_admin_conference_emails_path(@conference.short_title),
|
||||
redirect_to admin_conference_emails_path(@conference.short_title),
|
||||
notice: "Bulk email sent to #{recipients.count} recipients."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_filtered_recipients(filter_type)
|
||||
def filtered_recipients(filter_type, search_term = nil)
|
||||
base_users = get_base_user_set(filter_type)
|
||||
apply_search_filter(base_users, search_term)
|
||||
end
|
||||
|
||||
def get_base_user_set(filter_type)
|
||||
registrations = @conference.registrations.includes(:user, :qanswers)
|
||||
|
||||
case filter_type
|
||||
when 'no_questions_answered'
|
||||
registrations.select { |r| r.qanswers.empty? }.map(&:user)
|
||||
users_with_no_answers(registrations)
|
||||
when 'some_questions_answered'
|
||||
registrations.select { |r| r.qanswers.any? && r.qanswers.count < @conference.questions.count }.map(&:user)
|
||||
users_with_some_answers(registrations)
|
||||
when 'all_questions_answered'
|
||||
registrations.select { |r| r.qanswers.count >= @conference.questions.count }.map(&:user)
|
||||
users_with_all_answers(registrations)
|
||||
when 'all_registered'
|
||||
registrations.map(&:user)
|
||||
when 'not_registered'
|
||||
User.where.not(id: registrations.map(&:user_id))
|
||||
when 'all_users'
|
||||
User.all
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def users_with_no_answers(registrations)
|
||||
registrations.select { |r| r.qanswers.empty? }.map(&:user)
|
||||
end
|
||||
|
||||
def users_with_some_answers(registrations)
|
||||
total_questions = @conference.questions.count
|
||||
registrations.select { |r| r.qanswers.any? && r.qanswers.count < total_questions }.map(&:user)
|
||||
end
|
||||
|
||||
def users_with_all_answers(registrations)
|
||||
total_questions = @conference.questions.count
|
||||
registrations.select { |r| r.qanswers.count >= total_questions }.map(&:user)
|
||||
end
|
||||
|
||||
def apply_search_filter(base_users, search_term)
|
||||
return base_users if search_term.blank?
|
||||
|
||||
if base_users.respond_to?(:where)
|
||||
base_users.where('name ILIKE ? OR email ILIKE ?', "%#{search_term}%", "%#{search_term}%")
|
||||
else
|
||||
search_in_memory(base_users, search_term)
|
||||
end
|
||||
end
|
||||
|
||||
def search_in_memory(users, search_term)
|
||||
lower_term = search_term.downcase
|
||||
users.select { |u| user_matches_search?(u, lower_term) }
|
||||
end
|
||||
|
||||
def user_matches_search?(user, search_term)
|
||||
user.name&.downcase&.include?(search_term) || user.email&.downcase&.include?(search_term)
|
||||
end
|
||||
|
||||
def bulk_email_params
|
||||
params.permit(:subject, :body, :filter_type)
|
||||
end
|
||||
|
|
|
|||
64
app/views/admin/emails/_bulk_compose.html.haml
Normal file
64
app/views/admin/emails/_bulk_compose.html.haml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
.row
|
||||
.col-md-8
|
||||
%h3 Step 3: Compose Email
|
||||
|
||||
= form_tag send_bulk_admin_conference_emails_path(@conference.short_title), method: :post, class: 'form-horizontal' do
|
||||
- @recipient_emails.each do |email|
|
||||
= hidden_field_tag 'recipient_emails[]', email
|
||||
|
||||
.form-group
|
||||
= label_tag :subject, 'Subject', class: 'col-sm-2 control-label'
|
||||
.col-sm-10
|
||||
= text_field_tag :subject, params[:subject], class: 'form-control', required: true, placeholder: 'Enter email subject'
|
||||
|
||||
.form-group
|
||||
= label_tag :body, 'Message Body', class: 'col-sm-2 control-label'
|
||||
.col-sm-10
|
||||
= text_area_tag :body, params[:body], rows: 15, class: 'form-control', required: true, placeholder: 'Enter your email message here...'
|
||||
%p.help-block
|
||||
You can use the following variables in your email:
|
||||
%code {name}
|
||||
,
|
||||
%code {conference}
|
||||
,
|
||||
%code {registrationlink}
|
||||
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
= submit_tag 'Send Bulk Email', class: 'btn btn-success btn-lg', data: { confirm: "Are you sure you want to send this email to #{@recipient_emails.count} recipients?" }
|
||||
= link_to 'Back to Recipients', bulk_admin_conference_emails_path(@conference.short_title, step: 'recipients', filter_type: params[:filter_type], search_term: params[:search_term]), class: 'btn btn-default'
|
||||
= link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default'
|
||||
|
||||
.col-md-4
|
||||
.panel.panel-success
|
||||
.panel-heading
|
||||
%h4 Email Summary
|
||||
.panel-body
|
||||
%p
|
||||
%strong Recipients:
|
||||
= @recipient_emails.count
|
||||
selected
|
||||
|
||||
- if @recipient_emails.any?
|
||||
%h5 Recipient List:
|
||||
.recipient-list{ style: 'max-height: 200px; overflow-y: auto;' }
|
||||
- @recipient_emails.each do |email|
|
||||
.small.text-muted= email
|
||||
- else
|
||||
%p.text-warning No recipients selected. Please go back to select recipients.
|
||||
|
||||
.panel.panel-info
|
||||
.panel-heading
|
||||
%h4 Email Variables
|
||||
.panel-body
|
||||
%p Available template variables:
|
||||
%ul.small
|
||||
%li
|
||||
%code {name}
|
||||
- User's full name
|
||||
%li
|
||||
%code {conference}
|
||||
- Conference name
|
||||
%li
|
||||
%code {registrationlink}
|
||||
- Link to conference registration
|
||||
56
app/views/admin/emails/_bulk_filter.html.haml
Normal file
56
app/views/admin/emails/_bulk_filter.html.haml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
.row
|
||||
.col-md-8
|
||||
%h3 Step 1: Filter Recipients
|
||||
|
||||
= form_tag bulk_admin_conference_emails_path(@conference.short_title), method: :get, class: 'form-horizontal', id: 'filter-form' do
|
||||
= hidden_field_tag :step, 'recipients'
|
||||
|
||||
.form-group
|
||||
= label_tag :filter_type, 'Filter Type', class: 'col-sm-3 control-label'
|
||||
.col-sm-9
|
||||
= select_tag :filter_type, options_for_select([
|
||||
['All users in the system', 'all_users'],
|
||||
['All registered users', 'all_registered'],
|
||||
['Users not registered for this conference', 'not_registered'],
|
||||
['Users who haven\'t answered any registration questions', 'no_questions_answered'],
|
||||
['Users who answered some but not all questions', 'some_questions_answered'],
|
||||
['Users who answered all registration questions', 'all_questions_answered']
|
||||
], params[:filter_type]), { class: 'form-control', id: 'filter-type-select' }
|
||||
|
||||
.form-group
|
||||
= label_tag :search_term, 'Search in Name/Email', class: 'col-sm-3 control-label'
|
||||
.col-sm-9
|
||||
= text_field_tag :search_term, params[:search_term], class: 'form-control', placeholder: 'Enter keyword to search in names or email addresses'
|
||||
%p.help-block Leave empty to include all users matching the filter type above.
|
||||
|
||||
.form-group
|
||||
.col-sm-offset-3.col-sm-9
|
||||
= submit_tag 'Next: Select Recipients', class: 'btn btn-primary', id: 'next-recipients-btn'
|
||||
= link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default'
|
||||
|
||||
.col-md-4
|
||||
.panel.panel-info
|
||||
.panel-heading
|
||||
%h4 Filter Information
|
||||
.panel-body
|
||||
%p
|
||||
%strong Total registered users:
|
||||
= @conference.registrations.count
|
||||
- if @questions.any?
|
||||
%p
|
||||
%strong Registration questions configured:
|
||||
= @questions.count
|
||||
%hr
|
||||
%h5 Current distribution:
|
||||
%p
|
||||
%strong No questions answered:
|
||||
= @registrations.select { |r| r.qanswers.empty? }.count
|
||||
%p
|
||||
%strong Some questions answered:
|
||||
= @registrations.select { |r| r.qanswers.any? && r.qanswers.count < @questions.count }.count
|
||||
%p
|
||||
%strong All questions answered:
|
||||
= @registrations.select { |r| r.qanswers.count >= @questions.count }.count
|
||||
- else
|
||||
%p
|
||||
%em No registration questions configured for this conference.
|
||||
44
app/views/admin/emails/_bulk_recipients.html.haml
Normal file
44
app/views/admin/emails/_bulk_recipients.html.haml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.row
|
||||
.col-md-12
|
||||
%h3 Step 2: Select Recipients
|
||||
%p
|
||||
Found
|
||||
%strong= @selected_recipients.count
|
||||
recipients matching your filter criteria. Select the recipients you want to email:
|
||||
|
||||
= form_tag bulk_admin_conference_emails_path(@conference.short_title), method: :get, id: 'recipients-form' do
|
||||
= hidden_field_tag :step, 'compose'
|
||||
= hidden_field_tag :filter_type, @filter_type
|
||||
= hidden_field_tag :search_term, @search_term
|
||||
|
||||
.row
|
||||
.col-md-12
|
||||
.form-group
|
||||
.btn-toolbar{ style: 'margin-bottom: 15px;' }
|
||||
= button_tag 'Select All', type: 'button', class: 'btn btn-sm btn-default', id: 'select-all-btn'
|
||||
= button_tag 'Deselect All', type: 'button', class: 'btn btn-sm btn-default', id: 'deselect-all-btn'
|
||||
= button_tag 'Remove Selected', type: 'button', class: 'btn btn-sm btn-danger', id: 'remove-selected-btn'
|
||||
|
||||
.panel.panel-default
|
||||
.panel-heading
|
||||
%h4
|
||||
Recipients
|
||||
%span.badge#recipient-count= @selected_recipients.count
|
||||
.panel-body
|
||||
- if @selected_recipients.any?
|
||||
#recipients-list
|
||||
- @selected_recipients.each do |user|
|
||||
.recipient-item{ 'data-email' => user.email }
|
||||
.checkbox
|
||||
%label
|
||||
= check_box_tag 'recipient_emails[]', user.email, true, class: 'recipient-checkbox'
|
||||
%strong= user.name.present? ? user.name : 'No Name'
|
||||
%br
|
||||
%small.text-muted= user.email
|
||||
- else
|
||||
%p.text-muted No recipients found with the current filter criteria.
|
||||
|
||||
.form-group
|
||||
= submit_tag 'Next: Compose Email', class: 'btn btn-primary', id: 'next-compose-btn'
|
||||
= link_to 'Back to Filter', bulk_admin_conference_emails_path(@conference.short_title, step: 'filter', filter_type: @filter_type, search_term: @search_term), class: 'btn btn-default'
|
||||
= link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default'
|
||||
|
|
@ -1,61 +1,22 @@
|
|||
.row
|
||||
.col-md-8
|
||||
.col-md-12
|
||||
%h2 Bulk Email
|
||||
%p Send customized emails to registered users based on their registration status.
|
||||
%p Send customized emails to users with advanced filtering and recipient selection.
|
||||
|
||||
= form_tag send_bulk_admin_conference_emails_path(@conference.short_title), method: :post, class: 'form-horizontal' do
|
||||
.form-group
|
||||
= label_tag :subject, 'Subject', class: 'col-sm-2 control-label'
|
||||
.col-sm-10
|
||||
= text_field_tag :subject, params[:subject], class: 'form-control', required: true
|
||||
.nav.nav-pills{ role: 'navigation' }
|
||||
%li{ class: (@step == 'filter' ? 'active' : ''), id: 'step-filter' }
|
||||
%a{ href: '#' } 1. Filter Recipients
|
||||
%li{ class: (@step == 'recipients' ? 'active' : ''), id: 'step-recipients' }
|
||||
%a{ href: '#' } 2. Select Recipients
|
||||
%li{ class: (@step == 'compose' ? 'active' : ''), id: 'step-compose' }
|
||||
%a{ href: '#' } 3. Compose Email
|
||||
|
||||
.form-group
|
||||
= label_tag :body, 'Body', class: 'col-sm-2 control-label'
|
||||
.col-sm-10
|
||||
= text_area_tag :body, params[:body], rows: 10, class: 'form-control', required: true
|
||||
%p.help-block You can use the following variables in your email: {name}, {conference}, {registrationlink}
|
||||
%hr
|
||||
|
||||
.form-group
|
||||
= label_tag :filter_type, 'Send to', class: 'col-sm-2 control-label'
|
||||
.col-sm-10
|
||||
= select_tag :filter_type, options_for_select([
|
||||
['All registered users', 'all_registered'],
|
||||
['Users who haven\'t answered any questions', 'no_questions_answered'],
|
||||
['Users who answered some but not all questions', 'some_questions_answered'],
|
||||
['Users who answered all questions', 'all_questions_answered']
|
||||
], params[:filter_type]), { class: 'form-control', required: true }
|
||||
|
||||
- if @questions.any?
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
%h4 Registration Questions
|
||||
%ul
|
||||
- @questions.each do |question|
|
||||
%li= question.title
|
||||
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
= submit_tag 'Send Bulk Email', class: 'btn btn-primary', data: { confirm: 'Are you sure you want to send this email to all selected recipients?' }
|
||||
= link_to 'Cancel', admin_conference_emails_path(@conference.short_title), class: 'btn btn-default'
|
||||
|
||||
.col-md-4
|
||||
.panel.panel-default
|
||||
.panel-heading
|
||||
%h4 Recipient Preview
|
||||
.panel-body
|
||||
%p
|
||||
%strong All registered users:
|
||||
= @conference.registrations.count
|
||||
- if @questions.any?
|
||||
%p
|
||||
%strong No questions answered:
|
||||
= @registrations.select { |r| r.qanswers.empty? }.count
|
||||
%p
|
||||
%strong Some questions answered:
|
||||
= @registrations.select { |r| r.qanswers.any? && r.qanswers.count < @questions.count }.count
|
||||
%p
|
||||
%strong All questions answered:
|
||||
= @registrations.select { |r| r.qanswers.count >= @questions.count }.count
|
||||
- else
|
||||
%p
|
||||
%em No registration questions configured for this conference.
|
||||
- case @step
|
||||
- when 'filter'
|
||||
= render 'bulk_filter'
|
||||
- when 'recipients'
|
||||
= render 'bulk_recipients'
|
||||
- when 'compose'
|
||||
= render 'bulk_compose'
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ Osem::Application.routes.draw do
|
|||
resources :emails, only: [:show, :update, :index] do
|
||||
collection do
|
||||
get :bulk
|
||||
get :recipients
|
||||
post :send_bulk
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue