feat(meetings): email invites + add external participants by email + share link (batch69)

#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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:02:45 +05:30
parent 466c0d5d70
commit 7bc40d8397
6 changed files with 175 additions and 18 deletions
+27 -4
View File
@@ -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 @@
<body>
<script src="/icons.js?v=5"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-10-batch68';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-10-batch69';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -3094,6 +3103,7 @@ async function loadScheduledMeetings(){
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>'<a class="rec-dl '+(r.kind==='video'?'vid':'txt')+'" href="'+pEsc(r.url)+'" title="Download '+(r.kind==='video'?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(r.kind==='video'?'Recording':'Transcript')+'</span>'+(r.kind==='video'&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>').join('')+'</div>':'')+'</div>'
+'<div class="si-actions">'
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')
+(canCancel?'<button class="iconbtn edit" data-edit="'+pEsc(m.id)+'" title="Edit meeting" aria-label="Edit meeting">'+ic('pencil',14)+'</button>':'')
+(canCancel?'<button class="iconbtn cancel-ic" data-cancel="'+pEsc(m.id)+'" title="Cancel meeting" aria-label="Cancel meeting">'+ic('calendarX',14)+'</button>':'')
@@ -3103,6 +3113,7 @@ async function loadScheduledMeetings(){
const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):'';
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){
+'<div class="sch-days hidden" id="schDays">'+DAY1.map((d,i)=>'<button type="button" class="day-chip" data-d="'+i+'" title="'+DAYW[i]+'">'+d+'</button>').join('')+'<button type="button" class="day-all" data-all="1">Everyday</button></div>'
+'<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
+'<label class="flbl">Invite participants</label>'
+'<div class="gi-list" id="schPeople" style="max-height:24vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
+'<div class="gi-list" id="schPeople" style="max-height:20vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
+'<div class="email-chips" id="schEmailChips"></div>'
+'<button class="gobtn" id="schSave" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">'+(editing?'Save changes':'Schedule & invite')+'</button>'
+'<div class="hint" id="schErr"></div></div>';
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)=>'<span class="echip">'+pEsc(e)+'<button type="button" data-i="'+i+'" title="Remove">'+ic('x',12)+'</button></span>').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'; }
};