@xuda.io/account_module 1.2.2275 → 1.2.2277
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 +407 -1
- package/index_ms.mjs +28 -0
- package/index_msa.mjs +28 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -54,6 +54,8 @@ const team_ms = await import(`${module_path}/team_module/index_ms.mjs`);
|
|
|
54
54
|
const email_ms = await import(`${module_path}/email_module/index_ms.mjs`);
|
|
55
55
|
const marketplace_ms = await import(`${module_path}/marketplace_module/index_ms.mjs`);
|
|
56
56
|
const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
|
|
57
|
+
// Awaitable app handle for the ops termination flow (power off / destroy).
|
|
58
|
+
const app_ms = await import(`${module_path}/app_module/index_ms.mjs`);
|
|
57
59
|
|
|
58
60
|
const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
|
|
59
61
|
const drive_msa = await import(`${module_path}/drive_module/index_msa.mjs`);
|
|
@@ -1118,6 +1120,192 @@ export const get_account_info = async function (req) {
|
|
|
1118
1120
|
});
|
|
1119
1121
|
};
|
|
1120
1122
|
|
|
1123
|
+
// ===================================================================
|
|
1124
|
+
// OPS ACCOUNT TERMINATION FLOW (manual, superuser-only)
|
|
1125
|
+
// -------------------------------------------------------------------
|
|
1126
|
+
// Escalation ladder for non-payment, driven manually from the ops
|
|
1127
|
+
// dashboard (never automatic): suspend (services OFF) -> terminate
|
|
1128
|
+
// (services OFF + 14-day appeal window) -> finalize (permanent delete)
|
|
1129
|
+
// -> or reinstate. Every step notifies ops. `req.uid` is ALWAYS the
|
|
1130
|
+
// authenticated ops caller (the HTTP layer overwrites it from the
|
|
1131
|
+
// session), so the TARGET account is passed as `account_uid`.
|
|
1132
|
+
// ===================================================================
|
|
1133
|
+
const _OPS_SERVER_TYPES = ['datacenter', 'deployment', 'vps', 'managed_vps', 'instance', 'master'];
|
|
1134
|
+
const _ops_is_super = (req) => !!(req && req.uid && Array.isArray(_conf.superuser_account_ids) && _conf.superuser_account_ids.includes(req.uid));
|
|
1135
|
+
const _ops_host = () => (_conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain) || 'xuda.ai';
|
|
1136
|
+
const _ops_appeal_days = () => Number(_conf.termination?.appeal_days) || 14;
|
|
1137
|
+
|
|
1138
|
+
const _ops_enum_apps = async (uid) => {
|
|
1139
|
+
const ret = await db_module.find_couch_query('xuda_master', { selector: { app_uId: uid, docType: 'app' }, limit: 99999 }, true);
|
|
1140
|
+
return ret.docs || [];
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
// Turn every server-type service for an account OFF (or back ON). Read the
|
|
1144
|
+
// power state via the canonical app_ms wrappers so DO/proxmox teardown is
|
|
1145
|
+
// handled correctly. Returns a small tally.
|
|
1146
|
+
const _ops_services_power = async (uid, on) => {
|
|
1147
|
+
const servers = (await _ops_enum_apps(uid)).filter((a) => _OPS_SERVER_TYPES.includes(a.app_type));
|
|
1148
|
+
let ok = 0, err = 0;
|
|
1149
|
+
for (const a of servers) {
|
|
1150
|
+
try {
|
|
1151
|
+
const r = on ? await app_ms.turn_app_on({ app_id: a._id }) : await app_ms.turn_app_off({ app_id: a._id });
|
|
1152
|
+
if (!r || r.code < 0) err++; else ok++;
|
|
1153
|
+
} catch (e) { err++; console.error('[ops power]', a._id, e.message); }
|
|
1154
|
+
}
|
|
1155
|
+
return { total: servers.length, ok, err };
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
// OPS = the superuser accounts + info@xuda.ai. Sends both an email to info@ and
|
|
1159
|
+
// a system notification to every superuser. `ref` is shown in a small footer.
|
|
1160
|
+
const _ops_notify_ops = (subject, rows, ref) => {
|
|
1161
|
+
try {
|
|
1162
|
+
const esc = (v) => String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1163
|
+
const footer = ref ? `<p style="margin:16px 0 0 0;font-size:11px;color:#999">Ref: ${esc(ref)}</p>` : '';
|
|
1164
|
+
const body = `<h2>${esc(subject)}</h2><table cellpadding="6" style="border-collapse:collapse;font-size:14px">${rows.map(([k, v]) => `<tr><td><strong>${esc(k)}</strong></td><td>${esc(v)}</td></tr>`).join('')}</table>${footer}`;
|
|
1165
|
+
email_msa.send_email({ email: 'info@xuda.ai', subject: `[OPS] ${subject}`, body });
|
|
1166
|
+
const supers = _conf.superuser_account_ids || [];
|
|
1167
|
+
if (supers.length) notification_msa.submit_notification({ type: 'system', uid_arr: supers, system: true, subject: `[OPS] ${subject}`, body, delivery_method: ['banner', 'email'], display_type: 'warning' });
|
|
1168
|
+
} catch (e) { console.error('[ops notify]', e.message); }
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
// Step 1a — SUSPEND: services off, no data loss.
|
|
1172
|
+
export const ops_suspend_account = async function (req = {}) {
|
|
1173
|
+
try {
|
|
1174
|
+
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
1175
|
+
const account_uid = req.account_uid;
|
|
1176
|
+
if (!account_uid) return { code: -1, data: 'account_uid is required' };
|
|
1177
|
+
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1178
|
+
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1179
|
+
const account = ar.data;
|
|
1180
|
+
account.account_suspension_status = 1;
|
|
1181
|
+
account.account_suspension_date = Date.now();
|
|
1182
|
+
account.suspension_reason = req.reason || '';
|
|
1183
|
+
account.suspension_by_uid = req.uid;
|
|
1184
|
+
const sr = await db_module.save_couch_doc('xuda_accounts', account);
|
|
1185
|
+
if (sr.code < 0) return { code: -1, data: sr.data };
|
|
1186
|
+
const power = await _ops_services_power(account_uid, false);
|
|
1187
|
+
const host = _ops_host();
|
|
1188
|
+
notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], topic: 'health_account_suspended', params: { billing_url: `https://${host}/dashboard/settings/billing` } });
|
|
1189
|
+
_ops_notify_ops(`Account suspended: ${account.account_info?.email || account_uid}`, [['Account', account_uid], ['Email', account.account_info?.email], ['By', req.uid], ['Reason', req.reason || ''], ['Services off', `${power.ok}/${power.total}`]], 'A2');
|
|
1190
|
+
logs_msa.add_account_log({ uid: account_uid, method: 'ops_suspend_account', source: 'ops dashboard', response_status_code: 200, response_data: `suspended by ${req.uid}; services off ${power.ok}/${power.total}` });
|
|
1191
|
+
return { code: 1, data: { suspended: account_uid, services: power } };
|
|
1192
|
+
} catch (err) { return { code: -1, data: err.message }; }
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// Step 1b — TERMINATE: services off + start the appeal window. No deletion.
|
|
1196
|
+
export const ops_terminate_account = async function (req = {}) {
|
|
1197
|
+
try {
|
|
1198
|
+
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
1199
|
+
const account_uid = req.account_uid;
|
|
1200
|
+
if (!account_uid) return { code: -1, data: 'account_uid is required' };
|
|
1201
|
+
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1202
|
+
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1203
|
+
const account = ar.data;
|
|
1204
|
+
const appeal_days = _ops_appeal_days();
|
|
1205
|
+
account.account_termination_status = 1;
|
|
1206
|
+
account.account_termination_date = Date.now();
|
|
1207
|
+
account.termination_reason = req.reason || '';
|
|
1208
|
+
account.termination_by_uid = req.uid;
|
|
1209
|
+
account.termination_appeal_deadline = Date.now() + appeal_days * 86400000;
|
|
1210
|
+
account.appeal_status = 'none';
|
|
1211
|
+
account.termination_finalized_ts = null;
|
|
1212
|
+
const sr = await db_module.save_couch_doc('xuda_accounts', account);
|
|
1213
|
+
if (sr.code < 0) return { code: -1, data: sr.data };
|
|
1214
|
+
const power = await _ops_services_power(account_uid, false);
|
|
1215
|
+
const host = _ops_host();
|
|
1216
|
+
notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], topic: 'health_account_terminated', params: { appeal_days, appeal_url: `https://${host}/dashboard/support` } });
|
|
1217
|
+
_ops_notify_ops(`Account terminated (${appeal_days}d appeal): ${account.account_info?.email || account_uid}`, [['Account', account_uid], ['Email', account.account_info?.email], ['By', req.uid], ['Reason', req.reason || ''], ['Services off', `${power.ok}/${power.total}`], ['Appeal deadline', new Date(account.termination_appeal_deadline).toISOString()]], 'A3');
|
|
1218
|
+
logs_msa.add_account_log({ uid: account_uid, method: 'ops_terminate_account', source: 'ops dashboard', response_status_code: 200, response_data: `terminated by ${req.uid}; appeal until ${new Date(account.termination_appeal_deadline).toISOString()}; services off ${power.ok}/${power.total}` });
|
|
1219
|
+
return { code: 1, data: { terminated: account_uid, appeal_deadline: account.termination_appeal_deadline, services: power } };
|
|
1220
|
+
} catch (err) { return { code: -1, data: err.message }; }
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// Step 3 — FINALIZE: NEVER destroys anything. It compiles the list of resources
|
|
1224
|
+
// that should be deleted for a terminated account and REPORTS it to ops, who
|
|
1225
|
+
// then delete manually. Superuser + terminated required.
|
|
1226
|
+
export const ops_finalize_termination = async function (req = {}) {
|
|
1227
|
+
try {
|
|
1228
|
+
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
1229
|
+
const account_uid = req.account_uid;
|
|
1230
|
+
if (!account_uid) return { code: -1, data: 'account_uid is required' };
|
|
1231
|
+
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1232
|
+
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1233
|
+
const account = ar.data;
|
|
1234
|
+
if (account.account_termination_status !== 1) return { code: -1, data: 'account is not in a terminated state' };
|
|
1235
|
+
|
|
1236
|
+
const apps = await _ops_enum_apps(account_uid);
|
|
1237
|
+
const resources = apps.map((a) => ({
|
|
1238
|
+
app_id: a._id,
|
|
1239
|
+
app_type: a.app_type,
|
|
1240
|
+
app_name: a.app_name || a._id,
|
|
1241
|
+
ip: a.app_hosting_server?.ip || '',
|
|
1242
|
+
region: a.app_hosting_server?.region_name || a.app_hosting_server?.provider || '',
|
|
1243
|
+
}));
|
|
1244
|
+
const appeal_window_elapsed = !!(account.termination_appeal_deadline && Date.now() >= account.termination_appeal_deadline);
|
|
1245
|
+
account.termination_reported_ts = Date.now();
|
|
1246
|
+
account.termination_reported_by_uid = req.uid;
|
|
1247
|
+
await db_module.save_couch_doc('xuda_accounts', account);
|
|
1248
|
+
|
|
1249
|
+
const rows = [
|
|
1250
|
+
['Account', account_uid], ['Email', account.account_info?.email], ['Requested by', req.uid],
|
|
1251
|
+
['Appeal window elapsed', appeal_window_elapsed ? 'yes' : 'NO — still within appeal period'],
|
|
1252
|
+
['Resources to delete', resources.length],
|
|
1253
|
+
...resources.map((r, i) => [`#${i + 1} ${r.app_type}`, `${r.app_name} ${r.ip}`.trim()]),
|
|
1254
|
+
];
|
|
1255
|
+
_ops_notify_ops(`Resources to delete (manual) for terminated account: ${account.account_info?.email || account_uid}`, rows, 'OPS-DELETE-LIST');
|
|
1256
|
+
logs_msa.add_account_log({ uid: account_uid, method: 'ops_finalize_termination', security: true, source: 'ops dashboard', response_status_code: 200, response_data: `reported ${resources.length} resource(s) for manual deletion by ${req.uid}; appeal_elapsed=${appeal_window_elapsed}` });
|
|
1257
|
+
return { code: 1, data: { account_uid, appeal_window_elapsed, resources } };
|
|
1258
|
+
} catch (err) { return { code: -1, data: err.message }; }
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// Reinstate (appeal accepted): clear suspension/termination, services back on.
|
|
1262
|
+
export const ops_reinstate_account = async function (req = {}) {
|
|
1263
|
+
try {
|
|
1264
|
+
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
1265
|
+
const account_uid = req.account_uid;
|
|
1266
|
+
if (!account_uid) return { code: -1, data: 'account_uid is required' };
|
|
1267
|
+
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1268
|
+
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1269
|
+
const account = ar.data;
|
|
1270
|
+
if (account.termination_finalized_ts) return { code: -1, data: 'resources already deleted; cannot reinstate' };
|
|
1271
|
+
account.account_suspension_status = 0;
|
|
1272
|
+
account.account_termination_status = 0;
|
|
1273
|
+
account.appeal_status = 'accepted';
|
|
1274
|
+
account.reinstated_ts = Date.now();
|
|
1275
|
+
account.reinstated_by_uid = req.uid;
|
|
1276
|
+
const sr = await db_module.save_couch_doc('xuda_accounts', account);
|
|
1277
|
+
if (sr.code < 0) return { code: -1, data: sr.data };
|
|
1278
|
+
const power = await _ops_services_power(account_uid, true);
|
|
1279
|
+
notification_msa.submit_notification({ type: 'account', uid_arr: [account_uid], subject: 'Your account has been reinstated', body: '<h2>Your account has been reinstated</h2><p>Your account is active again and your services are being restored. Thank you.</p><p style="margin:16px 0 0 0;font-size:11px;color:#999">Ref: OPS-REINST</p>', delivery_method: ['banner', 'email'], display_type: 'info' });
|
|
1280
|
+
_ops_notify_ops(`Account reinstated: ${account.account_info?.email || account_uid}`, [['Account', account_uid], ['Email', account.account_info?.email], ['By', req.uid], ['Services on', `${power.ok}/${power.total}`]], 'OPS-REINST');
|
|
1281
|
+
logs_msa.add_account_log({ uid: account_uid, method: 'ops_reinstate_account', source: 'ops dashboard', response_status_code: 200, response_data: `reinstated by ${req.uid}; services on ${power.ok}/${power.total}` });
|
|
1282
|
+
return { code: 1, data: { reinstated: account_uid, services: power } };
|
|
1283
|
+
} catch (err) { return { code: -1, data: err.message }; }
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
// Ops dashboard queue: accounts currently suspended or terminated.
|
|
1287
|
+
export const ops_list_terminations = async function (req = {}) {
|
|
1288
|
+
try {
|
|
1289
|
+
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
1290
|
+
const ret = await db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', $or: [{ account_suspension_status: 1 }, { account_termination_status: 1 }] }, limit: 9999 }, true);
|
|
1291
|
+
const rows = (ret.docs || []).map((a) => ({
|
|
1292
|
+
account_uid: a._id,
|
|
1293
|
+
email: a.account_info?.email,
|
|
1294
|
+
name: `${a.account_info?.first_name || ''} ${a.account_info?.last_name || ''}`.trim(),
|
|
1295
|
+
suspended: a.account_suspension_status === 1,
|
|
1296
|
+
suspension_date: a.account_suspension_date,
|
|
1297
|
+
terminated: a.account_termination_status === 1,
|
|
1298
|
+
termination_date: a.account_termination_date,
|
|
1299
|
+
reason: a.termination_reason || a.suspension_reason || '',
|
|
1300
|
+
by_uid: a.termination_by_uid || a.suspension_by_uid || '',
|
|
1301
|
+
appeal_deadline: a.termination_appeal_deadline || null,
|
|
1302
|
+
appeal_status: a.appeal_status || 'none',
|
|
1303
|
+
finalized: !!a.termination_finalized_ts,
|
|
1304
|
+
}));
|
|
1305
|
+
return { code: 1, data: { rows } };
|
|
1306
|
+
} catch (err) { return { code: -1, data: err.message }; }
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1121
1309
|
const TIPS_FILE_PATH = path.join(process.env.XUDA_HOME, 'dist', 'tips', 'tips.json');
|
|
1122
1310
|
let _did_you_know_tips_cache = null;
|
|
1123
1311
|
let _did_you_know_tips_cache_mtime = 0;
|
|
@@ -2583,6 +2771,36 @@ export const onboarding_completed = async function (req, job_id, headers) {
|
|
|
2583
2771
|
}
|
|
2584
2772
|
};
|
|
2585
2773
|
|
|
2774
|
+
// One-time admin heads-up to info@xuda.ai on every new signup. Fired from
|
|
2775
|
+
// maybe_send_welcome_email (the universal once-per-account choke point) so it
|
|
2776
|
+
// covers every signup surface. Prod only (gated by the caller) and idempotent
|
|
2777
|
+
// (admin_signup_notified_ts). Fire-and-forget: never blocks or fails the caller.
|
|
2778
|
+
const notify_admin_new_signup = function (account_doc) {
|
|
2779
|
+
try {
|
|
2780
|
+
const info = account_doc.account_info || {};
|
|
2781
|
+
const name = [info.first_name, info.last_name].filter(Boolean).join(' ') || info.username || 'Unknown';
|
|
2782
|
+
const email = info.email || 'unknown';
|
|
2783
|
+
const body = `
|
|
2784
|
+
<h2 style="margin:0 0 14px">New signup on Xuda</h2>
|
|
2785
|
+
<table cellpadding="6" style="border-collapse:collapse;font-size:14px">
|
|
2786
|
+
<tr><td><strong>Name</strong></td><td>${_.escape(name)}</td></tr>
|
|
2787
|
+
<tr><td><strong>Email</strong></td><td>${_.escape(email)}</td></tr>
|
|
2788
|
+
<tr><td><strong>Username</strong></td><td>${_.escape(info.username || '')}</td></tr>
|
|
2789
|
+
<tr><td><strong>Account ID</strong></td><td>${_.escape(account_doc._id || '')}</td></tr>
|
|
2790
|
+
<tr><td><strong>Signed up (UTC)</strong></td><td>${new Date().toISOString()}</td></tr>
|
|
2791
|
+
</table>`;
|
|
2792
|
+
email_msa.send_email({
|
|
2793
|
+
email: 'info@xuda.ai',
|
|
2794
|
+
subject: `New signup: ${name} (${email})`,
|
|
2795
|
+
body,
|
|
2796
|
+
ref: account_doc._id,
|
|
2797
|
+
app_id: null,
|
|
2798
|
+
});
|
|
2799
|
+
} catch (err) {
|
|
2800
|
+
console.error('[notify_admin_new_signup]', err.message);
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
|
|
2586
2804
|
// Sends the welcome_aboard email exactly once per account. Safe to call from
|
|
2587
2805
|
// multiple triggers (avatar-completion, onboarding_completed) and across every
|
|
2588
2806
|
// signup surface (xuda.ai, Google OAuth, xuda.fashion, xuda.network, chat
|
|
@@ -2591,11 +2809,26 @@ export const maybe_send_welcome_email = async function (uid) {
|
|
|
2591
2809
|
try {
|
|
2592
2810
|
const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
|
|
2593
2811
|
if (!account_doc || !account_doc.account_info) return { code: -1, data: 'no account' };
|
|
2812
|
+
|
|
2813
|
+
// Admin new-signup notification (prod only), independent of the user-facing
|
|
2814
|
+
// welcome email so it also covers suppressed ambassadors/mentors. Idempotent
|
|
2815
|
+
// via admin_signup_notified_ts; the flag persists on whichever save below runs.
|
|
2816
|
+
let admin_flag_needs_save = false;
|
|
2817
|
+
if (!_conf.is_debug && !account_doc.admin_signup_notified_ts) {
|
|
2818
|
+
notify_admin_new_signup(account_doc);
|
|
2819
|
+
account_doc.admin_signup_notified_ts = Date.now();
|
|
2820
|
+
admin_flag_needs_save = true;
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2594
2823
|
if (account_doc.account_info.is_xuda_network_ambassador === true) {
|
|
2595
2824
|
console.log('[maybe_send_welcome_email] suppressing for xuda.network ambassador/mentor', uid);
|
|
2825
|
+
if (admin_flag_needs_save) await db_module.save_couch_doc('xuda_accounts', account_doc);
|
|
2596
2826
|
return { code: 0, data: 'ambassador' };
|
|
2597
2827
|
}
|
|
2598
|
-
if (account_doc.welcome_email_sent_ts)
|
|
2828
|
+
if (account_doc.welcome_email_sent_ts) {
|
|
2829
|
+
if (admin_flag_needs_save) await db_module.save_couch_doc('xuda_accounts', account_doc);
|
|
2830
|
+
return { code: 0, data: 'already sent' };
|
|
2831
|
+
}
|
|
2599
2832
|
|
|
2600
2833
|
const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
|
|
2601
2834
|
const username = account_doc.account_info.username || uid;
|
|
@@ -2620,6 +2853,179 @@ export const maybe_send_welcome_email = async function (uid) {
|
|
|
2620
2853
|
}
|
|
2621
2854
|
};
|
|
2622
2855
|
|
|
2856
|
+
// ---- Email bounce lifecycle helpers -----------------------------------------
|
|
2857
|
+
const _bounce_settings_url = () => {
|
|
2858
|
+
const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
|
|
2859
|
+
return `https://${host}/dashboard/settings`;
|
|
2860
|
+
};
|
|
2861
|
+
|
|
2862
|
+
// Alert the user to update their email (banner + SMS per the topic's delivery
|
|
2863
|
+
// methods). SMS is gated/log-only in notification_module until sms_enabled.
|
|
2864
|
+
const _send_bounce_alert = async (account, attempt, max) => {
|
|
2865
|
+
const eb = account.account_email_bounce || {};
|
|
2866
|
+
return await notification_msa.submit_notification({
|
|
2867
|
+
type: 'account',
|
|
2868
|
+
app_id: null,
|
|
2869
|
+
uid_arr: [account._id],
|
|
2870
|
+
topic: 'email_bounce_update_required',
|
|
2871
|
+
params: {
|
|
2872
|
+
recipient_email: eb.recipient || account.account_info?.email || '',
|
|
2873
|
+
settings_url: _bounce_settings_url(),
|
|
2874
|
+
attempt,
|
|
2875
|
+
max,
|
|
2876
|
+
},
|
|
2877
|
+
});
|
|
2878
|
+
};
|
|
2879
|
+
|
|
2880
|
+
// Hard-email-bounce flag. Called (via account_ms RPC) by email_module's
|
|
2881
|
+
// scan_platform_bounces when a platform email to this account's address bounces
|
|
2882
|
+
// permanently (5.x.x). Records the flag and, on the FIRST transition into
|
|
2883
|
+
// flagged, fires alert #1 immediately (attempts 2..max + suspend are driven by
|
|
2884
|
+
// the daily sweep_email_bounces cron). Idempotent: repeated bounces for an
|
|
2885
|
+
// already-flagged/suspended account only refresh last_bounce_ts (no re-alert).
|
|
2886
|
+
export const flag_email_bounce = async function (req) {
|
|
2887
|
+
const { uid, recipient, reason, status_code, message_id, source } = req || {};
|
|
2888
|
+
if (!uid) return { code: -1, data: 'uid required' };
|
|
2889
|
+
try {
|
|
2890
|
+
const acct_ret = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
2891
|
+
if (acct_ret.code < 0) return { code: -1, data: 'account not found' };
|
|
2892
|
+
const account = acct_ret.data;
|
|
2893
|
+
const now = Date.now();
|
|
2894
|
+
const eb = account.account_email_bounce || {};
|
|
2895
|
+
const was_active = eb.status === 'flagged' || eb.status === 'suspended';
|
|
2896
|
+
const max = Number(_conf.bounce_monitor?.suspend_after_alerts || 3);
|
|
2897
|
+
|
|
2898
|
+
eb.recipient = recipient || eb.recipient;
|
|
2899
|
+
eb.reason = reason || eb.reason;
|
|
2900
|
+
eb.status_code = status_code || eb.status_code;
|
|
2901
|
+
eb.last_message_id = message_id || eb.last_message_id;
|
|
2902
|
+
eb.source = source || eb.source || 'dsn';
|
|
2903
|
+
eb.last_bounce_ts = now;
|
|
2904
|
+
eb.bounce_count = (eb.bounce_count || 0) + 1;
|
|
2905
|
+
if (eb.status !== 'suspended') {
|
|
2906
|
+
eb.status = 'flagged';
|
|
2907
|
+
eb.first_bounce_ts = eb.first_bounce_ts || now;
|
|
2908
|
+
if (!was_active) {
|
|
2909
|
+
// Fresh flag -> immediate first alert.
|
|
2910
|
+
eb.attempt_count = 1;
|
|
2911
|
+
eb.first_alert_ts = now;
|
|
2912
|
+
eb.last_attempt_ts = now;
|
|
2913
|
+
} else if (eb.attempt_count == null) {
|
|
2914
|
+
eb.attempt_count = 0;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
account.account_email_bounce = eb;
|
|
2919
|
+
const save_ret = await db_module.save_couch_doc('xuda_accounts', account);
|
|
2920
|
+
if (save_ret.code < 0) return save_ret;
|
|
2921
|
+
|
|
2922
|
+
if (!was_active) {
|
|
2923
|
+
try { await _send_bounce_alert(account, 1, max); } catch (e) { console.error('flag_email_bounce alert #1 failed:', e.message); }
|
|
2924
|
+
}
|
|
2925
|
+
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)' : ''}`);
|
|
2926
|
+
return { code: 1, data: { uid, status: eb.status, attempt_count: eb.attempt_count, bounce_count: eb.bounce_count } };
|
|
2927
|
+
} catch (err) {
|
|
2928
|
+
console.error('flag_email_bounce error:', err);
|
|
2929
|
+
return { code: -10, data: err.message };
|
|
2930
|
+
}
|
|
2931
|
+
};
|
|
2932
|
+
|
|
2933
|
+
// Daily retry/escalation sweep for flagged accounts (controller cron). For each
|
|
2934
|
+
// account with account_email_bounce.status === 'flagged':
|
|
2935
|
+
// - if the user changed their email away from the bounced address -> CLEAR;
|
|
2936
|
+
// - else if under the alert cap and >= alert_interval since last -> next alert;
|
|
2937
|
+
// - else (cap reached) -> SUSPEND (account_email_bounce_suspended) + ops alert.
|
|
2938
|
+
// `req` overrides: { interval_ms, max, limit, uids }.
|
|
2939
|
+
export const sweep_email_bounces = async function (req = {}) {
|
|
2940
|
+
const bm = _conf.bounce_monitor || {};
|
|
2941
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2942
|
+
const interval = Number(req.interval_ms ?? bm.alert_interval_ms ?? DAY_MS);
|
|
2943
|
+
const max = Number(req.max ?? bm.suspend_after_alerts ?? 3);
|
|
2944
|
+
const now = Date.now();
|
|
2945
|
+
const summary = { flagged: 0, cleared: 0, alerted: 0, suspended: 0, skipped: 0 };
|
|
2946
|
+
try {
|
|
2947
|
+
const selector = { 'account_email_bounce.status': 'flagged' };
|
|
2948
|
+
if (Array.isArray(req.uids) && req.uids.length) selector._id = { $in: req.uids };
|
|
2949
|
+
const find_ret = await db_module.find_couch_query('xuda_accounts', {
|
|
2950
|
+
selector,
|
|
2951
|
+
fields: ['_id'],
|
|
2952
|
+
limit: req.limit ?? 99999,
|
|
2953
|
+
});
|
|
2954
|
+
const rows = find_ret?.data?.docs || find_ret?.docs || [];
|
|
2955
|
+
summary.flagged = rows.length;
|
|
2956
|
+
|
|
2957
|
+
for (const row of rows) {
|
|
2958
|
+
const acct_ret = await db_module.get_couch_doc('xuda_accounts', row._id);
|
|
2959
|
+
if (acct_ret.code < 0) continue;
|
|
2960
|
+
const account = acct_ret.data;
|
|
2961
|
+
const eb = account.account_email_bounce;
|
|
2962
|
+
if (!eb || eb.status !== 'flagged') { summary.skipped++; continue; }
|
|
2963
|
+
|
|
2964
|
+
// CLEAR: the user updated their email away from the bounced address.
|
|
2965
|
+
const cur_email = (account.account_info?.email || '').toLowerCase().trim();
|
|
2966
|
+
if (cur_email && cur_email !== (eb.recipient || '').toLowerCase().trim()) {
|
|
2967
|
+
eb.status = 'cleared';
|
|
2968
|
+
eb.cleared_ts = now;
|
|
2969
|
+
account.account_email_bounce = eb;
|
|
2970
|
+
await db_module.save_couch_doc('xuda_accounts', account);
|
|
2971
|
+
summary.cleared++;
|
|
2972
|
+
continue;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// Cadence gate.
|
|
2976
|
+
if (eb.last_attempt_ts && now - eb.last_attempt_ts < interval) { summary.skipped++; continue; }
|
|
2977
|
+
|
|
2978
|
+
if ((eb.attempt_count || 0) < max) {
|
|
2979
|
+
const next = (eb.attempt_count || 0) + 1;
|
|
2980
|
+
eb.attempt_count = next;
|
|
2981
|
+
eb.last_attempt_ts = now;
|
|
2982
|
+
account.account_email_bounce = eb;
|
|
2983
|
+
await db_module.save_couch_doc('xuda_accounts', account);
|
|
2984
|
+
try { await _send_bounce_alert(account, next, max); } catch (e) { console.error('bounce alert failed:', e.message); }
|
|
2985
|
+
summary.alerted++;
|
|
2986
|
+
} else {
|
|
2987
|
+
// SUSPEND after the alert cap. Dedicated flag (enforced at
|
|
2988
|
+
// router_module.validate_app_active_status alongside billing
|
|
2989
|
+
// suspension) so we never clobber account_suspension_status.
|
|
2990
|
+
eb.status = 'suspended';
|
|
2991
|
+
eb.suspended_ts = now;
|
|
2992
|
+
account.account_email_bounce = eb;
|
|
2993
|
+
account.account_email_bounce_suspended = 1;
|
|
2994
|
+
await db_module.save_couch_doc('xuda_accounts', account);
|
|
2995
|
+
|
|
2996
|
+
const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
|
|
2997
|
+
const supers = _conf.superuser_account_ids || [];
|
|
2998
|
+
if (supers.length) {
|
|
2999
|
+
try {
|
|
3000
|
+
await notification_msa.submit_notification({
|
|
3001
|
+
type: 'account',
|
|
3002
|
+
app_id: null,
|
|
3003
|
+
uid_arr: supers,
|
|
3004
|
+
topic: 'email_bounce_suspended',
|
|
3005
|
+
system: true,
|
|
3006
|
+
params: {
|
|
3007
|
+
uid: account._id,
|
|
3008
|
+
account_email: account.account_info?.email || eb.recipient || '',
|
|
3009
|
+
recipient: eb.recipient || '',
|
|
3010
|
+
reason: eb.reason || '',
|
|
3011
|
+
status_code: eb.status_code || '',
|
|
3012
|
+
attempts: eb.attempt_count || max,
|
|
3013
|
+
domain: host,
|
|
3014
|
+
},
|
|
3015
|
+
});
|
|
3016
|
+
} catch (e) { console.error('ops bounce-suspend alert failed:', e.message); }
|
|
3017
|
+
}
|
|
3018
|
+
summary.suspended++;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
console.log('sweep_email_bounces:', JSON.stringify(summary));
|
|
3022
|
+
return { code: 1, data: summary };
|
|
3023
|
+
} catch (err) {
|
|
3024
|
+
console.error('sweep_email_bounces error:', err);
|
|
3025
|
+
return { code: -10, data: err.message };
|
|
3026
|
+
}
|
|
3027
|
+
};
|
|
3028
|
+
|
|
2623
3029
|
// Every-3-days verification nudge for accounts still at stat 1 (unverified).
|
|
2624
3030
|
// Runs from the controller's daily cron (master/dev only); the daily tick plus
|
|
2625
3031
|
// the per-account 3-day gate below yields the every-3-days cadence. Each email
|
package/index_ms.mjs
CHANGED
|
@@ -89,6 +89,26 @@ export const get_account_info = async function (...args) {
|
|
|
89
89
|
return await broker.send_to_queue("get_account_info", ...args);
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
export const ops_suspend_account = async function (...args) {
|
|
93
|
+
return await broker.send_to_queue("ops_suspend_account", ...args);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const ops_terminate_account = async function (...args) {
|
|
97
|
+
return await broker.send_to_queue("ops_terminate_account", ...args);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const ops_finalize_termination = async function (...args) {
|
|
101
|
+
return await broker.send_to_queue("ops_finalize_termination", ...args);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const ops_reinstate_account = async function (...args) {
|
|
105
|
+
return await broker.send_to_queue("ops_reinstate_account", ...args);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const ops_list_terminations = async function (...args) {
|
|
109
|
+
return await broker.send_to_queue("ops_list_terminations", ...args);
|
|
110
|
+
};
|
|
111
|
+
|
|
92
112
|
export const did_you_know_tips = async function (...args) {
|
|
93
113
|
return await broker.send_to_queue("did_you_know_tips", ...args);
|
|
94
114
|
};
|
|
@@ -209,6 +229,14 @@ export const maybe_send_welcome_email = async function (...args) {
|
|
|
209
229
|
return await broker.send_to_queue("maybe_send_welcome_email", ...args);
|
|
210
230
|
};
|
|
211
231
|
|
|
232
|
+
export const flag_email_bounce = async function (...args) {
|
|
233
|
+
return await broker.send_to_queue("flag_email_bounce", ...args);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const sweep_email_bounces = async function (...args) {
|
|
237
|
+
return await broker.send_to_queue("sweep_email_bounces", ...args);
|
|
238
|
+
};
|
|
239
|
+
|
|
212
240
|
export const send_verification_reminders = async function (...args) {
|
|
213
241
|
return await broker.send_to_queue("send_verification_reminders", ...args);
|
|
214
242
|
};
|
package/index_msa.mjs
CHANGED
|
@@ -89,6 +89,26 @@ export const get_account_info = function (...args) {
|
|
|
89
89
|
broker.send_to_queue_async("get_account_info", ...args);
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
export const ops_suspend_account = function (...args) {
|
|
93
|
+
broker.send_to_queue_async("ops_suspend_account", ...args);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const ops_terminate_account = function (...args) {
|
|
97
|
+
broker.send_to_queue_async("ops_terminate_account", ...args);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const ops_finalize_termination = function (...args) {
|
|
101
|
+
broker.send_to_queue_async("ops_finalize_termination", ...args);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const ops_reinstate_account = function (...args) {
|
|
105
|
+
broker.send_to_queue_async("ops_reinstate_account", ...args);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const ops_list_terminations = function (...args) {
|
|
109
|
+
broker.send_to_queue_async("ops_list_terminations", ...args);
|
|
110
|
+
};
|
|
111
|
+
|
|
92
112
|
export const did_you_know_tips = function (...args) {
|
|
93
113
|
broker.send_to_queue_async("did_you_know_tips", ...args);
|
|
94
114
|
};
|
|
@@ -209,6 +229,14 @@ export const maybe_send_welcome_email = function (...args) {
|
|
|
209
229
|
broker.send_to_queue_async("maybe_send_welcome_email", ...args);
|
|
210
230
|
};
|
|
211
231
|
|
|
232
|
+
export const flag_email_bounce = function (...args) {
|
|
233
|
+
broker.send_to_queue_async("flag_email_bounce", ...args);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const sweep_email_bounces = function (...args) {
|
|
237
|
+
broker.send_to_queue_async("sweep_email_bounces", ...args);
|
|
238
|
+
};
|
|
239
|
+
|
|
212
240
|
export const send_verification_reminders = function (...args) {
|
|
213
241
|
broker.send_to_queue_async("send_verification_reminders", ...args);
|
|
214
242
|
};
|