feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s

- Added functionality to create, accept, decline, and delete local calendar invitations.
- Integrated email notifications for calendar event invitations and deletions.
- Updated database schema to support local invitations and outbound event tracking.
- Enhanced the calendar UI to display pending invitations and allow users to manage them.
- Localized new strings for invitations in English and German.
This commit is contained in:
2026-03-02 14:37:54 +01:00
parent d989e1291d
commit c2c10f9a4b
10 changed files with 606 additions and 18 deletions

View File

@@ -558,6 +558,65 @@ export async function initDatabase() {
`);
}
// Track outbound calendar event federation sends for deletion propagation
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_event_outbound (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
remote_domain TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_uid, remote_domain)
);
CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid);
`);
}
// Local calendar event invitations (share-with-acceptance flow)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_local_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
from_user_id INTEGER NOT NULL,
to_user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, to_user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id);
`);
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {

View File

@@ -116,7 +116,7 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
subject: `${headerAppName} - Meeting invitation from ${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;">Hey ${safeName} 👋</h2>
@@ -140,6 +140,83 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
});
}
/**
* Send a calendar event invitation email (federated).
*/
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
const safeDesc = description ? escapeHtml(description) : null;
const formatDate = (iso) => {
try { return new Date(iso).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
catch { return iso; }
};
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} - Calendar invitation from ${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;">Hey ${safeName} 👋</h2>
<p>You have received a calendar invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation
</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
</div>
`,
text: `Hey ${name},\n\nYou have received a calendar invitation from ${fromUser}.\nEvent: ${title}\nTime: ${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\nView invitation: ${inboxUrl}\n\n\u2013 ${appName}`,
});
}
/**
* Notify a user that a federated calendar event they received was deleted by the organiser.
*/
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight') {
if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} Calendar event cancelled: ${sanitizeHeaderValue(title)}`,
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:#f38ba8;margin-top:0;">Hey ${safeName} 👋</h2>
<p>The following calendar event was deleted by the organiser (<strong style="color:#cdd6f4;">${safeFromUser}</strong>) and is no longer available:</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
</div>
<p style="font-size:13px;color:#7f849c;">The event has been automatically removed from your calendar.</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">This message was sent automatically by ${escapeHtml(appName)}.</p>
</div>
`,
text: `Hey ${name},\n\nThe calendar event "${title}" by ${fromUser} has been deleted and removed from your calendar.\n\n\u2013 ${appName}`,
});
}
/**
* Send a user registration invite email.
* @param {string} to recipient email