@promptowl/contextnest-community 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ import {
15
15
  config,
16
16
  getDb,
17
17
  isEmailish
18
- } from "./chunk-5QQ7WKAI.js";
18
+ } from "./chunk-I5KYGMIT.js";
19
19
  import {
20
20
  ANON_USER_ID
21
21
  } from "./chunk-SLTQACJW.js";
@@ -150,13 +150,14 @@ import { join as join2, dirname, isAbsolute } from "path";
150
150
  function isSkippedSegment(segment) {
151
151
  return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
152
152
  }
153
+ var ALLOWED_DOT_DIRS = /* @__PURE__ */ new Set([".versions", ".context"]);
153
154
  function safeSegments(rel) {
154
155
  if (!rel || isAbsolute(rel)) return null;
155
156
  const segs = rel.split(/[\\/]/).filter(Boolean);
156
157
  if (!segs.length) return null;
157
158
  for (const s of segs) {
158
159
  if (s === "..") return null;
159
- if (s !== ".versions" && isSkippedSegment(s)) return null;
160
+ if (!ALLOWED_DOT_DIRS.has(s) && isSkippedSegment(s)) return null;
160
161
  }
161
162
  return segs;
162
163
  }
@@ -641,6 +642,373 @@ async function _validateLicenseImpl(forceFresh) {
641
642
 
642
643
  // src/governance/teams-service.ts
643
644
  import { v4 as uuid } from "uuid";
645
+
646
+ // src/notify/email-render.ts
647
+ function escapeHtml(s) {
648
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
649
+ }
650
+ var EVENT = {
651
+ ":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
652
+ ":x:": { icon: "\u274C", color: "#dc2626" },
653
+ ":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
654
+ ":key:": { icon: "\u{1F511}", color: "#2563eb" }
655
+ };
656
+ var ALL_EMOJI = Object.fromEntries(
657
+ Object.entries(EVENT).map(([code, m]) => [code, m.icon])
658
+ );
659
+ function renderText(message) {
660
+ return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
661
+ }
662
+ var STATUS_META = {
663
+ approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
664
+ rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
665
+ pending_review: {
666
+ subjectWord: "awaiting review",
667
+ label: "Pending review",
668
+ color: "#d97706"
669
+ },
670
+ shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" },
671
+ invited: { subjectWord: "invited you", label: "Invitation", color: "#2563eb" },
672
+ steward: { subjectWord: "steward", label: "Steward", color: "#7c3aed" }
673
+ };
674
+ var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
675
+ var detailRow = (label, valueHtml) => `<tr>
676
+ <td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
677
+ <td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
678
+ </tr>`;
679
+ var plainRow = (label, value) => ({
680
+ label,
681
+ text: value,
682
+ html: escapeHtml(value)
683
+ });
684
+ var governanceRows = (x) => [
685
+ plainRow("Path", x.d.path),
686
+ { label: "Status", text: x.meta.label, html: x.statusBadge },
687
+ ...x.d.version != null ? [plainRow("Version", `v${x.d.version}`)] : []
688
+ ];
689
+ var TEMPLATES = {
690
+ approved: {
691
+ heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
692
+ detailsHeader: "Document details",
693
+ linkLabel: "Open document",
694
+ lineT: (x) => `${x.title}${x.vT} has been approved${x.byT}${x.noteT}.`,
695
+ lineH: (x) => `${x.doc}${x.vH} has been approved${x.byH}${x.noteH}.`,
696
+ rows: governanceRows
697
+ },
698
+ rejected: {
699
+ heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
700
+ detailsHeader: "Document details",
701
+ linkLabel: "Open document",
702
+ lineT: (x) => `${x.title}${x.vT} has been rejected${x.byT}${x.noteT}.`,
703
+ lineH: (x) => `${x.doc}${x.vH} has been rejected${x.byH}${x.noteH}.`,
704
+ rows: governanceRows
705
+ },
706
+ pending_review: {
707
+ heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
708
+ detailsHeader: "Document details",
709
+ linkLabel: "Open document",
710
+ lineT: (x) => `A review has been requested for ${x.title}${x.vT}${x.byT}${x.noteT}.`,
711
+ lineH: (x) => `A review has been requested for ${x.doc}${x.vH}${x.byH}${x.noteH}.`,
712
+ rows: governanceRows
713
+ },
714
+ shared: {
715
+ heading: (x) => `You've been added to ${x.title}`,
716
+ detailsHeader: "Details",
717
+ linkLabel: "Open nest",
718
+ lineT: (x) => `You've been given ${x.d.permission || "collaborator"} access to ${x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Open it below to start collaborating.`,
719
+ lineH: (x) => `You've been given <strong>${escapeHtml(x.d.permission || "collaborator")}</strong> access to ${x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Open it below to start collaborating.`,
720
+ rows: (x) => [
721
+ plainRow("Nest", x.d.path),
722
+ ...x.d.permission ? [plainRow("Role", cap(x.d.permission))] : []
723
+ ]
724
+ },
725
+ invited: {
726
+ heading: (x) => `You've been invited to ${x.title}`,
727
+ detailsHeader: "Sign-in details",
728
+ linkLabel: "Sign in",
729
+ lineT: (x) => `You've been invited to ${x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Sign in with your email and the temporary password below, then choose your own password.`,
730
+ lineH: (x) => `You've been invited to ${x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Sign in with your email and the temporary password below, then choose your own password.`,
731
+ rows: (x) => [
732
+ plainRow("Email", x.d.actor || ""),
733
+ ...x.d.tempPassword ? [
734
+ {
735
+ label: "Temporary password",
736
+ text: x.d.tempPassword,
737
+ html: x.codeValue(x.d.tempPassword)
738
+ }
739
+ ] : []
740
+ ]
741
+ },
742
+ steward: {
743
+ heading: (x) => `You're now a steward of ${x.title}`,
744
+ detailsHeader: "Details",
745
+ linkLabel: "Open nest",
746
+ lineT: (x) => `You've been assigned as a ${x.d.permission || "reviewer"} steward of ${x.d.scopeLabel || x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Open the nest below to start reviewing.`,
747
+ lineH: (x) => `You've been assigned as a <strong>${escapeHtml(x.d.permission || "reviewer")}</strong> steward of ${x.d.scopeLabel ? escapeHtml(x.d.scopeLabel) : x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Open the nest below to start reviewing.`,
748
+ rows: (x) => [
749
+ plainRow("Nest", x.d.path),
750
+ ...x.d.permission ? [plainRow("Role", cap(x.d.permission))] : [],
751
+ ...x.d.scopeLabel ? [plainRow("Scope", cap(x.d.scopeLabel))] : []
752
+ ]
753
+ }
754
+ };
755
+ function renderEmail(d) {
756
+ const meta = STATUS_META[d.status];
757
+ const t = TEMPLATES[d.status];
758
+ const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
759
+ const vLabel = d.version != null ? `v${d.version}` : "";
760
+ const ctx = {
761
+ d,
762
+ title,
763
+ doc: `<strong>${escapeHtml(title)}</strong>`,
764
+ byT: d.actor ? ` by ${d.actor}` : "",
765
+ byH: d.actor ? ` by ${escapeHtml(d.actor)}` : "",
766
+ noteT: d.note ? ` with the note: "${d.note}"` : "",
767
+ noteH: d.note ? ` with the note: &ldquo;${escapeHtml(d.note)}&rdquo;` : "",
768
+ vT: vLabel ? ` ${vLabel}` : "",
769
+ vH: vLabel ? ` ${vLabel}` : "",
770
+ meta,
771
+ statusBadge: `<span style="display:inline-block;padding:2px 9px;border-radius:9999px;background:#f3f4f6;color:${meta.color};font-size:12px;font-weight:600;">${meta.label}</span>`,
772
+ codeValue: (v) => `<span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:#111827;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${escapeHtml(v)}</span>`
773
+ };
774
+ const heading = t.heading(ctx).replace(/[\r\n]+/g, " ").trim();
775
+ const subject = heading;
776
+ const detailsHeader = t.detailsHeader;
777
+ const linkLabel = t.linkLabel;
778
+ const lineH = t.lineH(ctx);
779
+ const rows = t.rows(ctx);
780
+ const detailLines = [
781
+ `${detailsHeader}:`,
782
+ ...rows.map((r) => ` ${r.label}: ${r.text}`),
783
+ ...d.link ? [` ${linkLabel}: ${d.link}`] : []
784
+ ];
785
+ const text = ["Hi,", "", t.lineT(ctx), "", ...detailLines, "", "Regards,", "Community Nest"].join("\n");
786
+ const detailRows = rows.map((r) => detailRow(r.label, r.html)).join("");
787
+ const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
788
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
789
+ <td bgcolor="${meta.color}" style="border-radius:6px;">
790
+ <a href="${escapeHtml(d.link)}" target="_blank" style="display:inline-block;padding:10px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${linkLabel}</a>
791
+ </td>
792
+ </tr></table>
793
+ </td></tr>` : "";
794
+ const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
795
+ <table role="presentation" width="480" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;">
796
+ <tr><td style="padding:20px 24px 0;">
797
+ <div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
798
+ </td></tr>
799
+ <tr><td style="padding:10px 24px 0;">
800
+ <h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
801
+ </td></tr>
802
+ <tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
803
+ Hi,<br><br>${lineH}
804
+ </td></tr>
805
+ <tr><td style="padding:20px 24px 0;">
806
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
807
+ <tr><td style="padding:12px 16px;">
808
+ <div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
809
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
810
+ </td></tr>
811
+ </table>
812
+ </td></tr>
813
+ ${buttonRow}
814
+ <tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
815
+ Regards,<br>Community Nest
816
+ </td></tr>
817
+ </table>
818
+ </div>`;
819
+ return { subject, text, html };
820
+ }
821
+ function renderHtml(message, nestName2) {
822
+ const lead = message.match(/^(:[a-z_]+:)\s*/);
823
+ const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
824
+ const rest = lead ? message.slice(lead[0].length) : message;
825
+ const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
826
+ const safeNest = escapeHtml(nestName2);
827
+ return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
828
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
829
+ <tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
830
+ <div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
831
+ <div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
832
+ </td></tr>
833
+ <tr><td style="padding:24px;">
834
+ <table role="presentation" cellpadding="0" cellspacing="0"><tr>
835
+ <td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
836
+ <td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
837
+ </tr></table>
838
+ </td></tr>
839
+ </table>
840
+ </div>`;
841
+ }
842
+
843
+ // src/notify/email.ts
844
+ var transporterPromise = null;
845
+ var configuredUrl = null;
846
+ var warnedOnce = false;
847
+ var SMTP_TIMEOUT_DEFAULTS = [
848
+ ["connectionTimeout", 5e3],
849
+ ["greetingTimeout", 5e3],
850
+ ["socketTimeout", 1e4]
851
+ ];
852
+ function withTimeouts(url) {
853
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
854
+ const missing = SMTP_TIMEOUT_DEFAULTS.filter(
855
+ ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
856
+ ).map(([name, ms]) => `${name}=${ms}`);
857
+ if (missing.length === 0) return url;
858
+ return url + (url.includes("?") ? "&" : "?") + missing.join("&");
859
+ }
860
+ var POOL_DEFAULTS = [
861
+ ["pool", "true"],
862
+ ["maxConnections", "1"],
863
+ ["rateDelta", "1000"],
864
+ ["rateLimit", "8"]
865
+ // messages per second
866
+ ];
867
+ function withPool(url) {
868
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
869
+ const missing = POOL_DEFAULTS.filter(
870
+ ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
871
+ ).map(([name, val]) => `${name}=${val}`);
872
+ if (missing.length === 0) return url;
873
+ return url + (url.includes("?") ? "&" : "?") + missing.join("&");
874
+ }
875
+ async function verifySmtp(url) {
876
+ let transporter = null;
877
+ try {
878
+ const nodemailer = await import("nodemailer");
879
+ transporter = nodemailer.default.createTransport(withTimeouts(url), {
880
+ disableFileAccess: true,
881
+ disableUrlAccess: true
882
+ });
883
+ await transporter.verify();
884
+ return { ok: true };
885
+ } catch (err) {
886
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
887
+ } finally {
888
+ try {
889
+ transporter?.close?.();
890
+ } catch {
891
+ }
892
+ }
893
+ }
894
+ async function getTransporter() {
895
+ const url = config.SMTP_URL;
896
+ if (!url) return null;
897
+ if (!transporterPromise || configuredUrl !== url) {
898
+ configuredUrl = url;
899
+ transporterPromise = import("nodemailer").then(
900
+ (nodemailer) => (
901
+ // withPool → connection pool + per-second rate limit, so a burst queues
902
+ // instead of tripping the provider's "too many emails" 550.
903
+ nodemailer.default.createTransport(withPool(withTimeouts(url)), {
904
+ // Per-message defaults (belt-and-braces; also set on each sendMail).
905
+ disableFileAccess: true,
906
+ disableUrlAccess: true
907
+ })
908
+ )
909
+ );
910
+ transporterPromise.catch(() => {
911
+ transporterPromise = null;
912
+ configuredUrl = null;
913
+ });
914
+ }
915
+ return transporterPromise;
916
+ }
917
+ async function notifyEmailForNest(nestId, message, details) {
918
+ const from = config.NOTIFY_EMAIL_FROM;
919
+ const to = config.NOTIFY_EMAIL_TO;
920
+ if (!config.SMTP_URL || !from || !to) return;
921
+ try {
922
+ const transporter = await getTransporter();
923
+ if (!transporter) return;
924
+ let subject;
925
+ let text;
926
+ let html;
927
+ if (details) {
928
+ ({ subject, text, html } = renderEmail(details));
929
+ } else {
930
+ let name = nestId;
931
+ try {
932
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
933
+ nestId
934
+ ]);
935
+ if (row?.name) name = row.name;
936
+ } catch {
937
+ }
938
+ const safeName = name.replace(/[\r\n]+/g, " ").trim();
939
+ subject = `ContextNest \xB7 ${safeName}`;
940
+ text = renderText(message);
941
+ html = renderHtml(message, safeName);
942
+ }
943
+ await transporter.sendMail({
944
+ from,
945
+ to,
946
+ subject,
947
+ text,
948
+ html,
949
+ disableFileAccess: true,
950
+ disableUrlAccess: true
951
+ });
952
+ } catch (err) {
953
+ if (!warnedOnce) {
954
+ warnedOnce = true;
955
+ console.warn(
956
+ "[email] notification failed (suppressing further warnings):",
957
+ err instanceof Error ? err.message : err
958
+ );
959
+ }
960
+ }
961
+ }
962
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
963
+ function recipientEnvelope(to, from) {
964
+ const recipients = Array.isArray(to) ? to : [to];
965
+ return recipients.length > 1 ? { to: from, bcc: recipients } : { to: recipients[0] };
966
+ }
967
+ function isRateLimited(err) {
968
+ const msg = String(err?.message ?? err);
969
+ const code = Number(err?.responseCode);
970
+ return /too many|rate limit|throttl|try again|4\.7\.\d|too many emails/i.test(msg) || code === 421 || code === 429 || code === 450 || code === 451 || code === 550 && /too many|rate/i.test(msg);
971
+ }
972
+ async function sendMailWithRetry(transporter, msg, attempts = 5) {
973
+ for (let i = 0; ; i++) {
974
+ try {
975
+ return await transporter.sendMail(msg);
976
+ } catch (err) {
977
+ if (i >= attempts - 1 || !isRateLimited(err)) throw err;
978
+ const wait = 1e3 * 2 ** i;
979
+ await sleep(wait);
980
+ }
981
+ }
982
+ }
983
+ async function sendEmailToRecipient(to, details) {
984
+ const from = config.NOTIFY_EMAIL_FROM;
985
+ const hasRecipient = Array.isArray(to) ? to.length > 0 : !!to;
986
+ if (!config.SMTP_URL || !from || !hasRecipient) return;
987
+ try {
988
+ const transporter = await getTransporter();
989
+ if (!transporter) return;
990
+ const { subject, text, html } = renderEmail(details);
991
+ await sendMailWithRetry(transporter, {
992
+ from,
993
+ ...recipientEnvelope(to, from),
994
+ subject,
995
+ text,
996
+ html,
997
+ disableFileAccess: true,
998
+ disableUrlAccess: true
999
+ });
1000
+ } catch (err) {
1001
+ if (!warnedOnce) {
1002
+ warnedOnce = true;
1003
+ console.warn(
1004
+ "[email] notification failed (suppressing further warnings):",
1005
+ err instanceof Error ? err.message : err
1006
+ );
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ // src/governance/teams-service.ts
644
1012
  var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
645
1013
  function parseMembers(json) {
646
1014
  if (!json) return [];
@@ -929,7 +1297,8 @@ async function listNestTeams(nestId) {
929
1297
  }
930
1298
  async function shareTeamWithNest(params) {
931
1299
  const db = getDb();
932
- return db.transaction(async (tx) => {
1300
+ let sharedMembers = [];
1301
+ const refs = await db.transaction(async (tx) => {
933
1302
  const team = await tx.get(
934
1303
  "SELECT id, name, owner_id, members FROM teams WHERE id = ?",
935
1304
  [params.teamId]
@@ -944,16 +1313,18 @@ async function shareTeamWithNest(params) {
944
1313
  params.nestId
945
1314
  ]);
946
1315
  if (!nest) throw new NotFoundError("Nest not found");
947
- const refs = parseSharedTeams(nest.shared_teams);
948
- if (refs.some((r) => r.teamId === params.teamId)) {
1316
+ const refs2 = parseSharedTeams(nest.shared_teams);
1317
+ if (refs2.some((r) => r.teamId === params.teamId)) {
949
1318
  throw new ConflictError("This team is already shared with the nest.");
950
1319
  }
951
- refs.push({ teamId: team.id, teamName: team.name });
1320
+ refs2.push({ teamId: team.id, teamName: team.name });
952
1321
  await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
953
- JSON.stringify(refs),
1322
+ JSON.stringify(refs2),
954
1323
  params.nestId
955
1324
  ]);
956
- const hasGovernanceRole = parseMembers(team.members).some(
1325
+ const members = parseMembers(team.members);
1326
+ sharedMembers = members;
1327
+ const hasGovernanceRole = members.some(
957
1328
  (m) => m.role === "viewer" || m.role === "editor"
958
1329
  );
959
1330
  if (hasGovernanceRole) {
@@ -961,8 +1332,37 @@ async function shareTeamWithNest(params) {
961
1332
  params.nestId
962
1333
  ]);
963
1334
  }
964
- return refs;
1335
+ return refs2;
965
1336
  });
1337
+ try {
1338
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [
1339
+ params.callerUserId
1340
+ ]);
1341
+ const callerEmail = caller?.email?.toLowerCase();
1342
+ const shareNestName = await nestName(params.nestId);
1343
+ const link = docLink(params.nestId, void 0, params.baseUrl);
1344
+ const byRole = /* @__PURE__ */ new Map();
1345
+ for (const m of sharedMembers) {
1346
+ if (!m.email || m.email.toLowerCase() === callerEmail) continue;
1347
+ const arr = byRole.get(m.role) ?? [];
1348
+ arr.push(m.email);
1349
+ byRole.set(m.role, arr);
1350
+ }
1351
+ for (const [role, emails] of byRole) {
1352
+ if (emails.length === 0) continue;
1353
+ void sendEmailToRecipient(emails, {
1354
+ status: "shared",
1355
+ docTitle: shareNestName,
1356
+ path: shareNestName,
1357
+ link,
1358
+ permission: role,
1359
+ by: caller?.email || void 0
1360
+ });
1361
+ }
1362
+ } catch (err) {
1363
+ console.error("[notify] team-share email fan-out failed", params.nestId, err);
1364
+ }
1365
+ return refs;
966
1366
  }
967
1367
  async function unshareTeamFromNest(params) {
968
1368
  const db = getDb();
@@ -1841,6 +2241,32 @@ async function createStewardRecord(params) {
1841
2241
  "UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0",
1842
2242
  [params.nestId]
1843
2243
  );
2244
+ if (results.length > 0) {
2245
+ let scopeLabel;
2246
+ if (params.scope === "document") {
2247
+ const titles = await buildTitleMap(params.nestId);
2248
+ const docTitle = titles.get(nodePattern) || nodePattern;
2249
+ const capitalized = docTitle.charAt(0).toUpperCase() + docTitle.slice(1);
2250
+ scopeLabel = `"${capitalized}" document`;
2251
+ } else if (params.scope === "tag") {
2252
+ scopeLabel = `#${tagName}`;
2253
+ } else {
2254
+ scopeLabel = "the whole nest";
2255
+ }
2256
+ const shareNestName = await nestName(params.nestId);
2257
+ const link = docLink(params.nestId, void 0, params.baseUrl);
2258
+ for (const s of results) {
2259
+ void sendEmailToRecipient(s.userEmail, {
2260
+ status: "steward",
2261
+ docTitle: shareNestName,
2262
+ path: shareNestName,
2263
+ permission: s.role,
2264
+ scopeLabel,
2265
+ by: params.assignedBy || void 0,
2266
+ link
2267
+ });
2268
+ }
2269
+ }
1844
2270
  return results;
