7bc40d8397
#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>
83 lines
4.0 KiB
JavaScript
83 lines
4.0 KiB
JavaScript
// 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 = `<div style="font-family:'Segoe UI',system-ui,sans-serif;max-width:520px;margin:0 auto;color:#0f172a">
|
|
<div style="background:#1F3B73;border-radius:14px 14px 0 0;padding:20px 24px;color:#fff">
|
|
<div style="font-size:18px;font-weight:700">Biz Connect</div>
|
|
<div style="opacity:.85;font-size:13px;margin-top:2px">Meeting invitation</div>
|
|
</div>
|
|
<div style="border:1px solid #e3e8f2;border-top:none;border-radius:0 0 14px 14px;padding:22px 24px">
|
|
<p style="margin:0 0 14px;font-size:14px">${host ? esc(host) + ' invited you to a meeting.' : 'You have a meeting invite.'}</p>
|
|
<div style="font-size:17px;font-weight:700;margin-bottom:6px">${esc(title)}</div>
|
|
${when ? `<div style="font-size:14px;color:#475569;margin-bottom:4px">🗓 ${esc(when)}</div>` : ''}
|
|
${description ? `<div style="font-size:13px;color:#64748b;margin:10px 0 0;line-height:1.5">${esc(description)}</div>` : ''}
|
|
<a href="${esc(link)}" style="display:inline-block;margin:18px 0 8px;background:#1F3B73;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9px">Join the meeting</a>
|
|
<div style="font-size:12px;color:#94a3b8;margin-top:8px">No account needed — open the link and enter your name.</div>
|
|
<div style="font-size:11px;color:#cbd5e1;margin-top:14px;word-break:break-all">${esc(link)}</div>
|
|
</div>
|
|
</div>`;
|
|
return { subject, text, html };
|
|
}
|
|
|
|
module.exports = { send, meetingInviteEmail, isEnabled };
|