diff --git a/server/config/mailer.js b/server/config/mailer.js index b700775..48a35f9 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -289,6 +289,78 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi }); } +/** + * Send a calendar event invitation to an external email address (with ICS attachment). + * @param {string} to - recipient email + * @param {string} fromUser - sender display name + * @param {string} title - event title + * @param {string} startTime - ISO start time + * @param {string} endTime - ISO end time + * @param {string|null} description - optional event description + * @param {string|null} joinUrl - direct meeting join URL if a room is linked + * @param {string} ics - ICS file content to attach + * @param {string} appName - branding app name + * @param {string} lang - language code + */ +export async function sendCalendarEventEmail(to, fromUser, title, startTime, endTime, description, joinUrl, ics, 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 safeTitle = escapeHtml(title); + const safeDesc = description ? escapeHtml(description) : null; + + const formatDate = (iso) => { + try { return new Date(iso).toLocaleString(lang === 'de' ? 'de-DE' : 'en-GB', { dateStyle: 'full', timeStyle: 'short' }); } + catch { return iso; } + }; + + const introHtml = t(lang, 'email.calendarEventInvite.intro') + .replace('{fromUser}', `${safeFromUser}`); + + await transporter.sendMail({ + from: `"${headerAppName}" <${from}>`, + to, + subject: t(lang, 'email.calendarEventInvite.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }), + html: ` +
+

${t(lang, 'email.calendarEventInvite.title')}

+

${introHtml}

+
+

${safeTitle}

+

${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}

+ ${safeDesc ? `

"${safeDesc}"

` : ''} +
+ ${joinUrl ? ` +

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

+

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

` : ''} +

${t(lang, 'email.calendarEventInvite.icsHint')}

+
+

${t(lang, 'email.calendarEventInvite.footer', { appName: escapeHtml(appName) })}

+
+ `, + text: `${t(lang, 'email.calendarEventInvite.intro', { fromUser })}\n${title}\n${formatDate(startTime)} – ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}${joinUrl ? `\n\n${t(lang, 'email.calendarEventInvite.joinButton')}: ${joinUrl}` : ''}\n\n${t(lang, 'email.calendarEventInvite.icsHint')}\n\n– ${appName}`, + attachments: [ + { + filename: 'event.ics', + content: ics, + contentType: 'text/calendar; charset=utf-8; method=PUBLISH', + }, + ], + }); +} + /** * Notify a user that a federated calendar event they received was deleted by the organiser. */ diff --git a/server/i18n/de.json b/server/i18n/de.json index 932c594..4fcfb53 100644 --- a/server/i18n/de.json +++ b/server/i18n/de.json @@ -43,6 +43,14 @@ "subject": "{appName} - Kalendereinladung von {fromUser}", "intro": "Du hast eine Kalendereinladung von {fromUser} erhalten." }, + "calendarEventInvite": { + "subject": "{appName} - Kalendereinladung: {title}", + "title": "Kalendereinladung", + "intro": "{fromUser} hat dich zu folgendem Termin eingeladen:", + "joinButton": "Meeting beitreten", + "icsHint": "Mit der angehängten .ics-Datei kannst du den Termin zu deinem Kalender hinzufügen.", + "footer": "Diese Einladung wurde über {appName} versendet." + }, "calendarDeleted": { "subject": "{appName} - Kalendereintrag abgesagt: {title}", "intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:", diff --git a/server/i18n/en.json b/server/i18n/en.json index 9595cb8..d2bbb22 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -43,6 +43,14 @@ "subject": "{appName} - Calendar invitation from {fromUser}", "intro": "You have received a calendar invitation from {fromUser}." }, + "calendarEventInvite": { + "subject": "{appName} - Calendar invitation: {title}", + "title": "Calendar Invitation", + "intro": "{fromUser} has invited you to the following event:", + "joinButton": "Join Meeting", + "icsHint": "You can add this event to your calendar using the attached .ics file.", + "footer": "This invitation was sent via {appName}." + }, "calendarDeleted": { "subject": "{appName} - Calendar event cancelled: {title}", "intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:", diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 6b37179..0d90d2f 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -3,7 +3,7 @@ import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; -import { sendCalendarInviteEmail } from '../config/mailer.js'; +import { sendCalendarInviteEmail, sendCalendarEventEmail } from '../config/mailer.js'; import { getAppName } from '../config/appName.js'; import { isFederationEnabled, @@ -23,6 +23,16 @@ const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3} // Allowed reminder intervals in minutes const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]); +// Rate limit email invitations: each request may carry up to 50 addresses, so +// without a cap any registered account could be abused as an SMTP spam relay. +const calendarEmailInviteLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many email invitations. Please try again later.' }, +}); + // Rate limit for federation calendar receive const calendarFederationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -581,6 +591,69 @@ router.post('/events/:id/federation', authenticateToken, async (req, res) => { } }); +// ── POST /api/calendar/events/:id/email — Send event invitation by email ──── +router.post('/events/:id/email', authenticateToken, calendarEmailInviteLimiter, async (req, res) => { + try { + const { emails } = req.body; + if (!emails || !Array.isArray(emails) || !emails.length) { + return res.status(400).json({ error: 'At least one email address is required' }); + } + if (emails.length > 50) { + return res.status(400).json({ error: 'Maximum 50 email addresses allowed' }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + for (const email of emails) { + if (typeof email !== 'string' || !emailRegex.test(email) || email.length > 254) { + return res.status(400).json({ error: `Invalid email address: ${email}` }); + } + } + + const db = getDb(); + const event = await db.get(` + SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email + FROM calendar_events ce + JOIN users u ON ce.user_id = u.id + WHERE ce.id = ? AND ce.user_id = ? + `, [req.params.id, req.user.id]); + if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); + + const baseUrl = getBaseUrl(req); + let joinUrl = null; + if (event.room_uid) { + joinUrl = `${baseUrl}/join/${event.room_uid}`; + } + + const ics = generateICS(event, joinUrl || '', baseUrl); + const appName = await getAppName(); + 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 => + sendCalendarEventEmail( + email, fromUser, event.title, event.start_time, event.end_time, + event.description, joinUrl, ics, 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.server.warn(`${failed.length}/${emails.length} calendar email invitations failed`); + } + + res.json({ success: true, sent: emails.length - failed.length, failed: failed.length }); + } catch (err) { + log.server.error(`Calendar email invite error: ${err.message}`); + res.status(500).json({ error: err.message || 'Failed to send email invitations' }); + } +}); + // ── POST /receive-event or /calendar-event — Receive calendar event from remote ── // '/receive-event' when mounted at /api/calendar // '/calendar-event' when mounted at /api/federation (for remote instance discovery) diff --git a/src/i18n/de.json b/src/i18n/de.json index defaf07..fa8cf6f 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -644,9 +644,11 @@ "invitationPending": "Einladung ausstehend", "pendingInvitations": "Ausstehende Einladungen", "accepted": "Angenommen", - "sendFederated": "An Remote senden", - "sendFederatedTitle": "Event an Remote-Instanz senden", - "sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.", + "sendFederated": "Remote einladen", + "sendFederatedTitle": "Termin-Einladung senden", + "sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz oder an eine beliebige E-Mail-Adresse. Remote-Empfänger müssen die Einladung zuerst annehmen, bevor das Event in ihrem Kalender erscheint; E-Mail-Empfänger erhalten die Termindetails mit einer .ics-Datei im Anhang.", + "inviteSection": "Einladungen versenden (optional)", + "inviteSectionHint": "Lade Personen per E-Mail ein oder sende das Event an einen Benutzer auf einer anderen Redlight-Instanz.", "send": "Senden", "fedSent": "Kalendereinladung gesendet! Der Empfänger muss diese zuerst annehmen.", "fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden", diff --git a/src/i18n/en.json b/src/i18n/en.json index 21969d1..b9e120b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -644,9 +644,11 @@ "invitationPending": "Invitation pending", "pendingInvitations": "Pending Invitations", "accepted": "Accepted", - "sendFederated": "Send to remote", - "sendFederatedTitle": "Send Event to Remote Instance", - "sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.", + "sendFederated": "Invite remote", + "sendFederatedTitle": "Send Event Invitation", + "sendFederatedDesc": "Send this calendar event to a user on another Redlight instance or to any email address. Remote recipients must accept the invitation before the event appears in their calendar; email recipients receive the event details with an .ics attachment.", + "inviteSection": "Send invitations (optional)", + "inviteSectionHint": "Invite people by email or send the event to a user on another Redlight instance.", "send": "Send", "fedSent": "Calendar invitation sent! The recipient must accept it first.", "fedFailed": "Could not send event to remote instance", diff --git a/src/pages/Calendar.jsx b/src/pages/Calendar.jsx index 28fbb87..7dba03e 100644 --- a/src/pages/Calendar.jsx +++ b/src/pages/Calendar.jsx @@ -42,8 +42,13 @@ export default function Calendar() { const [sharedUsers, setSharedUsers] = useState([]); const [pendingInvitations, setPendingInvitations] = useState([]); const [fedAddress, setFedAddress] = useState(''); + const [fedEmails, setFedEmails] = useState(''); const [fedSending, setFedSending] = useState(false); + // Invitations sent right after creating an event + const [inviteFedAddress, setInviteFedAddress] = useState(''); + const [inviteEmails, setInviteEmails] = useState(''); + // Load events on month change useEffect(() => { fetchEvents(); @@ -159,6 +164,8 @@ export default function Calendar() { end_time: toLocalDateTimeStr(end), room_uid: '', color: '#6366f1', reminder_minutes: null, }); + setInviteFedAddress(''); + setInviteEmails(''); setEditingEvent(null); setShowCreate(true); }; @@ -178,8 +185,41 @@ export default function Calendar() { setShowCreate(true); }; + const isValidFedAddress = (address) => { + const normalized = address.startsWith('@') ? address.slice(1) : address; + return normalized.includes('@') && !normalized.endsWith('@'); + }; + + // Send invitations for an event; shows a toast per outcome (non-fatal on failure) + const sendEventInvites = async (eventId, address, emailsStr) => { + const addr = address.trim(); + if (addr) { + try { + await api.post(`/calendar/events/${eventId}/federation`, { to: addr }); + toast.success(t('calendar.fedSent')); + } catch (err) { + toast.error(err.response?.data?.error || t('calendar.fedFailed')); + } + } + const emailList = emailsStr.split(',').map(s => s.trim()).filter(Boolean); + if (emailList.length > 0) { + try { + await api.post(`/calendar/events/${eventId}/email`, { emails: emailList }); + toast.success(t('federation.emailSent')); + } catch (err) { + toast.error(err.response?.data?.error || t('federation.emailSendFailed')); + } + } + }; + const handleSave = async (e) => { e.preventDefault(); + + if (!editingEvent && inviteFedAddress.trim() && !isValidFedAddress(inviteFedAddress.trim())) { + toast.error(t('federation.addressHint')); + return; + } + setSaving(true); try { const data = { @@ -192,11 +232,17 @@ export default function Calendar() { await api.put(`/calendar/events/${editingEvent.id}`, data); toast.success(t('calendar.eventUpdated')); } else { - await api.post('/calendar/events', data); + const res = await api.post('/calendar/events', data); toast.success(t('calendar.eventCreated')); + const created = res.data.event; + if (created && (inviteFedAddress.trim() || inviteEmails.trim())) { + await sendEventInvites(created.id, inviteFedAddress, inviteEmails); + } } setShowCreate(false); setEditingEvent(null); + setInviteFedAddress(''); + setInviteEmails(''); fetchEvents(); } catch (err) { toast.error(err.response?.data?.error || t('calendar.saveFailed')); @@ -328,19 +374,41 @@ export default function Calendar() { const handleFedSend = async (e) => { e.preventDefault(); if (!showFedShare) return; - 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(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress }); - toast.success(t('calendar.fedSent')); + if (hasAddress) { + // Federation address mode + if (!isValidFedAddress(fedAddress.trim())) { + toast.error(t('federation.addressHint')); + setFedSending(false); + return; + } + await api.post(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress }); + toast.success(t('calendar.fedSent')); + } else { + // Email mode + const emailList = fedEmails.split(',').map(s => s.trim()).filter(Boolean); + if (emailList.length === 0) { + toast.error(t('federation.emailHint')); + setFedSending(false); + return; + } + await api.post(`/calendar/events/${showFedShare.id}/email`, { emails: emailList }); + toast.success(t('federation.emailSent')); + } setShowFedShare(null); setFedAddress(''); + setFedEmails(''); } catch (err) { - toast.error(err.response?.data?.error || t('calendar.fedFailed')); + toast.error(err.response?.data?.error || t(hasAddress ? 'calendar.fedFailed' : 'federation.emailSendFailed')); } finally { setFedSending(false); } @@ -378,6 +446,8 @@ export default function Calendar() { end_time: toLocalDateTimeStr(end), room_uid: '', color: '#6366f1', }); + setInviteFedAddress(''); + setInviteEmails(''); setEditingEvent(null); setShowCreate(true); }} className="btn-primary"> @@ -616,6 +686,41 @@ export default function Calendar() { + {!editingEvent && ( +
+
+

{t('calendar.inviteSection')}

+

{t('calendar.inviteSectionHint')}

+
+ +
+ + setInviteEmails(e.target.value)} + className="input-field" + placeholder={t('federation.emailPlaceholder')} + /> +

{t('federation.emailHint')}

+
+ +
+ + setInviteFedAddress(e.target.value)} + className="input-field" + placeholder={t('federation.addressPlaceholder')} + /> +

{t('federation.addressHint')}

+
+
+ )} +
-