@xuda.io/account_module 1.2.2281 → 1.2.2283

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.
package/index.mjs CHANGED
@@ -225,6 +225,7 @@ export const update_account_info = async function (req, job_id, headers) {
225
225
  eb.status = 'cleared';
226
226
  eb.cleared_ts = Date.now();
227
227
  account_obj.account_email_bounce_suspended = 0;
228
+ _clear_bounce_suspension_banner(uid); // retire the suspension banner
228
229
  }
229
230
  }
230
231
 
@@ -1227,6 +1228,11 @@ export const get_account_data = async function (req) {
1227
1228
  login_screen: await resolve_effective_login_screen(acc_obj),
1228
1229
  };
1229
1230
 
1231
+ // A bounce-suspended account must always see the suspension banner (the
1232
+ // one-shot notice at suspension time can be missed or predate this). Best-
1233
+ // effort + fire-and-forget; only touches the DB for the rare suspended account.
1234
+ if (acc_obj.account_email_bounce_suspended) _ensure_bounce_suspension_banner(acc_obj);
1235
+
1230
1236
  return ret;
1231
1237
  } catch (err) {
1232
1238
  console.error(err);
@@ -1413,6 +1419,71 @@ export const get_account_external_apps = async function (req) {
1413
1419
  }
1414
1420
  };
1415
1421
 
