diff --git a/server/config/mailer.js b/server/config/mailer.js index f33cfa1..236aba9 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -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}', `${safeFromUser}`); + + await transporter.sendMail({ + from: `"${headerAppName}" <${from}>`, + to, + subject: t(lang, 'email.guestInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }), + html: ` +
+

Meeting Invitation

+

${introHtml}

+
+

${t(lang, 'email.guestInvite.roomLabel')}

+

${safeRoomName}

+ ${safeMessage ? `

"${safeMessage}"

` : ''} +
+

+ + ${t(lang, 'email.guestInvite.joinButton')} + +

+

+ ${t(lang, 'email.linkHint')}
+ ${escapeHtml(joinUrl)} +

+
+

${t(lang, 'email.guestInvite.footer')}

+
+ `, + 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). */ diff --git a/server/i18n/de.json b/server/i18n/de.json index e898b27..e62485e 100644 --- a/server/i18n/de.json +++ b/server/i18n/de.json @@ -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." diff --git a/server/i18n/en.json b/server/i18n/en.json index 384f871..e36856e 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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}." diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 89140e0..d159d50 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -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; diff --git a/src/i18n/de.json b/src/i18n/de.json index c5e9384..b7b937b 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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", diff --git a/src/i18n/en.json b/src/i18n/en.json index ea76fbd..82d4694 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/pages/RoomDetail.jsx b/src/pages/RoomDetail.jsx index f160e3f..52d8829 100644 --- a/src/pages/RoomDetail.jsx +++ b/src/pages/RoomDetail.jsx @@ -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 { - await api.post('/federation/invite', { - room_uid: uid, - to: fedAddress, - message: fedMessage || undefined, - }); - toast.success(t('federation.sent')); + 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() { 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} />

{t('federation.addressHint')}

+ +
+
+ {t('common.or')} +
+
+ +
+ + { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }} + className="input-field" + placeholder={t('federation.emailPlaceholder')} + disabled={fedAddress.trim().length > 0} + /> +

{t('federation.emailHint')}

+
+