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){
+'
'+DAY1.map((d,i)=>'').join('')+'
'
+'
'
+'
'
- +'
'+(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 (_) {} } });