1845
2271
  }
1846
2272
  async function resolveStewardsForNode(nestId, nodeId) {
@@ -2177,24 +2603,9 @@ export {
2177
2603
  getAccessConfig,
2178
2604
  isSuperAdmin,
2179
2605
  isConfigSuperAdmin,
2180
- createTeam,
2181
- findTeamByExternalId,
2182
- listTeamsForUser,
2183
- getTeam,
2184
- renameTeam,
2185
- deleteTeam,
2186
- addMember,
2187
- resolveMemberIdentity,
2188
- replaceTeamRoster,
2189
- updateMemberRole,
2190
- removeMember,
2191
- listNestTeams,
2192
- shareTeamWithNest,
2193
- unshareTeamFromNest,
2194
- isServerAdminUserId,
2195
- resolveNestPermission,
2196
- permissionLevel,
2197
- isPublicReader,
2606
+ verifySmtp,
2607
+ notifyEmailForNest,
2608
+ sendEmailToRecipient,
2198
2609
  nestStorageRoot,
2199
2610
  resolveNestPath,
2200
2611
  uniqueNestName,
@@ -2218,6 +2629,24 @@ export {
2218
2629
  nestName,
2219
2630
  docLink,
2220
2631
  buildDocContext,
2632
+ createTeam,
2633
+ findTeamByExternalId,
2634
+ listTeamsForUser,
2635
+ getTeam,
2636
+ renameTeam,
2637
+ deleteTeam,
2638
+ addMember,
2639
+ resolveMemberIdentity,
2640
+ replaceTeamRoster,
2641
+ updateMemberRole,
2642
+ removeMember,
2643
+ listNestTeams,
2644
+ shareTeamWithNest,
2645
+ unshareTeamFromNest,
2646
+ isServerAdminUserId,
2647
+ resolveNestPermission,
2648
+ permissionLevel,
2649
+ isPublicReader,
2221
2650
  assignSteward,
2222
2651
  removeSteward,
2223
2652
  updateSteward,