feat: add email invitation functionality for guests with support for multiple addresses
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m21s

This commit is contained in:
2026-04-02 00:54:57 +02:00
parent 61585d8c63
commit 1690a74c19
7 changed files with 214 additions and 15 deletions

View File

@@ -144,6 +144,61 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
});
}
/**
* Send a guest meeting invitation email with a direct join link.
* @param {string} to - recipient email
* @param {string} fromUser - sender display name
* @param {string} roomName - name of the room
* @param {string} message - optional personal message
* @param {string} joinUrl - direct guest join URL
* @param {string} appName - branding app name
* @param {string} lang - language code
*/
export async function sendGuestInviteEmail(to, fromUser, roomName, message, joinUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null;
const introHtml = t(lang, 'email.guestInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: t(lang, 'email.guestInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Meeting Invitation</h2>
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.guestInvite.roomLabel')}</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${joinUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
${t(lang, 'email.guestInvite.joinButton')}
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
${t(lang, 'email.linkHint')}<br/>
<a href="${joinUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(joinUrl)}</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">${t(lang, 'email.guestInvite.footer')}</p>
</div>
`,
text: `${t(lang, 'email.guestInvite.intro', { fromUser })}\n${t(lang, 'email.guestInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.guestInvite.joinButton')}: ${joinUrl}\n\n- ${appName}`,
});
}
/**
* Send a calendar event invitation email (federated).
*/

View File

@@ -25,6 +25,13 @@
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"guestInvite": {
"subject": "{appName} - Einladung zu einem Meeting",
"intro": "{fromUser} hat dich zu einem Meeting eingeladen.",
"roomLabel": "Raum:",
"joinButton": "Meeting beitreten",
"footer": "Klicke auf den Button oben, um dem Meeting beizutreten."
},
"calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."

View File

@@ -25,6 +25,13 @@
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"guestInvite": {
"subject": "{appName} - You're invited to a meeting",
"intro": "{fromUser} has invited you to a meeting.",
"roomLabel": "Room:",
"joinButton": "Join Meeting",
"footer": "Click the button above to join the meeting."
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."

View File

@@ -8,6 +8,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import { sendGuestInviteEmail } from '../config/mailer.js';
import {
createMeeting,
joinMeeting,
@@ -848,4 +849,76 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
}
});
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
router.post('/invite-email', authenticateToken, async (req, res) => {
try {
const { room_uid, emails, message } = req.body;
if (!room_uid || !emails || !emails.length) {
return res.status(400).json({ error: 'room_uid and emails are required' });
}
if (emails.length > 50) {
return res.status(400).json({ error: 'Maximum 50 email addresses allowed' });
}
// Validate all emails
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const email of emails) {
if (!emailRegex.test(email) || email.length > 254) {
return res.status(400).json({ error: `Invalid email address: ${email}` });
}
}
if (message && message.length > 2000) {
return res.status(400).json({ error: 'Message must not exceed 2000 characters' });
}
const db = getDb();
// Verify room exists and user has access
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to invite from this room' });
}
}
// Build guest join URL
const baseUrl = getBaseUrl(req);
const joinUrl = room.access_code
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
const appName = process.env.APP_NAME || 'Redlight';
const fromUser = req.user.display_name || req.user.name;
const lang = req.user.language || 'en';
// Send emails (in parallel but collect errors)
const results = await Promise.allSettled(
emails.map(email =>
sendGuestInviteEmail(email, fromUser, room.name, message || null, joinUrl, appName, lang)
)
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length === emails.length) {
return res.status(500).json({ error: 'Failed to send all email invitations' });
}
if (failed.length > 0) {
log.rooms.warn(`${failed.length}/${emails.length} email invitations failed`);
}
res.json({ success: true, sent: emails.length - failed.length, failed: failed.length });
} catch (err) {
log.rooms.error('Email invite error:', err);
res.status(500).json({ error: err.message || 'Failed to send email invitations' });
}
});
export default router;