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: `
+
+ `,
+ 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')}
+
+
+
+
+
+
{ setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
+ className="input-field"
+ placeholder={t('federation.emailPlaceholder')}
+ disabled={fedAddress.trim().length > 0}
+ />
+
{t('federation.emailHint')}
+
+