@xuda.io/account_module 1.2.2274 → 1.2.2276

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
@@ -1415,7 +1415,30 @@ export const verify_account = async function (req) {
1415
1415
  if (ret.code < 0) {
1416
1416
  return ret_acc;
1417
1417
  }
1418
- return { code: 1, data: ret_acc.data };
1418
+
1419
+ // Retire the "Confirm your email" banner notification (created at signup)
1420
+ // now that the account is verified: mark it read and push the updated doc
1421
+ // over WS — the dashboard's SAVE_notification mutation removes read docs
1422
+ // live, so any open tab drops the banner without a refresh. Non-fatal.
1423
+ try {
1424
+ const n_ret = await db_module.find_couch_query('xuda_notification', {
1425
+ selector: { uid: account_id, topic: 'confirm_email', read: false },
1426
+ limit: 10,
1427
+ });
1428
+ for (const n_doc of n_ret?.data?.docs || n_ret?.docs || []) {
1429
+ n_doc.read = true;
1430
+ n_doc.read_ts = Date.now();
1431
+ n_doc.read_note = 'account verified';
1432
+ await db_module.save_couch_doc('xuda_notification', n_doc);
1433
+ ws_dashboard_msa.notification({ to: account_id, data: n_doc });
1434
+ }
1435
+ } catch (e) {
1436
+ console.warn('[verify_account] confirm_email banner cleanup failed:', e.message);
1437
+ }
1438
+
1439
+ // is_boarded rides along so the verify route can tell the client whether
1440
+ // onboarding is still pending (it is, for a fresh email signup).
1441
+ return { code: 1, data: { ...ret_acc.data, is_boarded: !!obj.isBoarded } };
1419
1442
  };
1420
1443
 
1421
1444
  export const validate_user_plan = async function (req) {
@@ -2560,6 +2583,36 @@ export const onboarding_completed = async function (req, job_id, headers) {
2560
2583
  }
2561
2584
  };
2562
2585
 
2586
+ // One-time admin heads-up to info@xuda.ai on every new signup. Fired from
2587
+ // maybe_send_welcome_email (the universal once-per-account choke point) so it
2588
+ // covers every signup surface. Prod only (gated by the caller) and idempotent
2589
+ // (admin_signup_notified_ts). Fire-and-forget: never blocks or fails the caller.
2590
+ const notify_admin_new_signup = function (account_doc) {
2591
+ try {
2592
+ const info = account_doc.account_info || {};
2593
+ const name = [info.first_name, info.last_name].filter(Boolean).join(' ') || info.username || 'Unknown';
2594
+ const email = info.email || 'unknown';
2595
+ const body = `
2596
+ <h2 style="margin:0 0 14px">New signup on Xuda</h2>
2597
+ <table cellpadding="6" style="border-collapse:collapse;font-size:14px">
2598
+ <tr><td><strong>Name</strong></td><td>${_.escape(name)}</td></tr>
2599
+ <tr><td><strong>Email</strong></td><td>${_.escape(email)}</td></tr>
2600
+ <tr><td><strong>Username</strong></td><td>${_.escape(info.username || '')}</td></tr>
2601
+ <tr><td><strong>Account ID</strong></td><td>${_.escape(account_doc._id || '')}</td></tr>
2602
+ <tr><td><strong>Signed up (UTC)</strong></td><td>${new Date().toISOString()}</td></tr>
2603
+ </table>`;
2604
+ email_msa.send_email({
2605
+ email: 'info@xuda.ai',
2606
+ subject: `New signup: ${name} (${email})`,
2607
+ body,
2608
+ ref: account_doc._id,
2609
+ app_id: null,
2610
+ });
2611
+ } catch (err) {
2612
+ console.error('[notify_admin_new_signup]', err.message);
2613
+ }
2614
+ };
2615
+
2563
2616
  // Sends the welcome_aboard email exactly once per account. Safe to call from
2564
2617
  // multiple triggers (avatar-completion, onboarding_completed) and across every
2565
2618
  // signup surface (xuda.ai, Google OAuth, xuda.fashion, xuda.network, chat
