@mohasinac/appkit 4.35.1 → 4.35.2

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.
Files changed (50) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +28 -22
  2. package/dist/_internal/server/features/orders/actions.js +58 -49
  3. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  4. package/dist/_internal/server/features/site-settings/adapters.js +8 -0
  5. package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
  6. package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
  7. package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
  8. package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
  9. package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
  10. package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
  11. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
  12. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
  13. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
  14. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
  15. package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
  16. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
  17. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
  18. package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
  19. package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
  20. package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
  21. package/dist/_internal/server/notifications/send-guard.js +142 -0
  22. package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
  23. package/dist/_internal/shared/features/messaging/config.js +44 -0
  24. package/dist/_internal/shared/features/notifications/action-url.js +9 -0
  25. package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
  26. package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
  27. package/dist/constants/api-endpoints.d.ts +30 -0
  28. package/dist/constants/api-endpoints.js +10 -0
  29. package/dist/constants/notification-types.d.ts +1 -1
  30. package/dist/features/admin/actions/notification-actions.d.ts +20 -0
  31. package/dist/features/admin/actions/notification-actions.js +43 -1
  32. package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
  33. package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
  34. package/dist/features/admin/schemas/firestore.d.ts +32 -2
  35. package/dist/features/admin/schemas/firestore.js +28 -0
  36. package/dist/features/auth/hooks/useAuth.d.ts +5 -0
  37. package/dist/features/auth/hooks/useAuth.js +42 -0
  38. package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
  39. package/dist/features/contact/email.d.ts +30 -26
  40. package/dist/features/contact/email.js +65 -105
  41. package/dist/features/email/notification-templates.js +11 -0
  42. package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
  43. package/dist/features/messaging/repository/message-budget.repository.js +138 -0
  44. package/dist/features/messaging/schemas/firestore.d.ts +62 -0
  45. package/dist/features/messaging/schemas/firestore.js +36 -0
  46. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
  47. package/dist/seed/site-settings-seed-data.js +39 -10
  48. package/dist/server.d.ts +3 -5
  49. package/dist/server.js +27 -13
  50. package/package.json +1 -1
@@ -306,7 +306,17 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
306
306
  // Daily ops digest — recipients are edited as one-per-line text, split on save.
307
307
  const [digestEnabled, setDigestEnabled] = React.useState(false);
308
308
  const [digestRecipients, setDigestRecipients] = React.useState("");
309
- const [digestCcRecipients, setDigestCcRecipients] = React.useState("");
309
+ /*
310
+ * Outbound messaging: the kill switch and the daily ceiling.
311
+ *
312
+ * `emailEnabled` suppresses USER-facing mail only. Staff mail — the daily
313
+ * digest, the payout summary — keeps flowing regardless, because switching
314
+ * off customer email must never also blind the operator.
315
+ */
316
+ const [msgEmailEnabled, setMsgEmailEnabled] = React.useState(false);
317
+ const [msgWhatsappEnabled, setMsgWhatsappEnabled] = React.useState(false);
318
+ const [msgCeilingEmail, setMsgCeilingEmail] = React.useState(80);
319
+ const [msgCeilingWhatsapp, setMsgCeilingWhatsapp] = React.useState(200);
310
320
  // Snapshot of the masked placeholder strings the server returned for every
311
321
  // credentials.* field, captured once per load. A combined single-save has
312
322
  // to send the FULL credentials object on every save (not just the group the
@@ -490,7 +500,13 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
490
500
  setNotifFromName(s.emailSettings?.fromName ?? "");
491
501
  setDigestEnabled(s.emailSettings?.dailyDigest?.enabled ?? false);
492
502
  setDigestRecipients((s.emailSettings?.dailyDigest?.recipients ?? []).join("\n"));
493
- setDigestCcRecipients((s.emailSettings?.dailyDigest?.ccRecipients ?? []).join("\n"));
503
+ // Defaults mirror MESSAGE_BUDGET_DEFAULTS — both channels OFF, so a
504
+ // settings document written before this feature existed reads as quiet
505
+ // rather than as "on and unmetered".
506
+ setMsgEmailEnabled(s.messaging?.emailEnabled ?? false);
507
+ setMsgWhatsappEnabled(s.messaging?.whatsappEnabled ?? false);
508
+ setMsgCeilingEmail(s.messaging?.dailyCeiling?.email ?? 80);
509
+ setMsgCeilingWhatsapp(s.messaging?.dailyCeiling?.whatsapp ?? 200);
494
510
  originalMaskedRef.current = {
495
511
  razorpayKeyId: s.credentialsMasked?.razorpayKeyId ?? "",
496
512
  razorpayKeySecret: s.credentialsMasked?.razorpayKeySecret ?? "",
@@ -630,7 +646,21 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
630
646
  dailyDigest: {
631
647
  enabled: digestEnabled,
632
648
  recipients: splitEmailList(digestRecipients),
633
- ccRecipients: splitEmailList(digestCcRecipients),
649
+ // Always empty — the CC field is gone (see the Notifications tab).
650
+ // Sent explicitly rather than omitted so a save clears any CC list a
651
+ // pre-existing document still carries.
652
+ ccRecipients: [],
653
+ },
654
+ },
655
+ messaging: {
656
+ emailEnabled: msgEmailEnabled,
657
+ whatsappEnabled: msgWhatsappEnabled,
658
+ dailyCeiling: {
659
+ email: msgCeilingEmail,
660
+ whatsapp: msgCeilingWhatsapp,
661
+ // No SMS sender exists; the ceiling is carried so one cannot ship
662
+ // uncapped if somebody adds a provider later.
663
+ sms: 50,
634
664
  },
635
665
  },
636
666
  integrations: { googleAnalyticsId: gaMeasurementId, facebookPixelId: fbPixelId, gtmContainerId },
@@ -744,7 +774,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
744
774
  ...bidIncrementTiers.slice(0, -1),
745
775
  { upTo: 100, increment: 10 },
746
776
  bidIncrementTiers[bidIncrementTiers.length - 1] ?? { upTo: null, increment: 1000 },
747
- ]), children: "+ Add tier" })] }), _jsx(Input, { label: "Auto-extend window (minutes before end)", value: String(autoExtendWindow), onChange: (e) => setAutoExtendWindow(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Extend auction end time if a bid arrives within this window." }), _jsx(Input, { label: "Settlement grace period (hours)", value: String(settlementGrace), onChange: (e) => setSettlementGrace(parseInt(e.target.value) || 0), type: "number", min: 1, helperText: "Time winner has to pay before the auction is re-listed." })] }) }), _jsx(TabsContent, { value: "limits", children: _jsx(SettingsTabForm, { id: "limits", label: "Limits", children: _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Max products per store", value: String(maxProductsPerStore), onChange: (e) => setMaxProductsPerStore(parseInt(e.target.value) || 0), type: "number", min: 1 }), _jsx(Input, { label: "Max custom fields per product", value: String(maxCustomFields), onChange: (e) => setMaxCustomFields(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Max custom sections per product", value: String(maxCustomSections), onChange: (e) => setMaxCustomSections(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Order cancellation window (hours)", value: String(orderCancelWindow), onChange: (e) => setOrderCancelWindow(parseInt(e.target.value) || 0), type: "number", min: 0 })] }) }) }), _jsx(TabsContent, { value: "whatsapp", children: _jsxs(SettingsTabForm, { id: "whatsapp", label: "Whatsapp", children: [_jsx(Text, { size: "xs", color: "muted", children: "Platform-level WhatsApp Business Cloud API credentials. Used for automated purchase announcements to admin numbers when orders are placed. Store owners configure their own credentials in Store \u2192 WhatsApp." }), _jsx(Input, { label: "Phone Number ID", value: waPhoneNumberId, onChange: (e) => setWaPhoneNumberId(e.target.value), placeholder: "987654321098765", helperText: "From Meta for Developers \u2192 App \u2192 WhatsApp \u2192 API Setup" }), _jsx(MaskedInput, { label: "Cloud API System User Token", value: waCloudApiToken, onChange: setWaCloudApiToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived system user access token with WhatsApp Business permissions" }), _jsx(Input, { label: "Admin Notify Numbers", value: waAdminNotifyNumbers, onChange: (e) => setWaAdminNotifyNumbers(e.target.value), placeholder: "919876543210,918765432109", helperText: "Comma-separated, digits-only, include country code. These receive a WhatsApp message when any order is placed." }), _jsx(Text, { size: "xs", color: "muted", children: "Approved Meta message templates for the buyer-facing WhatsApp order-updates addon (Fees tab). Meta rejects business-initiated free-text WhatsApp messages outside a 24h customer-service window \u2014 a real template name here is required for that notification type to actually deliver. Leave blank to skip WhatsApp for that type (in-app + email still fire)." }), _jsx(Input, { label: "Template language code", value: waTemplateLanguage, onChange: (e) => setWaTemplateLanguage(e.target.value), placeholder: "en", helperText: "BCP-47 code the templates below were approved in, e.g. en or en_US." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Order placed template name", value: waTemplateOrderPlaced, onChange: (e) => setWaTemplateOrderPlaced(e.target.value), placeholder: "order_placed_update" }), _jsx(Input, { label: "Order confirmed template name", value: waTemplateOrderConfirmed, onChange: (e) => setWaTemplateOrderConfirmed(e.target.value), placeholder: "order_confirmed_update" }), _jsx(Input, { label: "Order shipped template name", value: waTemplateOrderShipped, onChange: (e) => setWaTemplateOrderShipped(e.target.value), placeholder: "order_shipped_update" }), _jsx(Input, { label: "Order delivered template name", value: waTemplateOrderDelivered, onChange: (e) => setWaTemplateOrderDelivered(e.target.value), placeholder: "order_delivered_update" }), _jsx(Input, { label: "Order cancelled template name", value: waTemplateOrderCancelled, onChange: (e) => setWaTemplateOrderCancelled(e.target.value), placeholder: "order_cancelled_update" }), _jsx(Input, { label: "Refund initiated template name", value: waTemplateRefundInitiated, onChange: (e) => setWaTemplateRefundInitiated(e.target.value), placeholder: "refund_initiated_update" })] })] }) }), _jsx(TabsContent, { value: "notifications", children: _jsxs(SettingsTabForm, { id: "notifications", label: "Notifications", children: [_jsx(Text, { size: "xs", color: "muted", children: "In-app notifications are always on. Enable external channels below to let the platform fan out to email, WhatsApp, or SMS. Users can further restrict which types they receive." }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "In-app (notification bell)" }), _jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-xs", children: "Always on" })] }), _jsx(Text, { size: "xs", color: "muted", children: "Displayed in the notification bell and inbox. Cannot be disabled." })] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-email-notifications", children: _jsx(Toggle, { label: "Email notifications", checked: notifEmailEnabled, onChange: setNotifEmailEnabled }) }), notifEmailEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send email", options: PRIORITY_OPTIONS, value: notifEmailMinPriority, onValueChange: setNotifEmailMinPriority }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Resend API (for transactional email)" }), _jsx(MaskedInput, { label: "Resend API Key", value: resendApiKey, onChange: setResendApiKey, placeholder: "re_live_\u2026", helperText: "Get your key at resend.com \u2014 used for all transactional notifications." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "From email", value: notifFromEmail, onChange: (e) => setNotifFromEmail(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Input, { label: "From name", value: notifFromName, onChange: (e) => setNotifFromName(e.target.value), placeholder: "LetItRip" })] }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifEmailTypes, onChange: (values) => setNotifEmailTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-daily-digest", children: _jsx(Toggle, { label: "Daily status digest", checked: digestEnabled, onChange: setDigestEnabled }) }), digestEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Text, { size: "xs", color: "muted", children: "Sent every morning at 10:00 IST with the previous 24 hours of order activity. Its arrival doubles as the platform health check \u2014 if it stops landing, something needs a look." }), _jsx(Textarea, { label: "Recipients (To)", value: digestRecipients, onChange: (e) => setDigestRecipients(e.target.value), placeholder: "support@letitrip.in\nmohasin@letitrip.in", rows: 4, helperText: "One address per line." }), _jsx(Textarea, { label: "CC recipients", value: digestCcRecipients, onChange: (e) => setDigestCcRecipients(e.target.value), placeholder: "One address per line", rows: 3, helperText: "Optional \u2014 anyone else who should receive a copy." })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-whatsapp-notifications", children: _jsx(Toggle, { label: "WhatsApp notifications", checked: notifWhatsappEnabled, onChange: setNotifWhatsappEnabled }) }), notifWhatsappEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send WhatsApp", options: PRIORITY_OPTIONS, value: notifWhatsappMinPriority, onValueChange: setNotifWhatsappMinPriority }), _jsx(Toggle, { label: "Enable WhatsApp OTP (for login and verification)", checked: notifWhatsappOtpEnabled, onChange: setNotifWhatsappOtpEnabled }), _jsx(Text, { size: "xs", color: "muted", children: "WhatsApp credentials are configured in the WhatsApp tab (\u246C). OTP messages use the same phone number." }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifWhatsappTypes, onChange: (values) => setNotifWhatsappTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Toggle, { label: "SMS notifications", checked: notifSmsEnabled, onChange: setNotifSmsEnabled }), notifSmsEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send SMS", options: PRIORITY_OPTIONS, value: notifSmsMinPriority, onValueChange: setNotifSmsMinPriority }), _jsx(Text, { size: "xs", color: "muted", children: "SMS gateway credentials (e.g. Twilio, MSG91) can be configured in the Integrations tab once an SMS provider is connected." })] }))] })] }) }), _jsx(TabsContent, { value: "legal", children: _jsx(SettingsTabForm, { id: "legal", label: "Legal", children: [
777
+ ]), children: "+ Add tier" })] }), _jsx(Input, { label: "Auto-extend window (minutes before end)", value: String(autoExtendWindow), onChange: (e) => setAutoExtendWindow(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Extend auction end time if a bid arrives within this window." }), _jsx(Input, { label: "Settlement grace period (hours)", value: String(settlementGrace), onChange: (e) => setSettlementGrace(parseInt(e.target.value) || 0), type: "number", min: 1, helperText: "Time winner has to pay before the auction is re-listed." })] }) }), _jsx(TabsContent, { value: "limits", children: _jsx(SettingsTabForm, { id: "limits", label: "Limits", children: _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Max products per store", value: String(maxProductsPerStore), onChange: (e) => setMaxProductsPerStore(parseInt(e.target.value) || 0), type: "number", min: 1 }), _jsx(Input, { label: "Max custom fields per product", value: String(maxCustomFields), onChange: (e) => setMaxCustomFields(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Max custom sections per product", value: String(maxCustomSections), onChange: (e) => setMaxCustomSections(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Order cancellation window (hours)", value: String(orderCancelWindow), onChange: (e) => setOrderCancelWindow(parseInt(e.target.value) || 0), type: "number", min: 0 })] }) }) }), _jsx(TabsContent, { value: "whatsapp", children: _jsxs(SettingsTabForm, { id: "whatsapp", label: "Whatsapp", children: [_jsx(Text, { size: "xs", color: "muted", children: "Platform-level WhatsApp Business Cloud API credentials. Used for automated purchase announcements to admin numbers when orders are placed. Store owners configure their own credentials in Store \u2192 WhatsApp." }), _jsx(Input, { label: "Phone Number ID", value: waPhoneNumberId, onChange: (e) => setWaPhoneNumberId(e.target.value), placeholder: "987654321098765", helperText: "From Meta for Developers \u2192 App \u2192 WhatsApp \u2192 API Setup" }), _jsx(MaskedInput, { label: "Cloud API System User Token", value: waCloudApiToken, onChange: setWaCloudApiToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived system user access token with WhatsApp Business permissions" }), _jsx(Input, { label: "Admin Notify Numbers", value: waAdminNotifyNumbers, onChange: (e) => setWaAdminNotifyNumbers(e.target.value), placeholder: "919876543210,918765432109", helperText: "Comma-separated, digits-only, include country code. These receive a WhatsApp message when any order is placed." }), _jsx(Text, { size: "xs", color: "muted", children: "Approved Meta message templates for the buyer-facing WhatsApp order-updates addon (Fees tab). Meta rejects business-initiated free-text WhatsApp messages outside a 24h customer-service window \u2014 a real template name here is required for that notification type to actually deliver. Leave blank to skip WhatsApp for that type (in-app + email still fire)." }), _jsx(Input, { label: "Template language code", value: waTemplateLanguage, onChange: (e) => setWaTemplateLanguage(e.target.value), placeholder: "en", helperText: "BCP-47 code the templates below were approved in, e.g. en or en_US." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Order placed template name", value: waTemplateOrderPlaced, onChange: (e) => setWaTemplateOrderPlaced(e.target.value), placeholder: "order_placed_update" }), _jsx(Input, { label: "Order confirmed template name", value: waTemplateOrderConfirmed, onChange: (e) => setWaTemplateOrderConfirmed(e.target.value), placeholder: "order_confirmed_update" }), _jsx(Input, { label: "Order shipped template name", value: waTemplateOrderShipped, onChange: (e) => setWaTemplateOrderShipped(e.target.value), placeholder: "order_shipped_update" }), _jsx(Input, { label: "Order delivered template name", value: waTemplateOrderDelivered, onChange: (e) => setWaTemplateOrderDelivered(e.target.value), placeholder: "order_delivered_update" }), _jsx(Input, { label: "Order cancelled template name", value: waTemplateOrderCancelled, onChange: (e) => setWaTemplateOrderCancelled(e.target.value), placeholder: "order_cancelled_update" }), _jsx(Input, { label: "Refund initiated template name", value: waTemplateRefundInitiated, onChange: (e) => setWaTemplateRefundInitiated(e.target.value), placeholder: "refund_initiated_update" })] })] }) }), _jsx(TabsContent, { value: "notifications", children: _jsxs(SettingsTabForm, { id: "notifications", label: "Notifications", children: [_jsx(Text, { size: "xs", color: "muted", children: "In-app notifications are always on. Enable external channels below to let the platform fan out to email, WhatsApp, or SMS. Users can further restrict which types they receive." }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Outbound messaging budget" }), _jsxs(Text, { size: "xs", color: "muted", children: ["The master switches. These suppress ", _jsx(Span, { weight: "semibold", children: "user-facing" }), " messages only \u2014 the daily status digest always reaches staff, so turning customer email off never leaves the site unwatched. Transactional messages (checkout verification codes, order receipts) also keep sending, because suppressing those breaks checkout rather than quietening it."] }), _jsx(Toggle, { label: "Send user-facing email", checked: msgEmailEnabled, onChange: setMsgEmailEnabled }), _jsx(Toggle, { label: "Send user-facing WhatsApp", checked: msgWhatsappEnabled, onChange: setMsgWhatsappEnabled }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Daily email ceiling", type: "number", value: String(msgCeilingEmail), onChange: (e) => setMsgCeilingEmail(Number(e.target.value) || 0), helperText: "Resend's free tier is 100/day. 80 leaves headroom for password-reset mail and in-flight sends." }), _jsx(Input, { label: "Daily WhatsApp ceiling", type: "number", value: String(msgCeilingWhatsapp), onChange: (e) => setMsgCeilingWhatsapp(Number(e.target.value) || 0), helperText: "A sanity bound. Meta bills per conversation, not per message." })] }), _jsx(Text, { size: "xs", color: "muted", children: "Some notification types never email regardless of these settings \u2014 losing an auction, being outbid, marketing, review activity and ticket updates are shown in the notification bell only. That is deliberate: those were the highest-volume sends and the ones most likely to exhaust the day's allowance. A support reply can still be emailed one-off from the ticket screen." })] }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "In-app (notification bell)" }), _jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-xs", children: "Always on" })] }), _jsx(Text, { size: "xs", color: "muted", children: "Displayed in the notification bell and inbox. Cannot be disabled." })] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-email-notifications", children: _jsx(Toggle, { label: "Email notifications", checked: notifEmailEnabled, onChange: setNotifEmailEnabled }) }), notifEmailEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send email", options: PRIORITY_OPTIONS, value: notifEmailMinPriority, onValueChange: setNotifEmailMinPriority }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Resend API (for transactional email)" }), _jsx(MaskedInput, { label: "Resend API Key", value: resendApiKey, onChange: setResendApiKey, placeholder: "re_live_\u2026", helperText: "Get your key at resend.com \u2014 used for all transactional notifications." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "From email", value: notifFromEmail, onChange: (e) => setNotifFromEmail(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Input, { label: "From name", value: notifFromName, onChange: (e) => setNotifFromName(e.target.value), placeholder: "LetItRip" })] }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifEmailTypes, onChange: (values) => setNotifEmailTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-daily-digest", children: _jsx(Toggle, { label: "Daily status digest", checked: digestEnabled, onChange: setDigestEnabled }) }), digestEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Text, { size: "xs", color: "muted", children: "Sent every morning at 10:00 IST with the previous 24 hours of order activity. Its arrival doubles as the platform health check \u2014 if it stops landing, something needs a look." }), _jsx(Textarea, { label: "Recipients (To)", value: digestRecipients, onChange: (e) => setDigestRecipients(e.target.value), placeholder: "mohasin@letitrip.in", rows: 3, helperText: "One address per line. Each address is billed as a separate send against the daily allowance \u2014 keep this list short." })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-whatsapp-notifications", children: _jsx(Toggle, { label: "WhatsApp notifications", checked: notifWhatsappEnabled, onChange: setNotifWhatsappEnabled }) }), notifWhatsappEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send WhatsApp", options: PRIORITY_OPTIONS, value: notifWhatsappMinPriority, onValueChange: setNotifWhatsappMinPriority }), _jsx(Toggle, { label: "Enable WhatsApp OTP (for login and verification)", checked: notifWhatsappOtpEnabled, onChange: setNotifWhatsappOtpEnabled }), _jsx(Text, { size: "xs", color: "muted", children: "WhatsApp credentials are configured in the WhatsApp tab (\u246C). OTP messages use the same phone number." }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifWhatsappTypes, onChange: (values) => setNotifWhatsappTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Toggle, { label: "SMS notifications", checked: notifSmsEnabled, onChange: setNotifSmsEnabled }), notifSmsEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send SMS", options: PRIORITY_OPTIONS, value: notifSmsMinPriority, onValueChange: setNotifSmsMinPriority }), _jsx(Text, { size: "xs", color: "muted", children: "SMS gateway credentials (e.g. Twilio, MSG91) can be configured in the Integrations tab once an SMS provider is connected." })] }))] })] }) }), _jsx(TabsContent, { value: "legal", children: _jsx(SettingsTabForm, { id: "legal", label: "Legal", children: [
748
778
  ["Terms of Service", termsHtml, setTermsHtml],
749
779
  ["Privacy Policy", privacyHtml, setPrivacyHtml],
750
780
  ["Refund Policy", refundHtml, setRefundHtml],
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { Code, useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React from "react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { Button, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
6
+ import { Button, Checkbox, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS, SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
9
9
  import { supportTicketUpdateSchema, toRelatedPartiesPayload, } from "../schemas/admin-ops-forms";
@@ -65,6 +65,12 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
65
65
  const queryClient = useQueryClient();
66
66
  const { showToast } = useToast();
67
67
  const [replyBody, setReplyBody] = React.useState("");
68
+ /*
69
+ * Opt-in per reply, and it RESETS to false after every send (see
70
+ * `onSuccess`). A sticky "also email" would quietly turn the exception back
71
+ * into the default — which is the behaviour this whole change removed.
72
+ */
73
+ const [alsoEmail, setAlsoEmail] = React.useState(false);
68
74
  const [draft, setDraft] = React.useState(() => seedTicketDraft(currentStatus, currentPriority, internalNotes, relatedParties));
69
75
  React.useEffect(() => {
70
76
  if (open) {
@@ -102,11 +108,12 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
102
108
  const replyMutation = useApiMutation({
103
109
  errorMessage: "Failed to send reply.",
104
110
  mutationFn: async () => {
105
- await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status });
111
+ await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status, sendEmail: alsoEmail });
106
112
  },
107
113
  onSuccess: () => {
108
- showToast("Reply sent.", "success");
114
+ showToast(alsoEmail ? "Reply sent and emailed." : "Reply sent.", "success");
109
115
  setReplyBody("");
116
+ setAlsoEmail(false);
110
117
  invalidate();
111
118
  onClose();
112
119
  },
@@ -257,7 +264,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
257
264
  setOrderItemsOpen(false);
258
265
  },
259
266
  });
260
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
267
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Checkbox, { checked: alsoEmail, onChange: (e) => setAlsoEmail(e.target.checked), label: "Also email the user about this reply" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
261
268
  // SectionForm scrolls to the first erroring section and then
262
269
  // submits regardless, so the guard lives here.
263
270
  form.clearErrors();
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { AboutContentDocument } from "../../about/schemas/firestore";
6
6
  export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
7
- export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
7
+ export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
8
8
  export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
9
9
  /**
10
10
  * What a notification points AT. Drives the shared `actionUrl` resolver, so a
@@ -77,7 +77,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
77
77
  export declare const notificationQueryHelpers: {
78
78
  readonly byUser: (userId: string) => readonly ["userId", "==", string];
79
79
  readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
80
- readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
80
+ readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "support_ticket_update" | "scam_report_update" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
81
81
  };
82
82
  export interface TrustBarItem {
83
83
  icon: string;
@@ -447,6 +447,36 @@ export interface SiteSettingsDocument extends BaseDocument {
447
447
  instagram?: string;
448
448
  linkedin?: string;
449
449
  };
450
+ /**
451
+ * The outbound-messaging kill switch and spend ceiling.
452
+ *
453
+ * Distinct from `notificationChannels`, which decides WHICH notification
454
+ * types a channel carries. This decides whether the channel runs at all and
455
+ * how much of the day's allowance it may spend — a site-wide posture rather
456
+ * than a per-type routing rule.
457
+ *
458
+ * 🛑 Every field here is PRIVATE. It is triaged into
459
+ * `PRIVATE_SITE_SETTINGS_FIELDS` in the public projection, and must stay
460
+ * there: the ceiling and the remaining headroom are operational detail, and
461
+ * publishing "email is currently off" tells an attacker that password-reset
462
+ * mail is not being watched.
463
+ */
464
+ messaging?: {
465
+ /** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
466
+ emailEnabled: boolean;
467
+ /** User-facing WhatsApp. Same exemption for staff. */
468
+ whatsappEnabled: boolean;
469
+ /**
470
+ * Reserved sends allowed per channel per day. Applies to staff and
471
+ * transactional mail too — the ceiling exists to stay inside the
472
+ * provider's own limit, and the provider does not exempt anyone.
473
+ */
474
+ dailyCeiling: {
475
+ email: number;
476
+ whatsapp: number;
477
+ sms: number;
478
+ };
479
+ };
450
480
  emailSettings: {
451
481
  fromName: string;
452
482
  fromEmail: string;
@@ -49,7 +49,27 @@ export const NOTIFICATION_TYPE_VALUES = [
49
49
  "promotion",
50
50
  "system",
51
51
  "welcome",
52
+ /*
53
+ * 🛑 `account_action` means A BAN OR AN UNBAN, and nothing else.
54
+ *
55
+ * It used to also carry support-ticket activity AND scam-report outcomes —
56
+ * three unrelated concerns on one value, which made them impossible to tell
57
+ * apart anywhere downstream. That mattered the moment email became
58
+ * type-gated (see `EMAIL_ELIGIBLE_TYPES`): there was no way to stop emailing
59
+ * "your ticket got a reply" without also silencing "you have been banned",
60
+ * and those two have opposite answers. A banned user has to be told outside
61
+ * the app, because the app is what they just lost.
62
+ *
63
+ * Split 2026-09. If you are tempted to route a fourth concern through this
64
+ * value because it is vaguely account-shaped, add a type instead — the
65
+ * `Record<NotificationType, …>` maps will tell you everywhere that needs an
66
+ * answer.
67
+ */
52
68
  "account_action",
69
+ /** Support ticket created, replied to, or moved status. Bell-only by default — see `EMAIL_ELIGIBLE_TYPES`; staff can opt a single reply into email. */
70
+ "support_ticket_update",
71
+ /** Scam report received, verified, or rejected. Bell-only; the admin side rides the daily digest. */
72
+ "scam_report_update",
53
73
  "offer_received",
54
74
  "offer_responded",
55
75
  "offer_expired",
@@ -199,6 +219,14 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
199
219
  smsVerification: false,
200
220
  adminCheckoutBypass: false,
201
221
  },
222
+ // Mirrors MESSAGE_BUDGET_DEFAULTS — both channels start OFF. A fresh install
223
+ // that has made no decision should be quiet, not mailing real people from a
224
+ // half-configured site.
225
+ messaging: {
226
+ emailEnabled: false,
227
+ whatsappEnabled: false,
228
+ dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
229
+ },
202
230
  emi: {
203
231
  enabled: false,
204
232
  minOrderValue: 10000,
@@ -100,6 +100,11 @@ export declare function useResendVerification(options?: {
100
100
  onSuccess?: (data: JsonValue) => void;
101
101
  onError?: (error: Error) => void;
102
102
  }): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, void | ResendVerificationData, unknown>;
103
+ /** Thrown when the cooldown refuses — carries the wait so the UI can say it. */
104
+ export declare class AuthMailCooldownError extends Error {
105
+ readonly retryAfterSeconds: number;
106
+ constructor(retryAfterSeconds: number);
107
+ }
103
108
  export declare function useForgotPassword(options?: {
104
109
  onSuccess?: (data: JsonValue) => void;
105
110
  onError?: (error: Error) => void;
@@ -373,9 +373,51 @@ export function useResendVerification(options) {
373
373
  onError: options?.onError,
374
374
  });
375
375
  }
376
+ /**
377
+ * Ask the server whether this address may be sent an auth mail right now.
378
+ *
379
+ * Firebase's own auth mail goes browser → Firebase with no server hop, so
380
+ * nothing on our side can see it — including `applyRateLimit`. This gate is the
381
+ * only throttle these flows have, and it is deliberately advisory: a `false`
382
+ * from the network, a thrown request, or a missing route all resolve to
383
+ * "allowed", because a user locked out of their account must never be blocked
384
+ * from requesting a reset by our own infrastructure failing.
385
+ *
386
+ * See `src/app/api/auth/mail-gate/route.ts` for why this is an abuse guard
387
+ * rather than a security boundary.
388
+ */
389
+ async function requestAuthMailPermission(email, purpose) {
390
+ try {
391
+ const res = await apiClient.post(AUTH_ENDPOINTS.MAIL_GATE, { email, purpose });
392
+ const data = (res?.data ?? res);
393
+ return {
394
+ allowed: data?.allowed !== false,
395
+ retryAfterSeconds: data?.retryAfterSeconds ?? 0,
396
+ };
397
+ }
398
+ catch (err) {
399
+ // Normalised then deliberately discarded: an unreachable gate is not an
400
+ // incident worth reporting, it is the documented fail-open path. The user
401
+ // still gets their reset mail; only the cooldown is skipped.
402
+ void normalizeError(err);
403
+ return { allowed: true, retryAfterSeconds: 0 };
404
+ }
405
+ }
406
+ /** Thrown when the cooldown refuses — carries the wait so the UI can say it. */
407
+ export class AuthMailCooldownError extends Error {
408
+ constructor(retryAfterSeconds) {
409
+ const mins = Math.max(1, Math.ceil(retryAfterSeconds / 60));
410
+ super(`We've already sent that recently — please check your inbox, including spam. You can request another in ${mins} minute${mins === 1 ? "" : "s"}.`);
411
+ this.name = "AuthMailCooldownError";
412
+ this.retryAfterSeconds = retryAfterSeconds;
413
+ }
414
+ }
376
415
  export function useForgotPassword(options) {
377
416
  return useMutation({
378
417
  mutationFn: async (data) => {
418
+ const gate = await requestAuthMailPermission(data.email, "password_reset");
419
+ if (!gate.allowed)
420
+ throw new AuthMailCooldownError(gate.retryAfterSeconds);
379
421
  await getClientAuthProvider().sendPasswordResetEmail(data.email);
380
422
  return { success: true };
381
423
  },
@@ -18,6 +18,7 @@ import { addressesRepository } from "../../addresses/repository/addresses.reposi
18
18
  import { siteSettingsRepository } from "../../admin/repository/site-settings.repository";
19
19
  import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp";
20
20
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health";
21
+ import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard";
21
22
  import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp";
22
23
  function maskEmail(userEmail) {
23
24
  const [local, domain] = userEmail.split("@");
@@ -82,6 +83,20 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
82
83
  createdAt: new Date(),
83
84
  });
84
85
  const message = buildCheckoutValueOtpMessage(code, siteName);
86
+ // Same guard as the email branch below, same reasoning: transactional, so
87
+ // the kill switch does not apply (the buyer cannot check out without the
88
+ // code), but the daily ceiling does.
89
+ const waGuard = {
90
+ channel: "whatsapp",
91
+ feature: "checkout_value_otp",
92
+ audience: "user",
93
+ transactional: true,
94
+ };
95
+ const waDecision = await guardSend(waGuard);
96
+ if (!waDecision.allow) {
97
+ logSuppressedSend(waGuard, waDecision);
98
+ throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
99
+ }
85
100
  try {
86
101
  const sent = await withChannelRetry("whatsapp", async () => {
87
102
  const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
@@ -126,6 +141,23 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
126
141
  <p style="color:#888;font-size:12px">This code expires in 10 minutes. If you did not request this, please ignore this email.</p>
127
142
  </div>
128
143
  `,
144
+ }, {
145
+ channel: "email",
146
+ feature: "checkout_value_otp",
147
+ audience: "user",
148
+ /*
149
+ * The strongest case for `transactional` in the codebase: the buyer
150
+ * is mid-checkout above the high-value threshold and CANNOT complete
151
+ * the order without this code. Suppressing it does not quieten the
152
+ * site, it breaks checkout — so it ignores the kill switch.
153
+ *
154
+ * It still counts against the daily ceiling, which is the honest
155
+ * behaviour: if the provider is going to start rejecting, pretending
156
+ * otherwise here just moves the failure somewhere less legible. The
157
+ * 15-minute per-user cooldown in `checkout-value-otp.ts` already
158
+ * bounds how much of the allowance one buyer can consume.
159
+ */
160
+ transactional: true,
129
161
  });
130
162
  if (error)
131
163
  throw new Error(String(error));
@@ -1,4 +1,5 @@
1
1
  import "server-only";
2
+ import type { SendGuardContext } from "../../_internal/shared/features/messaging/config";
2
3
  import type { JsonValue } from "@mohasinac/appkit";
3
4
  type SendEmailOptions = Record<string, JsonValue> & {
4
5
  to: string | string[];
@@ -9,17 +10,37 @@ type SendEmailOptions = Record<string, JsonValue> & {
9
10
  cc?: string | string[];
10
11
  replyTo?: string;
11
12
  };
12
- export declare function sendEmail(opts: SendEmailOptions): Promise<{
13
+ /**
14
+ * Everything `sendEmail` needs beyond the message itself.
15
+ *
16
+ * 🛑 REQUIRED, and that is the entire enforcement mechanism.
17
+ *
18
+ * There is no `guard?:` and no default. A sender that cannot say who this mail
19
+ * is for and what it is does not compile, so "someone forgot to guard a new
20
+ * send path" is not a class of bug that can occur — which is what a
21
+ * suppression-marker-and-audit approach would only have made *detectable*
22
+ * (CLAUDE.md Rule #22: make the wrong thing unrepresentable).
23
+ *
24
+ * Kept as a separate second parameter rather than a field on
25
+ * `SendEmailOptions` because that type has a `Record<string, JsonValue>` index
26
+ * signature — a structured object would not satisfy it, and widening the index
27
+ * signature to accommodate one field would weaken every other field's typing.
28
+ */
29
+ export type SendEmailGuard = SendGuardContext;
30
+ /**
31
+ * Send one email, subject to the messaging guard.
32
+ *
33
+ * `suppressed` is a THIRD outcome, distinct from both success and failure, and
34
+ * callers must treat it as such: `{ data: null, error: null }` means the
35
+ * message was deliberately not sent, so a caller that only checks `error` will
36
+ * correctly not raise an alarm, and one that wants to know can read
37
+ * `suppressed`. Returning an `error` for a suppression would have turned every
38
+ * intentional quiet into a logged incident.
39
+ */
40
+ export declare function sendEmail(opts: SendEmailOptions, guard: SendEmailGuard): Promise<{
13
41
  data: JsonValue;
14
42
  error: JsonValue;
15
- }>;
16
- export declare function sendVerificationEmailWithLink(email: string, verificationLink: string): Promise<{
17
- success: boolean;
18
- data?: JsonValue;
19
- }>;
20
- export declare function sendPasswordResetEmailWithLink(email: string, resetLink: string): Promise<{
21
- success: boolean;
22
- data?: JsonValue;
43
+ suppressed?: string;
23
44
  }>;
24
45
  export interface OrderConfirmationEmailParams {
25
46
  to: string;
@@ -43,15 +64,6 @@ export declare function sendOrderConfirmationEmail(params: OrderConfirmationEmai
43
64
  success: boolean;
44
65
  data?: JsonValue;
45
66
  }>;
46
- export declare function sendContactEmail(params: {
47
- name: string;
48
- email: string;
49
- subject: string;
50
- message: string;
51
- }): Promise<{
52
- success: boolean;
53
- data?: JsonValue;
54
- }>;
55
67
  export declare function sendDigitalCodeClaimedEmail(params: {
56
68
  to: string;
57
69
  userName: string;
@@ -60,12 +72,4 @@ export declare function sendDigitalCodeClaimedEmail(params: {
60
72
  }): Promise<{
61
73
  success: boolean;
62
74
  }>;
63
- export declare function sendSiteSettingsChangedEmail(params: {
64
- adminEmails: string[];
65
- changedByEmail: string;
66
- changedFields: string[];
67
- }): Promise<{
68
- success: boolean;
69
- data?: JsonValue;
70
- }>;
71
75
  export {};