Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d5809ac
Reading configuration fixes
ineiti Sep 30, 2025
7418e2f
Add a test for email delivery
ineiti Sep 30, 2025
62691f0
Fix language error
ineiti Sep 30, 2025
89e536f
parameter passing in schedule
ineiti Oct 1, 2025
c1260e2
Adding track schedule
ineiti Oct 1, 2025
4300006
Simplify schedule
ineiti Oct 1, 2025
2485cdd
Adding questions to registration
ineiti Oct 3, 2025
0398a18
Fix question form
ineiti Oct 3, 2025
53f5832
Fix answer
ineiti Oct 3, 2025
b949212
Correctly store questions
ineiti Oct 3, 2025
b08e405
Fix question answer form
ineiti Oct 3, 2025
d6c76ce
Redirect to password reset in case of existing user
ineiti Oct 8, 2025
a1d8acb
Check username on signup
ineiti Oct 8, 2025
401ed3d
Fix mail test and add bundle install to devbox
ineiti Oct 8, 2025
eca5238
Adding bulk email functionality
ineiti Oct 10, 2025
1075313
Fixes by Claude
ineiti Oct 10, 2025
58deb27
Allow creation of new users
ineiti Oct 10, 2025
3816f9f
Fixes for bulk email
ineiti Oct 13, 2025
d8e68ca
Fixing wrong variable
ineiti Oct 13, 2025
c5808a4
Allowing to delete users
ineiti Oct 13, 2025
18be2ea
Better bulk email function
ineiti Oct 13, 2025
654ba70
Falling back to OSEM_EMAIL_ADDRESS
ineiti Oct 13, 2025
b69cd9d
Don't send emails in cookies
ineiti Oct 13, 2025
4182608
Add missing files :(
ineiti Oct 13, 2025
892b423
Manually registering users
ineiti Oct 13, 2025
7e04c92
Update cache key
ineiti Oct 17, 2025
f7768bd
Fix empty question in count
ineiti Oct 20, 2025
8ae9bf2
More nil answers
ineiti Oct 20, 2025
8557386
Making rooms work
ineiti Oct 27, 2025
39ec2b1
Also update schedule
ineiti Oct 27, 2025
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 .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Treat devbox.lock as binary to prevent it from showing in diffs
devbox.lock binary
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,40 @@ Check out our demo at https://osem.copyleft.dev
## Installation
Please refer to our [installation guide](INSTALL.md).

## DEBUG MAIL ISSUES

If you're experiencing email delivery problems, use these commands to diagnose SMTP configuration issues.
For these tests, it supposes that you have a valid .env.production file in your home directory.

### Show current mail configuration
```bash
bundle exec rake mail:config
```

### Test SMTP connection and send test email
```bash
[email protected] bundle exec rake mail:test
```

The test command will:
- Display your SMTP settings (with passwords hidden)
- Show detailed SMTP protocol conversation for debugging
- Attempt to send a test email
- Provide troubleshooting hints if delivery fails

**Common issues:**
- Authentication method mismatch (try `OSEM_SMTP_AUTHENTICATION=login` instead of `plain`)
- SSL certificate verification (try `OSEM_SMTP_OPENSSL_VERIFY_MODE=none` for testing)

### Using with devbox

If you're using [devbox](https://www.jetpack.io/devbox/docs/quickstart/) for development, prefix commands with `devbox run`:

```bash
devbox run bundle exec rake mail:config
devbox run [email protected] bundle exec rake mail:test
```

## How to contribute to OSEM
Please refer to our [contributing guide](CONTRIBUTING.md).

Expand Down
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand Down
143 changes: 143 additions & 0 deletions app/assets/javascripts/osem-bulk-email.js
Original file line number Diff line number Diff line change
@@ -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">&times;</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);
});
});
187 changes: 187 additions & 0 deletions app/controllers/admin/emails_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,195 @@ def index
@settings = @conference.email_settings
end

def bulk
authorize! :index, @conference.email_settings
@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'
if params[:bulk_session_token]
@bulk_session = BulkEmailSession.active.find_by(token: params[:bulk_session_token])
if @bulk_session && !@bulk_session.expired?
@recipient_emails = @bulk_session.recipient_emails
@filter_type = @bulk_session.filter_type
@search_term = @bulk_session.search_term
else
redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'filter'),
alert: 'Bulk email session expired. Please start again.'
return
end
else
# Fallback to old method for backward compatibility
@recipient_emails = params[:recipient_emails] || []
@filter_type = params[:filter_type]
@search_term = params[:search_term]
end
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 create_bulk_session
authorize! :index, @conference.email_settings

recipient_emails = params[:recipient_emails] || []
filter_type = params[:filter_type]
search_term = params[:search_term]

if recipient_emails.empty?
redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'recipients',
filter_type: filter_type, search_term: search_term),
alert: 'No recipients selected.'
return
end

# Clean up any expired sessions
BulkEmailSession.cleanup_expired!

# Create new session
@bulk_session = BulkEmailSession.create!(
recipient_emails: recipient_emails,
filter_type: filter_type,
search_term: search_term
)

redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'compose',
bulk_session_token: @bulk_session.token)
end

def send_bulk
authorize! :index, @conference.email_settings

subject = params[:subject]
body = params[:body]

# Get recipient emails from either bulk session or direct params
if params[:bulk_session_token]
bulk_session = BulkEmailSession.active.find_by(token: params[:bulk_session_token])
if bulk_session && !bulk_session.expired?
recipient_emails = bulk_session.recipient_emails
else
redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'filter'),
alert: 'Bulk email session expired. Please start again.'
return
end
else
recipient_emails = params[:recipient_emails] || []
end

if subject.blank? || body.blank?
redirect_to bulk_admin_conference_emails_path(@conference.short_title, step: 'compose'),
alert: 'Subject and body are required.'
return
end

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
Rails.logger.info "Bulk email queued - Subject: '#{subject}' - Recipient: #{user.email}"
end

# Clean up the bulk session after use
bulk_session&.destroy

redirect_to admin_conference_emails_path(@conference.short_title),
notice: "Bulk email sent to #{recipients.count} recipients."
end

private

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'
users_with_no_answers(registrations)
when 'some_questions_answered'
users_with_some_answers(registrations)
when 'all_questions_answered'
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

def email_params
params.require(:email_settings).permit(:send_on_registration,
:send_on_accepted, :send_on_rejected, :send_on_confirmed_without_registration,
Expand Down
Loading