@@ -2568,11 +2621,26 @@ export const maybe_send_welcome_email = async function (uid) {
2568
2621
  try {
2569
2622
  const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
2570
2623
  if (!account_doc || !account_doc.account_info) return { code: -1, data: 'no account' };
2624
+
2625
+ // Admin new-signup notification (prod only), independent of the user-facing
2626
+ // welcome email so it also covers suppressed ambassadors/mentors. Idempotent
2627
+ // via admin_signup_notified_ts; the flag persists on whichever save below runs.
2628
+ let admin_flag_needs_save = false;
2629
+ if (!_conf.is_debug && !account_doc.admin_signup_notified_ts) {
2630
+ notify_admin_new_signup(account_doc);
2631
+ account_doc.admin_signup_notified_ts = Date.now();
2632
+ admin_flag_needs_save = true;
2633
+ }
2634
+
2571
2635
  if (account_doc.account_info.is_xuda_network_ambassador === true) {
2572
2636
  console.log('[maybe_send_welcome_email] suppressing for xuda.network ambassador/mentor', uid);
2637
+ if (admin_flag_needs_save) await db_module.save_couch_doc('xuda_accounts', account_doc);
2573
2638
  return { code: 0, data: 'ambassador' };
2574
2639
  }
2575
- if (account_doc.welcome_email_sent_ts) return { code: 0, data: 'already sent' };
2640
+ if (account_doc.welcome_email_sent_ts) {
2641
+ if (admin_flag_needs_save) await db_module.save_couch_doc('xuda_accounts', account_doc);
2642
+ return { code: 0, data: 'already sent' };
2643
+ }
2576
2644
 
2577
2645
  const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
2578
2646
  const username = account_doc.account_info.username || uid;
@@ -2597,6 +2665,252 @@ export const maybe_send_welcome_email = async function (uid) {
2597
2665
  }
2598
2666
  };
2599
2667
 
2668
+ // ---- Email bounce lifecycle helpers -----------------------------------------
2669
+ const _bounce_settings_url = () => {
2670
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
2671
+ return `https://${host}/dashboard/settings`;
2672
+ };
2673
+
2674
+ // Alert the user to update their email (banner + SMS per the topic's delivery
2675
+ // methods). SMS is gated/log-only in notification_module until sms_enabled.
2676
+ const _send_bounce_alert = async (account, attempt, max) => {
2677
+ const eb = account.account_email_bounce || {};
2678
+ return await notification_msa.submit_notification({
2679
+ type: 'account',
2680
+ app_id: null,
2681
+ uid_arr: [account._id],
2682
+ topic: 'email_bounce_update_required',
2683
+ params: {
2684
+ recipient_email: eb.recipient || account.account_info?.email || '',
2685
+ settings_url: _bounce_settings_url(),
2686
+ attempt,
2687
+ max,
2688
+ },
2689
+ });
2690
+ };
2691
+
2692
+ // Hard-email-bounce flag. Called (via account_ms RPC) by email_module's
2693
+ // scan_platform_bounces when a platform email to this account's address bounces
2694
+ // permanently (5.x.x). Records the flag and, on the FIRST transition into
2695
+ // flagged, fires alert #1 immediately (attempts 2..max + suspend are driven by
2696
+ // the daily sweep_email_bounces cron). Idempotent: repeated bounces for an
2697
+ // already-flagged/suspended account only refresh last_bounce_ts (no re-alert).
2698
+ export const flag_email_bounce = async function (req) {
2699
+ const { uid, recipient, reason, status_code, message_id, source } = req || {};
2700
+ if (!uid) return { code: -1, data: 'uid required' };
2701
+ try {
2702
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
2703
+ if (acct_ret.code < 0) return { code: -1, data: 'account not found' };
2704
+ const account = acct_ret.data;
2705
+ const now = Date.now();
2706
+ const eb = account.account_email_bounce || {};
2707
+ const was_active = eb.status === 'flagged' || eb.status === 'suspended';
2708
+ const max = Number(_conf.bounce_monitor?.suspend_after_alerts || 3);
2709
+
2710
+ eb.recipient = recipient || eb.recipient;
2711
+ eb.reason = reason || eb.reason;
2712
+ eb.status_code = status_code || eb.status_code;
2713
+ eb.last_message_id = message_id || eb.last_message_id;
2714
+ eb.source = source || eb.source || 'dsn';
2715
+ eb.last_bounce_ts = now;
2716
+ eb.bounce_count = (eb.bounce_count || 0) + 1;
2717
+ if (eb.status !== 'suspended') {
2718
+ eb.status = 'flagged';
2719
+ eb.first_bounce_ts = eb.first_bounce_ts || now;
2720
+ if (!was_active) {
2721
+ // Fresh flag -> immediate first alert.
2722
+ eb.attempt_count = 1;
2723
+ eb.first_alert_ts = now;
2724
+ eb.last_attempt_ts = now;
2725
+ } else if (eb.attempt_count == null) {
2726
+ eb.attempt_count = 0;
2727
+ }
2728
+ }
2729
+
2730
+ account.account_email_bounce = eb;
2731
+ const save_ret = await db_module.save_couch_doc('xuda_accounts', account);
2732
+ if (save_ret.code < 0) return save_ret;
2733
+
2734
+ if (!was_active) {
2735
+ try { await _send_bounce_alert(account, 1, max); } catch (e) { console.error('flag_email_bounce alert #1 failed:', e.message); }
2736
+ }
2737
+ console.log(`flag_email_bounce: ${uid} <${recipient}> ${status_code || ''} -> status=${eb.status} attempt=${eb.attempt_count} bounce_count=${eb.bounce_count}${!was_active ? ' (alert #1 sent)' : ''}`);
2738
+ return { code: 1, data: { uid, status: eb.status, attempt_count: eb.attempt_count, bounce_count: eb.bounce_count } };
2739
+ } catch (err) {
2740
+ console.error('flag_email_bounce error:', err);
2741
+ return { code: -10, data: err.message };
2742
+ }
2743
+ };
2744
+
2745
+ // Daily retry/escalation sweep for flagged accounts (controller cron). For each
2746
+ // account with account_email_bounce.status === 'flagged':
2747
+ // - if the user changed their email away from the bounced address -> CLEAR;
2748
+ // - else if under the alert cap and >= alert_interval since last -> next alert;
2749
+ // - else (cap reached) -> SUSPEND (account_email_bounce_suspended) + ops alert.
2750
+ // `req` overrides: { interval_ms, max, limit, uids }.
2751
+ export const sweep_email_bounces = async function (req = {}) {
2752
+ const bm = _conf.bounce_monitor || {};
2753
+ const DAY_MS = 24 * 60 * 60 * 1000;
2754
+ const interval = Number(req.interval_ms ?? bm.alert_interval_ms ?? DAY_MS);
2755
+ const max = Number(req.max ?? bm.suspend_after_alerts ?? 3);
2756
+ const now = Date.now();
2757
+ const summary = { flagged: 0, cleared: 0, alerted: 0, suspended: 0, skipped: 0 };
2758
+ try {
2759
+ const selector = { 'account_email_bounce.status': 'flagged' };
2760
+ if (Array.isArray(req.uids) && req.uids.length) selector._id = { $in: req.uids };
2761
+ const find_ret = await db_module.find_couch_query('xuda_accounts', {
2762
+ selector,
2763
+ fields: ['_id'],
2764
+ limit: req.limit ?? 99999,
2765
+ });
2766
+ const rows = find_ret?.data?.docs || find_ret?.docs || [];
2767
+ summary.flagged = rows.length;
2768
+
2769
+ for (const row of rows) {
2770
+ const acct_ret = await db_module.get_couch_doc('xuda_accounts', row._id);
2771
+ if (acct_ret.code < 0) continue;
2772
+ const account = acct_ret.data;
2773
+ const eb = account.account_email_bounce;
2774
+ if (!eb || eb.status !== 'flagged') { summary.skipped++; continue; }
2775
+
2776
+ // CLEAR: the user updated their email away from the bounced address.
2777
+ const cur_email = (account.account_info?.email || '').toLowerCase().trim();
2778
+ if (cur_email && cur_email !== (eb.recipient || '').toLowerCase().trim()) {
2779
+ eb.status = 'cleared';
2780
+ eb.cleared_ts = now;
2781
+ account.account_email_bounce = eb;
2782
+ await db_module.save_couch_doc('xuda_accounts', account);
2783
+ summary.cleared++;
2784
+ continue;
2785
+ }
2786
+
2787
+ // Cadence gate.
2788
+ if (eb.last_attempt_ts && now - eb.last_attempt_ts < interval) { summary.skipped++; continue; }
2789
+
2790
+ if ((eb.attempt_count || 0) < max) {
2791
+ const next = (eb.attempt_count || 0) + 1;
2792
+ eb.attempt_count = next;
2793
+ eb.last_attempt_ts = now;
2794
+ account.account_email_bounce = eb;
2795
+ await db_module.save_couch_doc('xuda_accounts', account);
2796
+ try { await _send_bounce_alert(account, next, max); } catch (e) { console.error('bounce alert failed:', e.message); }
2797
+ summary.alerted++;
2798
+ } else {
2799
+ // SUSPEND after the alert cap. Dedicated flag (enforced at
2800
+ // router_module.validate_app_active_status alongside billing
2801
+ // suspension) so we never clobber account_suspension_status.
2802
+ eb.status = 'suspended';
2803
+ eb.suspended_ts = now;
2804
+ account.account_email_bounce = eb;
2805
+ account.account_email_bounce_suspended = 1;
2806
+ await db_module.save_couch_doc('xuda_accounts', account);
2807
+
2808
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
2809
+ const supers = _conf.superuser_account_ids || [];
2810
+ if (supers.length) {
2811
+ try {
2812
+ await notification_msa.submit_notification({
2813
+ type: 'account',
2814
+ app_id: null,
2815
+ uid_arr: supers,
2816
+ topic: 'email_bounce_suspended',
2817
+ system: true,
2818
+ params: {
2819
+ uid: account._id,
2820
+ account_email: account.account_info?.email || eb.recipient || '',
2821
+ recipient: eb.recipient || '',
2822
+ reason: eb.reason || '',
2823
+ status_code: eb.status_code || '',
2824
+ attempts: eb.attempt_count || max,
2825
+ domain: host,
2826
+ },
2827
+ });
2828
+ } catch (e) { console.error('ops bounce-suspend alert failed:', e.message); }
2829
+ }
2830
+ summary.suspended++;
2831
+ }
2832
+ }
2833
+ console.log('sweep_email_bounces:', JSON.stringify(summary));
2834
+ return { code: 1, data: summary };
2835
+ } catch (err) {
2836
+ console.error('sweep_email_bounces error:', err);
2837
+ return { code: -10, data: err.message };
2838
+ }
2839
+ };
2840
+
2841
+ // Every-3-days verification nudge for accounts still at stat 1 (unverified).
2842
+ // Runs from the controller's daily cron (master/dev only); the daily tick plus
2843
+ // the per-account 3-day gate below yields the every-3-days cadence. Each email
2844
+ // carries a rotating did-you-know card. Capped at MAX_VERIFICATION_REMINDERS
2845
+ // per account. `req` may override the knobs for testing:
2846
+ // { min_age_ms, interval_ms, max, limit, uids: ['acc_…'] }
2847
+ // (`uids` restricts the sweep to specific accounts — for E2E tests.)
2848
+ export const send_verification_reminders = async function (req) {
2849
+ const DAY_MS = 24 * 60 * 60 * 1000;
2850
+ const MIN_AGE_MS = req?.min_age_ms ?? 3 * DAY_MS; // don't nudge before 3 days old
2851
+ const INTERVAL_MS = req?.interval_ms ?? 3 * DAY_MS; // and at most every 3 days
2852
+ const MAX_VERIFICATION_REMINDERS = req?.max ?? 5;
2853
+ try {
2854
+ const selector = { stat: 1, docType: 'account' };
2855
+ if (Array.isArray(req?.uids) && req.uids.length) selector._id = { $in: req.uids };
2856
+ const find_ret = await db_module.find_couch_query('xuda_accounts', {
2857
+ selector,
2858
+ fields: ['_id', 'date_created_ts', 'verification_reminder_last_ts', 'verification_reminder_count', 'account_info.email'],
2859
+ limit: req?.limit ?? 99999,
2860
+ });
2861
+ const rows = find_ret?.data?.docs || find_ret?.docs || [];
2862
+ const now = Date.now();
2863
+ const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
2864
+ const base = `https://${host}`;
2865
+ const tips = load_did_you_know_tips();
2866
+ let sent = 0;
2867
+
2868
+ for (const row of rows) {
2869
+ const uid = row._id;
2870
+ const count = row.verification_reminder_count || 0;
2871
+ if (count >= MAX_VERIFICATION_REMINDERS) continue;
2872
+ if (!row.account_info?.email) continue;
2873
+ const age = now - (row.date_created_ts || 0);
2874
+ if (!row.date_created_ts || age < MIN_AGE_MS) continue;
2875
+ if (row.verification_reminder_last_ts && now - row.verification_reminder_last_ts < INTERVAL_MS) continue;
2876
+
2877
+ // Rotate the card: offset by a uid hash so users don't all get the same
2878
+ // tip, advance by count so each reminder shows a fresh one.
2879
+ let card = { title: 'Did you know?', text: 'Xuda is an all-in-one AI platform — chats, apps, websites, hosting and more.', image: '', url: `${base}/dashboard` };
2880
+ if (tips.length) {
2881
+ const uid_hash = String(uid).split('').reduce((a, c) => a + c.charCodeAt(0), 0);
2882
+ const tip = tips[(uid_hash + count) % tips.length];
2883
+ card = { title: tip.title, text: tip.text, image: tip.image ? `${base}${tip.image}` : '', url: tip.url || `${base}/dashboard` };
2884
+ }
2885
+
2886
+ await notification_msa.submit_notification({
2887
+ type: 'account',
2888
+ app_id: null,
2889
+ uid_arr: [uid],
2890
+ topic: 'verification_reminder',
2891
+ params: { hostname: host, ref: uid, card_title: card.title, card_text: card.text, card_image: card.image, card_url: card.url },
2892
+ ref: null,
2893
+ email: null,
2894
+ });
2895
+
2896
+ // Persist the cadence state on the account doc.
2897
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
2898
+ if (account_doc) {
2899
+ account_doc.verification_reminder_last_ts = now;
2900
+ account_doc.verification_reminder_count = count + 1;
2901
+ await db_module.save_couch_doc('xuda_accounts', account_doc);
2902
+ }
2903
+ sent++;
2904
+ }
2905
+
2906
+ console.log(`[send_verification_reminders] scanned ${rows.length} unverified accounts, sent ${sent}`);
2907
+ return { code: 1, data: { scanned: rows.length, sent } };
2908
+ } catch (err) {
2909
+ console.error('[send_verification_reminders]', err.message);
2910
+ return { code: -1, data: err.message };
2911
+ }
2912
+ };
2913
+
2600
2914
  // Resolve an account uid from a public username (account_info.username).
2601
2915
  // Used by the public-profile route so /public_profiles/<username> resolves.
2602
2916
  export const get_uid_by_username = async function (username) {
package/index_ms.mjs CHANGED
@@ -209,6 +209,18 @@ export const maybe_send_welcome_email = async function (...args) {
209
209
  return await broker.send_to_queue("maybe_send_welcome_email", ...args);
210
210
  };
211
211
 
212
+ export const flag_email_bounce = async function (...args) {
213
+ return await broker.send_to_queue("flag_email_bounce", ...args);
214
+ };
215
+
216
+ export const sweep_email_bounces = async function (...args) {
217
+ return await broker.send_to_queue("sweep_email_bounces", ...args);
218
+ };
219
+
220
+ export const send_verification_reminders = async function (...args) {
221
+ return await broker.send_to_queue("send_verification_reminders", ...args);
222
+ };
223
+
212
224
  export const get_uid_by_username = async function (...args) {
213
225
  return await broker.send_to_queue("get_uid_by_username", ...args);
214
226
  };
package/index_msa.mjs CHANGED
@@ -209,6 +209,18 @@ export const maybe_send_welcome_email = function (...args) {
209
209
  broker.send_to_queue_async("maybe_send_welcome_email", ...args);
210
210
  };
211
211
 
212
+ export const flag_email_bounce = function (...args) {
213
+ broker.send_to_queue_async("flag_email_bounce", ...args);
214
+ };
215
+
216
+ export const sweep_email_bounces = function (...args) {
217
+ broker.send_to_queue_async("sweep_email_bounces", ...args);
218
+ };
219
+
220
+ export const send_verification_reminders = function (...args) {
221
+ broker.send_to_queue_async("send_verification_reminders", ...args);
222
+ };
223
+
212
224
  export const get_uid_by_username = function (...args) {
213
225
  broker.send_to_queue_async("get_uid_by_username", ...args);
214
226
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2274",
3
+ "version": "1.2.2276",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {