Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 50 additions & 1 deletion create-a-container/routers/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const { User, Group, InviteToken, Setting } = require('../models');
const { requireAuth, requireAdmin } = require('../middlewares');
const { sendInviteEmail } = require('../utils/email');
const { sendInviteEmail, sendBulkEmail } = require('../utils/email');
const { sendPushNotificationInvite } = require('../utils/push-notification-invite');

// Apply auth and admin check to all routes
Expand Down Expand Up @@ -56,6 +56,55 @@ router.get('/invite', async (req, res) => {
});
});

// POST /users/email-all - Send an email to all users with an email address
router.post('/email-all', async (req, res) => {
const { subject, message } = req.body;

if (!subject || subject.trim() === '' || !message || message.trim() === '') {
await req.flash('error', 'Both Subject and Message are required');
return res.redirect('/users');
}

try {
// Verify SMTP is configured before attempting to send
const settings = await Setting.getMultiple(['smtp_url']);
if (!settings.smtp_url || settings.smtp_url.trim() === '') {
await req.flash('error', 'SMTP is not configured. Please configure SMTP settings before sending email.');
return res.redirect('/users');
}

// Collect unique, non-empty email addresses across all users
const users = await User.findAll({ attributes: ['mail'] });
const recipients = [...new Set(
users
.map(u => (u.mail || '').trim().toLowerCase())
.filter(m => m.length > 0)
)];

if (recipients.length === 0) {
await req.flash('error', 'No users with email addresses to send to');
return res.redirect('/users');
}

const { sent, failed } = await sendBulkEmail(recipients, subject.trim(), message);

if (failed.length === 0) {
await req.flash('success', `Email sent to ${sent.length} user${sent.length === 1 ? '' : 's'}`);
} else if (sent.length === 0) {
console.error('Bulk email failures:', failed);
await req.flash('error', `Failed to send email to all ${failed.length} recipient${failed.length === 1 ? '' : 's'}. Check SMTP settings.`);
} else {
console.error('Bulk email partial failures:', failed);
await req.flash('warning', `Email sent to ${sent.length} user${sent.length === 1 ? '' : 's'}; failed for ${failed.length}.`);
}
return res.redirect('/users');
} catch (error) {
console.error('Email all error:', error);
await req.flash('error', 'Failed to send email: ' + error.message);
return res.redirect('/users');
}
});

// POST /users/invite - Send invitation email
router.post('/invite', async (req, res) => {
const { email } = req.body;
Expand Down
62 changes: 61 additions & 1 deletion create-a-container/utils/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,68 @@ Medical Informatics Engineering`,
await transporter.sendMail(mailOptions);
}

/**
* Escape HTML special characters to prevent injection in email HTML body
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

/**
* Send a bulk email to multiple recipients (one message per recipient).
* Returns { sent, failed } where failed is an array of { to, error }.
*
* @param {string[]} recipients - List of recipient email addresses
* @param {string} subject - Email subject
* @param {string} message - Plain-text message body
*/
async function sendBulkEmail(recipients, subject, message) {
const settings = await Setting.getMultiple(['smtp_noreply_address']);
const from = settings.smtp_noreply_address || 'noreply@localhost';
Comment on lines +178 to +179
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this fallback. If smtp_noreply_address is not set this should throw up instead.


const transporter = await createTransport();

// Convert plain-text body to safe HTML (preserve line breaks)
const htmlBody = escapeHtml(message).replace(/\r?\n/g, '<br>');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="color: #333; font-size: 14px; line-height: 1.5;">${htmlBody}</div>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #333; font-size: 14px;">
<strong>Medical Informatics Engineering</strong>
</p>
</div>
`;

const sent = [];
const failed = [];

for (const to of recipients) {
try {
await transporter.sendMail({
from,
to,
subject,
text: message,
html
});
sent.push(to);
} catch (error) {
failed.push({ to, error: error.message });
}
}
Comment on lines +198 to +211
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do a single email with BCC instead of looping?


return { sent, failed };
}

module.exports = {
createTransport,
sendPasswordResetEmail,
sendInviteEmail
sendInviteEmail,
sendBulkEmail
};
34 changes: 34 additions & 0 deletions create-a-container/views/users/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="card-title mb-0">Users</h4>
<div>
<button type="button" class="btn btn-info me-2" data-bs-toggle="modal" data-bs-target="#emailAllModal" aria-label="Send email to all users">Email All</button>
<a class="btn btn-success me-2" href="/users/invite" role="button" aria-label="Invite user via email">Invite User</a>
<a class="btn btn-primary" href="/users/new" role="button" aria-label="Create new user">New User</a>
</div>
Expand Down Expand Up @@ -74,4 +75,37 @@
</div>
</div>

<!-- Email All Modal -->
<div class="modal fade" id="emailAllModal" tabindex="-1" aria-labelledby="emailAllModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<form method="POST" action="/users/email-all" onsubmit="return confirm('Send this email to all <%= rows.length %> user(s)?');">
<div class="modal-header">
<h5 class="modal-title" id="emailAllModalLabel">Email All Users</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small">
This will send an email to every user with a registered email address (<%= rows.filter(r => r.mail && r.mail.trim() !== '').length %> recipient(s)).
</p>
<div class="mb-3">
<label for="emailAllSubject" class="form-label">Subject <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="emailAllSubject" name="subject" required maxlength="200" aria-required="true">
</div>
<div class="mb-3">
<label for="emailAllMessage" class="form-label">Message <span class="text-danger">*</span></label>
<textarea class="form-control" id="emailAllMessage" name="message" rows="8" required aria-required="true"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-info" aria-label="Send email to all users">
<i class="bi bi-envelope me-1"></i> Send Email
</button>
</div>
</form>
</div>
</div>
</div>

<%- include('../layouts/footer') %>
Loading