From 7bc40d839711bfdde976d43a543ae016384486bb Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 10 Jul 2026 16:02:45 +0530 Subject: [PATCH] feat(meetings): email invites + add external participants by email + share link (batch69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4: scheduled meetings can now invite people who aren't on Connect. - SMTP config (config.js, env-gated: SMTP_HOST/PORT/USER/PASS/FROM/SECURE, PUBLIC_BASE_URL) + a small nodemailer wrapper (mailer.js) with a branded meeting-invite template carrying the guest join link. No-op until SMTP is set. - /api/meetings/schedule + /update accept participantEmails; external emails are persisted (scheduled_meetings.guest_emails migration) and emailed the guest link (plus any invited Connect users with an email on file). Fire-and-forget — a mail outage never fails scheduling. - Meetings list DTO returns `link`; schedule form gains an "Invite by email" chip input; each scheduled-meeting card gets a "Copy link" action. Co-Authored-By: Claude Opus 4.8 --- server/config.js | 20 ++++++++++ server/db.js | 3 ++ server/mailer.js | 82 +++++++++++++++++++++++++++++++++++++++++ server/public/home.html | 31 ++++++++++++++-- server/repos.js | 12 +++--- server/routes.js | 45 ++++++++++++++++++---- 6 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 server/mailer.js diff --git a/server/config.js b/server/config.js index 8025e21..993fce5 100644 --- a/server/config.js +++ b/server/config.js @@ -26,6 +26,18 @@ const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || ''; const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || ''; const LIVEKIT_ENABLED = !!(LIVEKIT_URL && LIVEKIT_API_KEY && LIVEKIT_API_SECRET); +// SMTP for outbound email (meeting invites to external participants, #4). Entirely optional and +// config-gated: email is only sent when SMTP_HOST/USER/PASS are set. Credentials stay server-side. +// PUBLIC_BASE_URL is the origin used to build guest meeting links in emails (e.g. https://remote.bizgaze.com). +const SMTP_HOST = process.env.SMTP_HOST || ''; +const SMTP_PORT = Number(process.env.SMTP_PORT || 587); +const SMTP_SECURE = String(process.env.SMTP_SECURE || '').toLowerCase() === 'true' || SMTP_PORT === 465; // TLS on connect (465) vs STARTTLS +const SMTP_USER = process.env.SMTP_USER || ''; +const SMTP_PASS = process.env.SMTP_PASS || ''; +const SMTP_FROM = process.env.SMTP_FROM || (SMTP_USER ? ('Biz Connect <' + SMTP_USER + '>') : ''); +const SMTP_ENABLED = !!(SMTP_HOST && SMTP_USER && SMTP_PASS); +const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.com').replace(/\/+$/, ''); + module.exports = { PORT: process.env.PORT || 8090, HTTPS_PORT: process.env.HTTPS_PORT || 8443, @@ -33,6 +45,14 @@ module.exports = { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, + SMTP_HOST, + SMTP_PORT, + SMTP_SECURE, + SMTP_USER, + SMTP_PASS, + SMTP_FROM, + SMTP_ENABLED, + PUBLIC_BASE_URL, PUBLIC_DIR, REC_DIR, TRANS_DIR, diff --git a/server/db.js b/server/db.js index 12ebf90..48961c5 100644 --- a/server/db.js +++ b/server/db.js @@ -225,6 +225,9 @@ try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active // their mobile number, so this — not the typed login identifier — is the stable identity key. // Provisioning matches on it to keep one Biz Connect account per person (#2 account merge). try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ } +// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed +// guest join link instead of an in-app invite. +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ } try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ } // When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so // any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor diff --git a/server/mailer.js b/server/mailer.js new file mode 100644 index 0000000..3d38e01 --- /dev/null +++ b/server/mailer.js @@ -0,0 +1,82 @@ +// Outbound email (meeting invites to external participants — #4). Config-gated: does nothing unless +// SMTP_* env vars are set (SMTP_ENABLED). Credentials come from config (env), never the client. +// +// Kept intentionally small: one lazily-created nodemailer transport + a couple of helpers. Sending is +// fire-and-forget from the caller's perspective (we log failures but never throw into request handlers, +// so a mail outage can't break scheduling). +const cfg = require('./config'); + +let _transport = null; +let _nodemailer = null; +function transport() { + if (!cfg.SMTP_ENABLED) return null; + if (_transport) return _transport; + try { + _nodemailer = _nodemailer || require('nodemailer'); + _transport = _nodemailer.createTransport({ + host: cfg.SMTP_HOST, + port: cfg.SMTP_PORT, + secure: cfg.SMTP_SECURE, // true for 465, false for 587/STARTTLS + auth: { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS }, + }); + } catch (e) { + console.warn('[mailer] transport init failed:', e && e.message); + _transport = null; + } + return _transport; +} + +const isEnabled = () => cfg.SMTP_ENABLED; + +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +} + +// Send one email. Returns a promise that resolves to true/false — never rejects (callers shouldn't +// have to try/catch around scheduling). `to` may be a string or an array of addresses. +function send({ to, subject, html, text }) { + return new Promise((resolve) => { + const t = transport(); + if (!t) { console.warn('[mailer] SMTP not configured — skipping email:', subject); return resolve(false); } + const list = Array.isArray(to) ? to.filter(Boolean) : [to].filter(Boolean); + if (!list.length) return resolve(false); + t.sendMail({ from: cfg.SMTP_FROM, to: list.join(', '), subject, text: text || '', html: html || undefined }, (err) => { + if (err) { console.warn('[mailer] sendMail failed:', err && err.message); return resolve(false); } + resolve(true); + }); + }); +} + +// Branded meeting-invite email. `link` is the guest join URL; `when` a human-readable time string. +function meetingInviteEmail({ title, when, link, host, description }) { + const subject = 'Meeting invite: ' + title; + const text = [ + (host ? host + ' invited you to a meeting.' : 'You have a meeting invite.'), + '', + 'Title: ' + title, + when ? ('When: ' + when) : '', + description ? ('Details: ' + description) : '', + '', + 'Join: ' + link, + '', + 'No account needed — just open the link and enter your name.', + ].filter(Boolean).join('\n'); + const html = `
+
+
Biz Connect
+
Meeting invitation
+
+
+

${host ? esc(host) + ' invited you to a meeting.' : 'You have a meeting invite.'}

+
${esc(title)}
+ ${when ? `
🗓 ${esc(when)}
` : ''} + ${description ? `
${esc(description)}
` : ''} + Join the meeting +
No account needed — open the link and enter your name.
+
${esc(link)}
+
+
`; + return { subject, text, html }; +} + +module.exports = { send, meetingInviteEmail, isEnabled }; diff --git a/server/public/home.html b/server/public/home.html index 6a15531..0c68969 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -728,6 +728,15 @@ .flbl .opt{color:var(--muted);font-weight:400;} .finput{width:100%;border:1px solid var(--line);border-radius:9px;padding:.55rem .65rem;font-size:.92rem;font-family:inherit;background:#fbfcfe;color:var(--ink);box-sizing:border-box;} .finput:focus{outline:none;border-color:var(--blue);} + .email-invite{display:flex;gap:.5rem;align-items:stretch;} + .email-invite .finput{flex:1;} + .email-add{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:9px;padding:0 .8rem;font-size:.82rem;font-weight:600;cursor:pointer;white-space:nowrap;} + .email-add:hover{background:var(--blue);color:#fff;} + .email-chips{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem;} + .email-chips:empty{margin-top:0;} + .echip{display:inline-flex;align-items:center;gap:.3rem;background:#eef2fa;border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:.2rem .3rem .2rem .6rem;font-size:.78rem;} + .echip button{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.1rem;border-radius:50%;} + .echip button:hover{color:#dc2626;} .iconbtn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto;} .iconbtn:hover{background:#f1f5f9;color:var(--blue);} .iconbtn.rm:hover{color:var(--red);background:#fee2e2;} @@ -875,7 +884,7 @@ - @@ -3094,6 +3103,7 @@ async function loadScheduledMeetings(){ +(m.invited&&m.invited.length?'
'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'
':'') +(m.recordings&&m.recordings.length?'':'')+'' +'
' + +((m.status!=='past'&&!cancelled)?'':'') +((m.status!=='past'&&!cancelled&&canStart)?'':'') +(canCancel?'':'') +(canCancel?'':'') @@ -3103,6 +3113,7 @@ async function loadScheduledMeetings(){ const sec=(title,arr)=>arr.length?('
'+title+'
'+arr.map(card).join('')+'
'):''; wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+sec('Past meetings',past); wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code)); + wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); }); wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); }); wrap.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=()=>cancelMeeting(byId[b.dataset.cancel])); } @@ -3158,7 +3169,10 @@ function openScheduleModal(gid, editMtg){ +'' +'' +'' - +'
'+(CONTACTS.length?CONTACTS.map(c=>'').join(''):'
No contacts to invite
')+'
' + +'
'+(CONTACTS.length?CONTACTS.map(c=>'').join(''):'
No contacts to invite
')+'
' + +'' + +'' + +'' +'' +'
'; document.body.appendChild(ov); @@ -3203,11 +3217,20 @@ function openScheduleModal(gid, editMtg){ daysWrap.querySelector('.day-all').onclick=()=>{ const allOn=daysWrap.querySelectorAll('.day-chip.on').length===7; daysWrap.querySelectorAll('.day-chip').forEach(x=>x.classList.toggle('on', !allOn)); }; $('schTitle').addEventListener('input',clearErrAll); setTimeout(()=>$('schTitle').focus(),0); + // External (guest) email invitees — chips you can add/remove; each gets an emailed join link (#4). + const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailList=(editing&&Array.isArray(editMtg.guestEmails))?editMtg.guestEmails.slice():[]; + function renderEmailChips(){ const w=$('schEmailChips'); if(!w) return; w.innerHTML=emailList.map((e,i)=>''+pEsc(e)+'').join(''); w.querySelectorAll('.echip button').forEach(b=>b.onclick=()=>{ emailList.splice(+b.dataset.i,1); renderEmailChips(); }); } + function addEmail(){ const inp=$('schEmail'); const v=(inp.value||'').trim().toLowerCase(); if(!v) return; if(!EMAIL_RE.test(v)){ err.textContent='Enter a valid email address.'; return; } if(!emailList.includes(v)) emailList.push(v); inp.value=''; err.textContent=''; renderEmailChips(); } + $('schEmailAdd').onclick=addEmail; + $('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } }); + renderEmailChips(); $('schSave').onclick=async()=>{ const title=$('schTitle').value.trim(); const desc=$('schDesc').value.trim(); const durationMins=parseInt($('schDur').value,10)||30; const participants=[...ov.querySelectorAll('#schPeople input:checked')].map(i=>i.value); + { const pend=($('schEmail').value||'').trim().toLowerCase(); if(pend && EMAIL_RE.test(pend) && !emailList.includes(pend)) emailList.push(pend); } // include a typed-but-not-added email clearErrAll(); if(!title){ err.textContent='Please add a title.'; $('schTitle').classList.add('field-err'); $('schTitle').focus(); return; } const ts=new Date(selDate.getFullYear(),selDate.getMonth(),selDate.getDate(),Math.floor(selMin/60),selMin%60,0,0).getTime(); @@ -3215,8 +3238,8 @@ function openScheduleModal(gid, editMtg){ let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; } const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); try{ - if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, recurrence }); toast('Meeting updated'); } - else { await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, durationMins, recurrence }); toast('Meeting scheduled'+(participants.length?' · '+participants.length+' invited':'')); } + if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence }); toast('Meeting updated'); } + else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); } ov.remove(); switchTab('meeting'); loadScheduledMeetings(); }catch(e){ err.textContent=e.message||'Could not save'; } }; diff --git a/server/repos.js b/server/repos.js index 8e00e3f..ec819c0 100644 --- a/server/repos.js +++ b/server/repos.js @@ -297,9 +297,9 @@ const attachments = { }; const scheduledMeetings = { - create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) => - db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)') - .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null), + create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence, guestEmails }) => + db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence,guest_emails) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null), byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id), byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code), // Meetings a user can see: created by them, a member of the group, or an invited participant. @@ -315,9 +315,9 @@ const scheduledMeetings = { end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence - update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) => - db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?') - .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId), + update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence, guestEmails }) => + db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, guest_emails=?, reminded=0 WHERE id=? AND team_id=?') + .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, id, teamId), remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId), }; diff --git a/server/routes.js b/server/routes.js index 9e15729..abcb7ac 100644 --- a/server/routes.js +++ b/server/routes.js @@ -83,7 +83,10 @@ const API_KEY_SCOPES = ['report:read', 'audit:read']; const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); const CALLS = require('./calls'); require('./reminders'); // start the 10-minute meeting-reminder loop -const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config'); +const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL } = require('./config'); +const mailer = require('./mailer'); +// Basic email validation for external meeting invitees (#4). +const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); const crypto = require('crypto'); const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment @@ -1062,7 +1065,7 @@ route('POST', '/api/groups/remove', async (req, res) => { route('POST', '/api/meetings/schedule', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const { group, title, description, scheduledAt, whenText, participants, durationMins, recurrence } = await readBody(req); + const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence } = await readBody(req); const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' }); const when = Number(scheduledAt); @@ -1078,9 +1081,11 @@ route('POST', '/api/meetings/schedule', async (req, res) => { const desc = String(description || '').trim().slice(0, 1000); // Invited participants: tenant users, excluding the host (creator). const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; + // External invitees by email (#4): people not on Connect — they get an emailed guest link. + const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); let code; do { code = A.numericCode(6); } while (R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); const id = A.id(); - R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur }); + R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t }); const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString(); if (groupId) { @@ -1092,7 +1097,21 @@ route('POST', '/api/meetings/schedule', async (req, res) => { // Invitation notification to each invited participant. const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } }; for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} } - json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited }); + // Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users + // who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off. + try { + if (mailer.isEnabled() && (guestEmails.length || invited.length)) { + const link = PUBLIC_BASE_URL + '/home?meet=' + code; + const nameByEmail = {}; const emails = new Set(guestEmails); + for (const x of R.users.listByTenant(u.team_id)) { if (x.email) nameByEmail[x.id] = x.email; } + for (const pid of invited) { const em = nameByEmail[pid]; if (em && isEmail(em)) emails.add(em.toLowerCase()); } + if (emails.size) { + const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: desc }); + mailer.send({ to: [...emails], subject: tpl.subject, html: tpl.html, text: tpl.text }); + } + } + } catch (e) { console.warn('[meetings] invite email failed:', e && e.message); } + json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited, guestEmails, link: PUBLIC_BASE_URL + '/home?meet=' + code }); }); // List the meetings this user can see, bucketed into running / upcoming / past. @@ -1118,12 +1137,13 @@ route('GET', '/api/meetings', async (req, res) => { else if (s.ended_at) status = 'past'; else if (nowTs > endTime) status = 'past'; // its scheduled window has fully passed let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {} + let guestEmails = []; try { guestEmails = JSON.parse(s.guest_emails || '[]'); } catch (_) {} return { id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '', - scheduledAt: schedAt, groupId: s.group_id, + scheduledAt: schedAt, groupId: s.group_id, link: PUBLIC_BASE_URL + '/home?meet=' + s.room_code, groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null, createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id, - invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, + invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), status, inCall: running ? live.size : 0, recordings: [], }; @@ -1229,7 +1249,7 @@ route('POST', '/api/meetings/cancel', async (req, res) => { route('POST', '/api/meetings/update', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const { id, title, description, scheduledAt, durationMins, participants, recurrence } = await readBody(req); + const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence } = await readBody(req); const s = id && R.scheduledMeetings.byId(id); if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' }); @@ -1239,8 +1259,17 @@ route('POST', '/api/meetings/update', async (req, res) => { const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30); const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; - R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur }); + const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); + R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails }); const label = new Date(when).toLocaleString(); + // Email the updated details to external invitees (new + existing) so their link/time stays current. + try { + if (mailer.isEnabled() && guestEmails.length) { + const link = PUBLIC_BASE_URL + '/home?meet=' + s.room_code; + const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: String(description || '').trim().slice(0, 1000) }); + mailer.send({ to: guestEmails, subject: tpl.subject, html: tpl.html, text: tpl.text }); + } + } catch (_) {} const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } }; const recips = new Set(invited); if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } });