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
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m21s
This commit is contained in:
@@ -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;">"${safeMessage}"</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).
|
||||
*/
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}."
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -514,14 +514,19 @@
|
||||
"inbox": "Einladungen",
|
||||
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
|
||||
"inviteTitle": "Remote-Benutzer einladen",
|
||||
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
|
||||
"inviteSubtitle": "Du kannst entweder einen Benutzer von einer anderen Redlight-Instanz über seine Adresse einladen oder direkt eine E-Mail-Einladung senden. Es kann nur eine Option gleichzeitig verwendet werden.",
|
||||
"addressLabel": "Benutzeradresse",
|
||||
"addressPlaceholder": "@benutzer@andere-instanz.com",
|
||||
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
|
||||
"emailLabel": "Oder per E-Mail einladen",
|
||||
"emailPlaceholder": "name@beispiel.de, name2@beispiel.de",
|
||||
"emailHint": "Eine oder mehrere E-Mail-Adressen, durch Komma getrennt",
|
||||
"messageLabel": "Nachricht (optional)",
|
||||
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
|
||||
"send": "Einladung senden",
|
||||
"sent": "Einladung gesendet!",
|
||||
"emailSent": "E-Mail-Einladung(en) gesendet!",
|
||||
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
|
||||
"sendFailed": "Einladung konnte nicht gesendet werden",
|
||||
"from": "Von",
|
||||
"accept": "Annehmen",
|
||||
|
||||
@@ -514,14 +514,19 @@
|
||||
"inbox": "Invitations",
|
||||
"inboxSubtitle": "Meeting invitations from other Redlight instances",
|
||||
"inviteTitle": "Invite Remote User",
|
||||
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
|
||||
"inviteSubtitle": "You can either invite a user from another Redlight instance by their address, or send an email invitation directly. Only one option can be used at a time.",
|
||||
"addressLabel": "User address",
|
||||
"addressPlaceholder": "@user@other-instance.com",
|
||||
"addressHint": "Format: @username@domain of the Redlight instance",
|
||||
"emailLabel": "Or invite by email",
|
||||
"emailPlaceholder": "name@example.com, name2@example.com",
|
||||
"emailHint": "Enter one or more email addresses, separated by commas",
|
||||
"messageLabel": "Message (optional)",
|
||||
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
|
||||
"send": "Send invitation",
|
||||
"sent": "Invitation sent!",
|
||||
"emailSent": "Email invitation(s) sent!",
|
||||
"emailSendFailed": "Could not send email invitation",
|
||||
"sendFailed": "Could not send invitation",
|
||||
"from": "From",
|
||||
"accept": "Accept",
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function RoomDetail() {
|
||||
// Federation invite state
|
||||
const [showFedInvite, setShowFedInvite] = useState(false);
|
||||
const [fedAddress, setFedAddress] = useState('');
|
||||
const [fedEmails, setFedEmails] = useState('');
|
||||
const [fedMessage, setFedMessage] = useState('');
|
||||
const [fedSending, setFedSending] = useState(false);
|
||||
|
||||
@@ -266,25 +267,51 @@ export default function RoomDetail() {
|
||||
|
||||
const handleFedInvite = async (e) => {
|
||||
e.preventDefault();
|
||||
// Accept @user@domain or user@domain — must have a domain part
|
||||
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||
const hasAddress = fedAddress.trim().length > 0;
|
||||
const hasEmails = fedEmails.trim().length > 0;
|
||||
|
||||
if (!hasAddress && !hasEmails) {
|
||||
toast.error(t('federation.addressHint'));
|
||||
return;
|
||||
}
|
||||
|
||||
setFedSending(true);
|
||||
try {
|
||||
if (hasAddress) {
|
||||
// Federation address mode
|
||||
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
||||
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
||||
toast.error(t('federation.addressHint'));
|
||||
setFedSending(false);
|
||||
return;
|
||||
}
|
||||
await api.post('/federation/invite', {
|
||||
room_uid: uid,
|
||||
to: fedAddress,
|
||||
message: fedMessage || undefined,
|
||||
});
|
||||
toast.success(t('federation.sent'));
|
||||
} else {
|
||||
// Email mode
|
||||
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
|
||||
if (emailList.length === 0) {
|
||||
toast.error(t('federation.emailHint'));
|
||||
setFedSending(false);
|
||||
return;
|
||||
}
|
||||
await api.post('/rooms/invite-email', {
|
||||
room_uid: uid,
|
||||
emails: emailList,
|
||||
message: fedMessage || undefined,
|
||||
});
|
||||
toast.success(t('federation.emailSent'));
|
||||
}
|
||||
setShowFedInvite(false);
|
||||
setFedAddress('');
|
||||
setFedEmails('');
|
||||
setFedMessage('');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('federation.sendFailed'));
|
||||
toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
|
||||
} finally {
|
||||
setFedSending(false);
|
||||
}
|
||||
@@ -857,13 +884,33 @@ export default function RoomDetail() {
|
||||
<input
|
||||
type="text"
|
||||
value={fedAddress}
|
||||
onChange={e => setFedAddress(e.target.value)}
|
||||
onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
|
||||
className="input-field"
|
||||
placeholder={t('federation.addressPlaceholder')}
|
||||
required
|
||||
disabled={fedEmails.trim().length > 0}
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-2">
|
||||
<div className="flex-1 border-t border-th-border" />
|
||||
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
|
||||
<div className="flex-1 border-t border-th-border" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fedEmails}
|
||||
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
|
||||
className="input-field"
|
||||
placeholder={t('federation.emailPlaceholder')}
|
||||
disabled={fedAddress.trim().length > 0}
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
||||
<textarea
|
||||
@@ -878,7 +925,7 @@ export default function RoomDetail() {
|
||||
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
||||
<button type="submit" disabled={fedSending || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
|
||||
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
{t('federation.send')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user