@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 +239 -27
- package/index_ms.mjs +8 -0
- package/index_msa.mjs +8 -0
- package/package.json +1 -1
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)} →</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 =
|
|
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
|
|
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
|
|
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
|
|
5948
|
-
<p style="color
|
|
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
|
|
5986
|
-
<div style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;color
|
|
5987
|
-
<div style="font-size:16px;font-weight:700;color
|
|
5988
|
-
<p style="color
|
|
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
|
|
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
|
|
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
|
|
6078
|
-
<a href="${esc(p.url)}" style="display:inline-block;background
|
|
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 →</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
|
|
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="
|
|
6304
|
+
<div style="${shell}">
|
|
6096
6305
|
<div style="text-align:center;margin:0 0 8px">
|
|
6097
|
-
<span style="display:inline-block;background
|
|
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 ? ' · ' + esc(dateStr) : ''}</span>
|
|
6098
6307
|
</div>
|
|
6099
|
-
<h1 style="text-align:center;font-size:28px;font-weight:800;color
|
|
6100
|
-
<p style="text-align:center;color
|
|
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
|
|
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> · <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
|
-
|
|
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
|
};
|