1422
+ // Eligible backends for a load balancer's pool: the user's ACTIVE apps of one
1423
+ // chosen deploy_type. full_stack_vps is stored as app_type:'vps' + is_full_stack,
1424
+ // so it's split out here. Balancers themselves are never selectable. Each row
1425
+ // carries the origin (ip/region) the balancer needs, plus a `reachable` flag —
1426
+ // farm/mesh-hosted backends (private IP) are shown but can't be pooled by a v1
1427
+ // DO balancer. req: { uid, data:{ backend_deploy_type } }
1428
+ export const get_load_balancer_candidates = async function (req) {
1429
+ try {
1430
+ const uid = req.uid;
1431
+ const bt = req.data?.backend_deploy_type || req.backend_deploy_type;
1432
+ if (!bt) return { code: -2, data: 'backend_deploy_type required' };
1433
+ const excluded = _conf.balancer?.excluded_backend_types || ['cron', 'emitter'];
1434
+ if (excluded.includes(bt)) return { code: -2, data: `${bt} is not balanceable` };
1435
+
1436
+ const isSuper = _conf.superuser_account_ids?.includes(uid);
1437
+ const app_type = bt === 'full_stack_vps' ? 'vps' : bt;
1438
+ const selector = isSuper ? { app_type, docType: 'app', app_status_code: 3 } : { app_uId: uid, app_type, docType: 'app', app_status_code: 3 };
1439
+ const ret = await db_module.find_couch_query('xuda_master', { selector, limit: 1000 });
1440
+ let docs = ret?.docs || [];
1441
+
1442
+ // Split the shared app_type:'vps' bucket into vps vs full_stack_vps.
1443
+ if (bt === 'full_stack_vps') docs = docs.filter((d) => d.is_full_stack);
1444
+ else if (bt === 'vps') docs = docs.filter((d) => !d.is_full_stack);
1445
+ docs = docs.filter((d) => !d.is_load_balancer && d.app_type !== 'balancer');
1446
+
1447
+ const is_private = (ip) => !ip || /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(String(ip));
1448
+ const rows = docs.map((d) => {
1449
+ const ip = d.app_hosting_server?.ip || null;
1450
+ const farm_hosted = is_private(ip) || d.app_hosting_server?.provider === 'proxmox';
1451
+ return {
1452
+ app_id: d._id,
1453
+ name: d.app_name,
1454
+ app_type: bt,
1455
+ region_code: d.app_hosting_server?.region_slug || d.app_hosting?.app_region || null,
1456
+ ip,
1457
+ public_url: `https://${d.app_access_url || `${d._id}.${_conf.app_domain}`}`,
1458
+ farm_hosted,
1459
+ reachable: !!ip && !farm_hosted,
1460
+ status: d.app_status_code,
1461
+ };
1462
+ });
1463
+ return { code: 1, data: { rows } };
1464
+ } catch (err) {
1465
+ return { code: -1, data: err.message };
1466
+ }
1467
+ };
1468
+
1469
+ // Load balancers are apps (app_type:'balancer') not emitted by any user_* view,
1470
+ // so — like get_account_external_apps — a Mango query surfaces them for the
1471
+ // sidenav bucket (DATA.balancer) right after create. req: { uid }
1472
+ export const get_account_balancers = async function (req) {
1473
+ try {
1474
+ const isSuper = _conf.superuser_account_ids?.includes(req.uid);
1475
+ const selector = isSuper ? { app_type: 'balancer', docType: 'app' } : { app_uId: req.uid, app_type: 'balancer', docType: 'app' };
1476
+ const ret = await db_module.find_couch_query('xuda_master', {
1477
+ selector,
1478
+ limit: 1000,
1479
+ });
1480
+ const rows = (ret?.docs || []).map((d) => ({ id: d._id, value: d }));
1481
+ return { code: 1, data: { rows } };
1482
+ } catch (err) {
1483
+ return { code: -1, data: err.message };
1484
+ }
1485
+ };
1486
+
1416
1487
  export const get_account_info = async function (req) {
1417
1488
  return await get_account_data({
1418
1489
  uid: req.uid,
@@ -1432,6 +1503,23 @@ export const get_account_info = async function (req) {
1432
1503
  // ===================================================================
1433
1504
  const _OPS_SERVER_TYPES = ['datacenter', 'deployment', 'vps', 'managed_vps', 'instance', 'master'];
1434
1505
  const _ops_is_super = (req) => !!(req && req.uid && Array.isArray(_conf.superuser_account_ids) && _conf.superuser_account_ids.includes(req.uid));
1506
+
1507
+ // info@xuda.ai (platform owner) + any superuser account are permanently exempt
1508
+ // from billing hold / suspension / termination — manual OR automated. A held or
1509
+ // suspended owner account would lock the operator out. Email-keyed so it holds
1510
+ // across the dev/master account-id divergence. Mirrors the same guard in
1511
+ // stripe_module's webhook paths. Extend via _conf.billing_hold_exempt_emails.
1512
+ const _HOLD_EXEMPT_EMAILS = new Set(
1513
+ ['info@xuda.ai', ...(Array.isArray(_conf.billing_hold_exempt_emails) ? _conf.billing_hold_exempt_emails : [])]
1514
+ .map((e) => String(e).trim().toLowerCase()).filter(Boolean)
1515
+ );
1516
+ const _is_billing_restriction_exempt = (account_obj) => {
1517
+ if (!account_obj) return false;
1518
+ const email = String(account_obj.account_info?.email || '').trim().toLowerCase();
1519
+ if (email && _HOLD_EXEMPT_EMAILS.has(email)) return true;
1520
+ const supers = _conf.superuser_account_ids;
1521
+ return !!(Array.isArray(supers) && account_obj._id && supers.includes(account_obj._id));
1522
+ };
1435
1523
  const _ops_host = () => (_conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain) || 'xuda.ai';
1436
1524
  const _ops_appeal_days = () => Number(_conf.termination?.appeal_days) || 14;
1437
1525
 
@@ -1477,6 +1565,7 @@ export const ops_suspend_account = async function (req = {}) {
1477
1565
  const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1478
1566
  if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1479
1567
  const account = ar.data;
1568
+ if (_is_billing_restriction_exempt(account)) return { code: -1, data: 'account is on the permanent allowlist (info@ / superuser); suspension refused' };
1480
1569
  account.account_suspension_status = 1;
1481
1570
  account.account_suspension_date = Date.now();
1482
1571
  account.suspension_reason = req.reason || '';
@@ -1501,6 +1590,7 @@ export const ops_terminate_account = async function (req = {}) {
1501
1590
  const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1502
1591
  if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1503
1592
  const account = ar.data;
1593
+ if (_is_billing_restriction_exempt(account)) return { code: -1, data: 'account is on the permanent allowlist (info@ / superuser); termination refused' };
1504
1594
  const appeal_days = _ops_appeal_days();
1505
1595
  account.account_termination_status = 1;
1506
1596
  account.account_termination_date = Date.now();
@@ -1573,6 +1663,20 @@ export const ops_reinstate_account = async function (req = {}) {
1573
1663
  account.appeal_status = 'accepted';
1574
1664
  account.reinstated_ts = Date.now();
1575
1665
  account.reinstated_by_uid = req.uid;
1666
+ // Reinstate means "reactivate", regardless of which suspension type is in
1667
+ // effect — so also lift an email-bounce suspension if present. This mirrors
1668
+ // the clear branch of sweep_email_bounces (:3630) so the two paths agree.
1669
+ const _eb = account.account_email_bounce;
1670
+ const _was_bounce_suspended = account.account_email_bounce_suspended || (_eb && (_eb.status === 'suspended' || _eb.status === 'flagged'));
1671
+ if (_was_bounce_suspended) {
1672
+ if (_eb) {
1673
+ _eb.status = 'cleared';
1674
+ _eb.cleared_ts = Date.now();
1675
+ account.account_email_bounce = _eb;
1676
+ }
1677
+ account.account_email_bounce_suspended = 0;
1678
+ }
1679
+ if (account.stat === 5) account.stat = 3; // defensive: clear a hard suspended stat if one was set
1576
1680
  const sr = await db_module.save_couch_doc('xuda_accounts', account);
1577
1681
  if (sr.code < 0) return { code: -1, data: sr.data };
1578
1682
  const power = await _ops_services_power(account_uid, true);
@@ -1680,6 +1784,19 @@ export const ops_account_snapshot = async function (req = {}) {
1680
1784
  billing_hold_date: a.account_billing_hold_date || null,
1681
1785
  billing_hold_reason: a.billing_hold_reason || '',
1682
1786
  reinstated_ts: a.reinstated_ts || null,
1787
+ // Email-bounce suspension (a distinct suspension type — an outbound
1788
+ // platform email to the account hard-bounced). Surfaced so the admin
1789
+ // can see it's the reason an account reads as inactive.
1790
+ email_bounce: a.account_email_bounce_suspended
1791
+ ? {
1792
+ suspended: true,
1793
+ recipient: a.account_email_bounce?.recipient || '',
1794
+ reason: a.account_email_bounce?.reason || '',
1795
+ status: a.account_email_bounce?.status || '',
1796
+ bounce_count: a.account_email_bounce?.bounce_count || 0,
1797
+ suspended_ts: a.account_email_bounce?.suspended_ts || null,
1798
+ }
1799
+ : null,
1683
1800
  entitlements: get_effective_entitlements(a),
1684
1801
  credits,
1685
1802
  },
@@ -1696,6 +1813,7 @@ export const ops_set_billing_hold = async function (req = {}) {
1696
1813
  const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
1697
1814
  if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
1698
1815
  const account = ar.data;
1816
+ if (_is_billing_restriction_exempt(account)) return { code: -1, data: 'account is on the permanent allowlist (info@ / superuser); billing hold refused' };
1699
1817
  account.account_billing_hold_status = 1;
1700
1818
  account.account_billing_hold_date = Date.now();
1701
1819
  account.billing_hold_reason = req.reason || '';
@@ -3412,12 +3530,18 @@ const _notify_bounce_suspended = async (account) => {
3412
3530
  await notification_msa.submit_notification({
3413
3531
  type: 'account',
3414
3532
  app_id: null,
3533
+ // Account-level banner: tag with an empty app_id so get_system_notifications
3534
+ // (which scopes to app_id: '') returns it on every reload, not just the
3535
+ // one live WebSocket push at suspension time.
3536
+ to_app_id: '',
3415
3537
  uid_arr: [account._id],
3416
3538
  topic: 'email_bounce_update_required',
3417
3539
  params: {
3418
3540
  recipient_email: eb.recipient || account.account_info?.email || '',
3419
3541
  settings_url: _bounce_settings_url(),
3420
3542
  support_url: `https://${host}/dashboard/support`,
3543
+ action_label: 'Update email',
3544
+ action_href: _bounce_settings_url(),
3421
3545
  },
3422
3546
  });
3423
3547
  } catch (e) { console.error('bounce user notice failed:', e.message); }
@@ -3444,6 +3568,79 @@ const _notify_bounce_suspended = async (account) => {
3444
3568
  }
3445
3569
  };
3446
3570
 
3571
+ // Ensure a bounce-suspended account has a LIVE suspension banner on the
3572
+ // dashboard. The suspend-time notice (_notify_bounce_suspended) fires once and
3573
+ // can be missed or predate this; get_account_data calls this on every load.
3574
+ // Idempotent + banner-only: bails if a persistent (app_id '') banner already
3575
+ // exists, so it never duplicates the doc or re-sends the SMS.
3576
+ const _ensure_bounce_suspension_banner = async (account) => {
3577
+ try {
3578
+ const uid = account._id;
3579
+ const ret = await db_module.find_couch_query('xuda_notification', {
3580
+ selector: { docType: 'notification', uid, topic: 'email_bounce_update_required', delivery_method: 'banner', read: false },
3581
+ });
3582
+ const docs = ret?.docs || [];
3583
+ if (docs.length) {
3584
+ // Self-heal: concurrent get_account_data calls on boot can race the create
3585
+ // (find-then-create isn't atomic + CouchDB's index lags the fresh doc), so
3586
+ // more than one can slip through. Keep the newest, retire the rest.
3587
+ docs.sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0));
3588
+ for (let i = 1; i < docs.length; i++) {
3589
+ docs[i].read = true;
3590
+ docs[i].stat = 4;
3591
+ docs[i].stat_ts = Date.now();
3592
+ await db_module.save_couch_doc('xuda_notification', docs[i]).catch(() => {});
3593
+ }
3594
+ // Upgrade an older-format banner in place (created before locked / the
3595
+ // Update-email action existed) so it picks up the current fields.
3596
+ const keep = docs[0];
3597
+ if (!keep.locked || !keep.params?.action_href) {
3598
+ keep.locked = true;
3599
+ keep.params = { ...(keep.params || {}), action_label: 'Update email', action_href: _bounce_settings_url() };
3600
+ await db_module.save_couch_doc('xuda_notification', keep).catch(() => {});
3601
+ }
3602
+ return;
3603
+ }
3604
+ const eb = account.account_email_bounce || {};
3605
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
3606
+ await notification_msa.submit_notification({
3607
+ type: 'account',
3608
+ app_id: null,
3609
+ to_app_id: '',
3610
+ uid_arr: [uid],
3611
+ topic: 'email_bounce_update_required',
3612
+ delivery_filter: ['banner'], // banner only — the SMS already went out at suspension time
3613
+ params: {
3614
+ recipient_email: eb.recipient || account.account_info?.email || '',
3615
+ settings_url: _bounce_settings_url(),
3616
+ support_url: `https://${host}/dashboard/support`,
3617
+ action_label: 'Update email',
3618
+ action_href: _bounce_settings_url(),
3619
+ },
3620
+ });
3621
+ } catch (e) {
3622
+ console.error('ensure bounce banner failed:', e.message);
3623
+ }
3624
+ };
3625
+
3626
+ // Retire the suspension banner once the bounce is resolved (email changed).
3627
+ // Marks it read so get_system_notifications stops returning it next load.
3628
+ const _clear_bounce_suspension_banner = async (uid) => {
3629
+ try {
3630
+ const ret = await db_module.find_couch_query('xuda_notification', {
3631
+ selector: { docType: 'notification', uid, topic: 'email_bounce_update_required', read: false },
3632
+ });
3633
+ for (const doc of ret?.docs || []) {
3634
+ doc.read = true;
3635
+ doc.stat = 4;
3636
+ doc.stat_ts = Date.now();
3637
+ await db_module.save_couch_doc('xuda_notification', doc);
3638
+ }
3639
+ } catch (e) {
3640
+ console.error('clear bounce banner failed:', e.message);
3641
+ }
3642
+ };
3643
+
3447
3644
  // Hard-email-bounce handler. Called (via account_ms RPC) by email_module's
3448
3645
  // scan_platform_bounces when a platform email to this account's address bounces
3449
3646
  // permanently (5.x.x). Policy: suspend IMMEDIATELY (account_email_bounce_suspended,
@@ -5921,15 +6118,23 @@ const _nl_avatar_url = (host, pic) => {
5921
6118
  return '';
5922
6119
  };
5923
6120
 
5924
- const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_url }) => {
6121
+ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_url, theme }) => {
5925
6122
  const c = issue.content || {};
5926
6123
  const esc = _nl_esc;
6124
+ const dark = theme === 'dark';
6125
+ // Palette: light (default/production) vs dark (the "whole box dark" variant).
6126
+ const pal = dark
6127
+ ? { pageBg: '#0b1020', card: '#151c30', cardBorder: '#28324e', ink: '#ffffff', body: '#c3cbdb', muted: '#8b93a8', accentCard: '#13233c', accentInk: '#66BDFF', accentBody: '#c3cbdb', pill: '#152036', pillInk: '#8ecbff', imgBg: '#0e1424', footBorder: '#28324e' }
6128
+ : { pageBg: '#ffffff', card: '#ffffff', cardBorder: '#e9edf3', ink: '#0b1220', body: '#475569', muted: '#94a3b8', accentCard: '#eff6ff', accentInk: '#0b66c2', accentBody: '#334155', pill: '#eff6ff', pillInk: '#0b66c2', imgBg: '#f4f6fa', footBorder: '#e9edf3' };
6129
+ // A subtle border so the already-dark cards (news/feature/fashion) separate from
6130
+ // the dark page background instead of merging into it.
6131
+ const sep = dark ? ';border:1px solid #28324e' : '';
5927
6132
  const btn = (href, txt, bg = '#0091FF', fg = '#fff') =>
5928
6133
  `<a href="${esc(href)}" style="display:inline-block;background:${bg};color:${fg};text-decoration:none;padding:11px 24px;border-radius:9px;font-weight:700;font-size:14px">${esc(txt)} &rarr;</a>`;
5929
6134
 
5930
6135
  // A white content card with an optional top image. imgFit 'cover' crops to a
5931
6136
  // banner; 'contain' shows the WHOLE image (used for the t-shirt) on imgBg.
5932
- const card = ({ img, imgAlt, emoji, label, title, href, text, btnText, btnHref, imgFit = 'cover', imgBg = '#f4f6fa', imgMaxH = '210px' }) => {
6137
+ const card = ({ img, imgAlt, emoji, label, title, href, text, btnText, btnHref, imgFit = 'cover', imgBg = pal.imgBg, imgMaxH = '210px' }) => {
5933
6138
  let image = '';
5934
6139
  if (img) {
5935
6140
  const inner = imgFit === 'contain'
@@ -5937,15 +6142,15 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
5937
6142
  : `<img src="${esc(img)}" alt="${esc(imgAlt || '')}" width="100%" style="display:block;width:100%;max-height:${imgMaxH};object-fit:cover">`;
5938
6143
  image = `<a href="${esc(href || btnHref || '#')}" style="text-decoration:none">${inner}</a>`;
5939
6144
  }
5940
- const titleHtml = href ? `<a href="${esc(href)}" style="color:#0b1220;text-decoration:none">${esc(title)}</a>` : esc(title);
6145
+ const titleHtml = href ? `<a href="${esc(href)}" style="color:${pal.ink};text-decoration:none">${esc(title)}</a>` : esc(title);
5941
6146
  return `
5942
6147
  <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
5943
- <tr><td style="background:#ffffff;border:1px solid #e9edf3;border-radius:14px;overflow:hidden">
6148
+ <tr><td style="background:${pal.card};border:1px solid ${pal.cardBorder};border-radius:14px;overflow:hidden">
5944
6149
  ${image}
5945
6150
  <div style="padding:18px 20px">
5946
6151
  <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0091FF;font-weight:700;margin-bottom:7px">${emoji} ${esc(label)}</div>
5947
- <div style="font-size:18px;font-weight:700;line-height:1.28;color:#0b1220;margin-bottom:7px">${titleHtml}</div>
5948
- <p style="color:#475569;font-size:14px;line-height:1.55;margin:0 0 15px">${esc(text)}</p>
6152
+ <div style="font-size:18px;font-weight:700;line-height:1.28;color:${pal.ink};margin-bottom:7px">${titleHtml}</div>
6153
+ <p style="color:${pal.body};font-size:14px;line-height:1.55;margin:0 0 15px">${esc(text)}</p>
5949
6154
  ${btn(btnHref, btnText)}
5950
6155
  </div>
5951
6156
  </td></tr>
@@ -5982,10 +6187,10 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
5982
6187
  if (c.tip2) {
5983
6188
  parts.push(`
5984
6189
  <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
5985
- <tr><td style="background:#eff6ff;border-left:4px solid #0091FF;border-radius:10px;padding:16px 18px">
5986
- <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0b66c2;font-weight:700;margin-bottom:5px">⭐ Tip of the week</div>
5987
- <div style="font-size:16px;font-weight:700;color:#0b1220;margin:0 0 5px">${esc(c.tip2.title)}</div>
5988
- <p style="color:#334155;font-size:14px;line-height:1.55;margin:0 0 ${c.tip2.url ? '12px' : '0'}">${esc(c.tip2.text)}</p>
6190
+ <tr><td style="background:${pal.accentCard};border-left:4px solid #0091FF;border-radius:10px;padding:16px 18px">
6191
+ <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:${pal.accentInk};font-weight:700;margin-bottom:5px">⭐ Tip of the week</div>
6192
+ <div style="font-size:16px;font-weight:700;color:${pal.ink};margin:0 0 5px">${esc(c.tip2.title)}</div>
6193
+ <p style="color:${pal.accentBody};font-size:14px;line-height:1.55;margin:0 0 ${c.tip2.url ? '12px' : '0'}">${esc(c.tip2.text)}</p>
5989
6194
  ${c.tip2.url ? btn(c.tip2.url, 'See how') : ''}
5990
6195
  </td></tr>
5991
6196
  </table>`);
@@ -5996,7 +6201,7 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
5996
6201
  const nurl = `https://${host}/news/${c.news.slug}`;
5997
6202
  parts.push(`
5998
6203
  <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
5999
- <tr><td style="background:linear-gradient(160deg,#0b1220,#10213b);border-radius:14px;overflow:hidden">
6204
+ <tr><td style="background:linear-gradient(160deg,#0b1220,#10213b);border-radius:14px;overflow:hidden${sep}">
6000
6205
  <a href="${esc(nurl)}" style="text-decoration:none"><img src="https://${esc(host)}/news/${encodeURIComponent(c.news.slug)}/hero.png" alt="${esc(c.news.headline)}" width="100%" style="display:block;width:100%;max-height:230px;object-fit:cover"></a>
6001
6206
  <div style="padding:18px 20px">
6002
6207
  <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#66BDFF;font-weight:700;margin-bottom:7px">📰 News</div>
@@ -6023,7 +6228,7 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
6023
6228
  const gif = c.feature.gif ? _nl_gif_url(c.feature.gif) : '';
6024
6229
  parts.push(`
6025
6230
  <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
6026
- <tr><td style="background:linear-gradient(160deg,#0b1220,#10213b);border-radius:14px;padding:22px">
6231
+ <tr><td style="background:linear-gradient(160deg,#0b1220,#10213b);border-radius:14px;padding:22px${sep}">
6027
6232
  <div style="display:inline-block;background:#0091FF;color:#fff;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:4px 11px;border-radius:20px;margin-bottom:14px">⚡ Explore a feature</div>
6028
6233
  ${gif ? `<a href="https://${esc(host)}/dashboard" style="text-decoration:none"><img src="${esc(gif)}" alt="${esc(c.feature.label)}" width="100%" style="display:block;width:100%;border-radius:10px;border:1px solid #22344f;margin:0 0 14px"></a>` : ''}
6029
6234
  <div style="font-size:20px;font-weight:700;color:#ffffff;margin:0 0 6px">${esc(c.feature.label)}</div>
@@ -6041,7 +6246,7 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
6041
6246
  : '';
6042
6247
  parts.push(`
6043
6248
  <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 16px">
6044
- <tr><td style="background:linear-gradient(160deg,#160f2b,#0b1220);border-radius:14px;overflow:hidden">
6249
+ <tr><td style="background:linear-gradient(160deg,#160f2b,#0b1220);border-radius:14px;overflow:hidden${sep}">
6045
6250
  ${teeImg}
6046
6251
  <div style="padding:16px 20px 20px">
6047
6252
  <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#66BDFF;font-weight:700;margin-bottom:7px">👕 Wear the brand</div>
@@ -6062,27 +6267,27 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
6062
6267
  if (!p) return '';
6063
6268
  const av = _nl_avatar_url(host, p.pic);
6064
6269
  const avatar = av
6065
- ? `<img src="${esc(av)}" alt="${esc(p.name)}" width="76" height="76" style="border-radius:50%;object-fit:cover;border:2px solid #e9edf3;display:block">`
6270
+ ? `<img src="${esc(av)}" alt="${esc(p.name)}" width="76" height="76" style="border-radius:50%;object-fit:cover;border:2px solid ${pal.cardBorder};display:block">`
6066
6271
  : `<div style="width:76px;height:76px;border-radius:50%;background:#0091FF;color:#fff;font-size:30px;font-weight:800;line-height:76px;text-align:center">${esc((p.name || '?').slice(0, 1))}</div>`;
6067
6272
  const role = _nl_titlecase(p.role || 'Partner');
6068
6273
  const emoji = p.role === 'mentor' ? '🧭' : '📣';
6069
6274
  const where = p.city ? `${role} in ${esc(_nl_titlecase(p.city))}` : role;
6070
6275
  return `
6071
6276
  <table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:separate;margin:0 0 12px">
6072
- <tr><td style="background:#ffffff;border:1px solid #e9edf3;border-radius:14px;padding:18px 20px">
6277
+ <tr><td style="background:${pal.card};border:1px solid ${pal.cardBorder};border-radius:14px;padding:18px 20px">
6073
6278
  <table cellpadding="0" cellspacing="0" width="100%"><tr>
6074
6279
  <td width="88" style="vertical-align:middle">${avatar}</td>
6075
6280
  <td style="vertical-align:middle;padding-left:16px">
6076
6281
  <div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0091FF;font-weight:700;margin-bottom:3px">${emoji} ${esc(where)}</div>
6077
- <div style="font-size:19px;font-weight:800;color:#0b1220;margin:0 0 10px">${esc(p.name)}</div>
6078
- <a href="${esc(p.url)}" style="display:inline-block;background:#eff6ff;color:#0b66c2;text-decoration:none;padding:8px 16px;border-radius:8px;font-weight:700;font-size:13px">View profile &rarr;</a>
6282
+ <div style="font-size:19px;font-weight:800;color:${pal.ink};margin:0 0 10px">${esc(p.name)}</div>
6283
+ <a href="${esc(p.url)}" style="display:inline-block;background:${pal.accentCard};color:${pal.accentInk};text-decoration:none;padding:8px 16px;border-radius:8px;font-weight:700;font-size:13px">View profile &rarr;</a>
6079
6284
  </td>
6080
6285
  </tr></table>
6081
6286
  </td></tr>
6082
6287
  </table>`;
6083
6288
  };
6084
6289
  parts.push(`<p style="text-align:center;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#0091FF;font-weight:700;margin:6px 0 4px">🤝 Your local Xuda team</p>
6085
- <p style="text-align:center;color:#94a3b8;font-size:13px;margin:0 0 14px">Your regional ambassador and mentor are here to help you build.</p>`);
6290
+ <p style="text-align:center;color:${pal.muted};font-size:13px;margin:0 0 14px">Your regional ambassador and mentor are here to help you build.</p>`);
6086
6291
  if (amb) parts.push(person_card(amb));
6087
6292
  if (men) parts.push(person_card(men));
6088
6293
  }
@@ -6091,15 +6296,19 @@ const _nl_render_email = (issue, { host, first_name, fashion, partners, unsub_ur
6091
6296
  let dateStr = '';
6092
6297
  try { dateStr = new Date(issue.created_ts || Date.now()).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); } catch (e) {}
6093
6298
 
6299
+ // In dark mode the outer div paints a dark panel behind the whole newsletter.
6300
+ const shell = dark
6301
+ ? `font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:${pal.pageBg};padding:26px 20px 10px;border-radius:14px`
6302
+ : `font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
6094
6303
  return `
6095
- <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">
6304
+ <div style="${shell}">
6096
6305
  <div style="text-align:center;margin:0 0 8px">
6097
- <span style="display:inline-block;background:#eff6ff;color:#0b66c2;font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:5px 13px;border-radius:20px">Issue #${esc(issue.issue_number)}${dateStr ? ' &middot; ' + esc(dateStr) : ''}</span>
6306
+ <span style="display:inline-block;background:${pal.pill};color:${pal.pillInk};font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:5px 13px;border-radius:20px">Issue #${esc(issue.issue_number)}${dateStr ? ' &middot; ' + esc(dateStr) : ''}</span>
6098
6307
  </div>
6099
- <h1 style="text-align:center;font-size:28px;font-weight:800;color:#0b1220;margin:0 0 8px">Xuda Weekly</h1>
6100
- <p style="text-align:center;color:#475569;font-size:15px;line-height:1.55;margin:0 auto 24px;max-width:400px"><strong style="color:#0b1220">${greet}</strong> here's what's new at Xuda this week: a quick tip, fresh reads, a feature to try, and this week's deal.</p>
6308
+ <h1 style="text-align:center;font-size:28px;font-weight:800;color:${pal.ink};margin:0 0 8px">Xuda Weekly</h1>
6309
+ <p style="text-align:center;color:${pal.body};font-size:15px;line-height:1.55;margin:0 auto 24px;max-width:400px"><strong style="color:${pal.ink}">${greet}</strong> here's what's new at Xuda this week: a quick tip, fresh reads, a feature to try, and this week's deal.</p>
6101
6310
  ${parts.join('')}
6102
- <p style="text-align:center;color:#94a3b8;font-size:12px;line-height:1.7;margin:26px 0 0;border-top:1px solid #e9edf3;padding-top:18px">You're receiving this because you opted in to Xuda updates.<br><a href="${esc(unsub_url)}" style="color:#94a3b8;text-decoration:underline">Unsubscribe</a> &nbsp;&middot;&nbsp; <a href="https://${esc(host)}/dashboard" style="color:#94a3b8;text-decoration:underline">Your account</a></p>
6311
+ <p style="text-align:center;color:${pal.muted};font-size:12px;line-height:1.7;margin:26px 0 0;border-top:1px solid ${pal.footBorder};padding-top:18px">You're receiving this because you opted in to Xuda updates.<br><a href="${esc(unsub_url)}" style="color:${pal.muted};text-decoration:underline">Unsubscribe</a> &nbsp;&middot;&nbsp; <a href="https://${esc(host)}/dashboard" style="color:${pal.muted};text-decoration:underline">Your account</a></p>
6103
6312
  </div>`;
6104
6313
  };
6105
6314
 
@@ -6117,16 +6326,18 @@ const _nl_sample_recipient = (host) => ({
6117
6326
  unsub_url: `https://${host}/newsletter/unsubscribe?u=SAMPLE&t=SAMPLE`,
6118
6327
  });
6119
6328
 
6120
- const _newsletter_send_sample_internal = async (issue, to_email) => {
6329
+ const _newsletter_send_sample_internal = async (issue, to_email, theme) => {
6121
6330
  const host = _ops_host();
6122
6331
  const [fpool, ppool] = await Promise.all([_nl_fashion_pool(), _nl_partner_pool()]);
6123
6332
  const rcpt = _nl_sample_recipient(host);
6124
6333
  rcpt.fashion = _nl_fashion_for(fpool, rcpt.country, rcpt.city, issue.issue_number);
6125
6334
  rcpt.partners = _nl_partners_for(ppool, rcpt.cc, rcpt.country, rcpt.city);
6335
+ rcpt.theme = theme;
6126
6336
  const html = _nl_render_email(issue, rcpt);
6127
6337
  // email_ms (request/response) so we get a real {code} back — email_msa is
6128
6338
  // fire-and-forget and would report undefined.
6129
- return email_ms.send_email({ email: to_email, subject: `[SAMPLE] ${issue.subject}`, body: html, display_type: 'info' });
6339
+ const tag = theme === 'dark' ? '[SAMPLE · DARK] ' : '[SAMPLE] ';
6340
+ return email_ms.send_email({ email: to_email, subject: `${tag}${issue.subject}`, body: html, display_type: 'info' });
6130
6341
  };
6131
6342
 
6132
6343
  // Build the weekly draft: content + coupon + numbered issue, then sample to ops.
@@ -6223,6 +6434,7 @@ export const newsletter_preview = async function (req = {}) {
6223
6434
  const rcpt = _nl_sample_recipient(host);
6224
6435
  rcpt.fashion = _nl_fashion_for(fpool, rcpt.country, rcpt.city, ret.data.issue_number);
6225
6436
  rcpt.partners = _nl_partners_for(ppool, rcpt.cc, rcpt.country, rcpt.city);
6437
+ rcpt.theme = req.theme;
6226
6438
  const html = _nl_render_email(ret.data, rcpt);
6227
6439
  return { code: 1, data: { issue_number: ret.data.issue_number, subject: ret.data.subject, status: ret.data.status, html } };
6228
6440
  } catch (err) {
@@ -6237,8 +6449,8 @@ export const newsletter_send_sample = async function (req = {}) {
6237
6449
  const ret = await db_module.get_couch_doc(NEWSLETTER_DB, id);
6238
6450
  if (ret.code < 0 || !ret.data) return { code: -1, data: 'issue not found' };
6239
6451
  const to = req.email || 'info@xuda.ai';
6240
- const sr = await _newsletter_send_sample_internal(ret.data, to);
6241
- return { code: 1, data: { sent_to: to, email_code: sr && sr.code } };
6452
+ const sr = await _newsletter_send_sample_internal(ret.data, to, req.theme);
6453
+ return { code: 1, data: { sent_to: to, email_code: sr && sr.code, theme: req.theme || 'light' } };
6242
6454
  } catch (err) {
6243
6455
  return { code: -1, data: err.message };
6244
6456
  }
package/index_ms.mjs CHANGED
@@ -121,6 +121,14 @@ export const get_account_external_apps = async function (...args) {
121
121
  return await broker.send_to_queue("get_account_external_apps", ...args);
122
122
  };
123
123
 
124
+ export const get_load_balancer_candidates = async function (...args) {
125
+ return await broker.send_to_queue("get_load_balancer_candidates", ...args);
126
+ };
127
+
128
+ export const get_account_balancers = async function (...args) {
129
+ return await broker.send_to_queue("get_account_balancers", ...args);
130
+ };
131
+
124
132
  export const get_account_info = async function (...args) {
125
133
  return await broker.send_to_queue("get_account_info", ...args);
126
134
  };
package/index_msa.mjs CHANGED
@@ -121,6 +121,14 @@ export const get_account_external_apps = function (...args) {
121
121
  broker.send_to_queue_async("get_account_external_apps", ...args);
122
122
  };
123
123
 
124
+ export const get_load_balancer_candidates = function (...args) {
125
+ broker.send_to_queue_async("get_load_balancer_candidates", ...args);
126
+ };
127
+
128
+ export const get_account_balancers = function (...args) {
129
+ broker.send_to_queue_async("get_account_balancers", ...args);
130
+ };
131
+
124
132
  export const get_account_info = function (...args) {
125
133
  broker.send_to_queue_async("get_account_info", ...args);
126
134
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2281",
3
+ "version": "1.2.2283",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {