@openlup/platform 0.16.0 → 0.17.0
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/CHANGELOG.md +84 -0
- package/README.md +285 -5
- package/dist/communications/accountingDocumentDelivery.d.ts +35 -0
- package/dist/communications/accountingDocumentDelivery.js +33 -0
- package/dist/communications/authEmailError.d.ts +5 -0
- package/dist/communications/authEmailError.js +8 -0
- package/dist/communications/authEmailPorts.d.ts +2 -0
- package/dist/communications/authEmailPorts.js +1 -0
- package/dist/communications/authSendEmailHook.d.ts +35 -0
- package/dist/communications/authSendEmailHook.js +250 -0
- package/dist/communications/capturedTransactionalDelivery.d.ts +13 -0
- package/dist/communications/capturedTransactionalDelivery.js +14 -0
- package/dist/communications/communicationSync.d.ts +11 -0
- package/dist/communications/communicationSync.js +79 -0
- package/dist/communications/communicationSyncWorker.d.ts +68 -0
- package/dist/communications/communicationSyncWorker.js +218 -0
- package/dist/communications/consentPolicy.d.ts +19 -0
- package/dist/communications/consentPolicy.js +43 -0
- package/dist/communications/consentPurposeRegistry.d.ts +4 -0
- package/dist/communications/consentPurposeRegistry.js +24 -0
- package/dist/communications/contracts/abandonedCart.d.ts +27 -0
- package/dist/communications/contracts/abandonedCart.js +25 -0
- package/dist/communications/contracts/authEmail.d.ts +62 -0
- package/dist/communications/contracts/authEmail.js +1 -0
- package/dist/communications/contracts/backInStock.d.ts +28 -0
- package/dist/communications/contracts/backInStock.js +28 -0
- package/dist/communications/contracts/blocks.d.ts +24 -0
- package/dist/communications/contracts/blocks.js +23 -0
- package/dist/communications/contracts/checkoutExpired.d.ts +22 -0
- package/dist/communications/contracts/checkoutExpired.js +25 -0
- package/dist/communications/contracts/checkoutRecovery.d.ts +26 -0
- package/dist/communications/contracts/checkoutRecovery.js +37 -0
- package/dist/communications/contracts/commerceEmailContent.d.ts +176 -0
- package/dist/communications/contracts/commerceEmailContent.js +1 -0
- package/dist/communications/contracts/commerceEngagementEmailContent.d.ts +95 -0
- package/dist/communications/contracts/commerceEngagementEmailContent.js +1 -0
- package/dist/communications/contracts/consent.d.ts +12 -0
- package/dist/communications/contracts/consent.js +1 -0
- package/dist/communications/contracts/email.d.ts +182 -0
- package/dist/communications/contracts/email.js +1 -0
- package/dist/communications/contracts/guideCard.d.ts +3 -0
- package/dist/communications/contracts/guideCard.js +26 -0
- package/dist/communications/contracts/index.d.ts +51 -0
- package/dist/communications/contracts/index.js +42 -0
- package/dist/communications/contracts/inquiryContent.d.ts +32 -0
- package/dist/communications/contracts/inquiryContent.js +33 -0
- package/dist/communications/contracts/invoiceDocumentContent.d.ts +26 -0
- package/dist/communications/contracts/invoiceDocumentContent.js +36 -0
- package/dist/communications/contracts/marketingAutomation.d.ts +52 -0
- package/dist/communications/contracts/marketingAutomation.js +1 -0
- package/dist/communications/contracts/newsletter.d.ts +63 -0
- package/dist/communications/contracts/newsletter.js +1 -0
- package/dist/communications/contracts/newsletterWebhook.d.ts +26 -0
- package/dist/communications/contracts/newsletterWebhook.js +1 -0
- package/dist/communications/contracts/optOutContent.d.ts +11 -0
- package/dist/communications/contracts/optOutContent.js +17 -0
- package/dist/communications/contracts/orderCanceled.d.ts +25 -0
- package/dist/communications/contracts/orderCanceled.js +27 -0
- package/dist/communications/contracts/orderDraft.d.ts +46 -0
- package/dist/communications/contracts/orderDraft.js +46 -0
- package/dist/communications/contracts/orderPaid.d.ts +53 -0
- package/dist/communications/contracts/orderPaid.js +71 -0
- package/dist/communications/contracts/orderRefunded.d.ts +24 -0
- package/dist/communications/contracts/orderRefunded.js +27 -0
- package/dist/communications/contracts/paymentFailed.d.ts +24 -0
- package/dist/communications/contracts/paymentFailed.js +24 -0
- package/dist/communications/contracts/publicIntake.d.ts +49 -0
- package/dist/communications/contracts/publicIntake.js +1 -0
- package/dist/communications/contracts/render.d.ts +2 -0
- package/dist/communications/contracts/render.js +229 -0
- package/dist/communications/contracts/reorderReminder.d.ts +24 -0
- package/dist/communications/contracts/reorderReminder.js +24 -0
- package/dist/communications/contracts/returnEmails.d.ts +25 -0
- package/dist/communications/contracts/returnEmails.js +62 -0
- package/dist/communications/contracts/reviewEffects.d.ts +26 -0
- package/dist/communications/contracts/reviewEffects.js +25 -0
- package/dist/communications/contracts/reviewRequest.d.ts +26 -0
- package/dist/communications/contracts/reviewRequest.js +24 -0
- package/dist/communications/contracts/roleNotificationContent.d.ts +25 -0
- package/dist/communications/contracts/roleNotificationContent.js +46 -0
- package/dist/communications/contracts/shipmentDelivered.d.ts +23 -0
- package/dist/communications/contracts/shipmentDelivered.js +24 -0
- package/dist/communications/contracts/shipmentDispatched.d.ts +27 -0
- package/dist/communications/contracts/shipmentDispatched.js +35 -0
- package/dist/communications/contracts/shipmentException.d.ts +21 -0
- package/dist/communications/contracts/shipmentException.js +26 -0
- package/dist/communications/contracts/subscriptionActivationActionRequired.d.ts +17 -0
- package/dist/communications/contracts/subscriptionActivationActionRequired.js +22 -0
- package/dist/communications/contracts/subscriptionAddressChanged.d.ts +19 -0
- package/dist/communications/contracts/subscriptionAddressChanged.js +25 -0
- package/dist/communications/contracts/subscriptionCancelled.d.ts +19 -0
- package/dist/communications/contracts/subscriptionCancelled.js +25 -0
- package/dist/communications/contracts/subscriptionCycleSkipped.d.ts +25 -0
- package/dist/communications/contracts/subscriptionCycleSkipped.js +40 -0
- package/dist/communications/contracts/subscriptionDeliveryRescheduled.d.ts +25 -0
- package/dist/communications/contracts/subscriptionDeliveryRescheduled.js +39 -0
- package/dist/communications/contracts/subscriptionEmailContent.d.ts +145 -0
- package/dist/communications/contracts/subscriptionEmailContent.js +1 -0
- package/dist/communications/contracts/subscriptionPackageChanged.d.ts +20 -0
- package/dist/communications/contracts/subscriptionPackageChanged.js +26 -0
- package/dist/communications/contracts/subscriptionPauseReminder.d.ts +23 -0
- package/dist/communications/contracts/subscriptionPauseReminder.js +29 -0
- package/dist/communications/contracts/subscriptionPaused.d.ts +21 -0
- package/dist/communications/contracts/subscriptionPaused.js +25 -0
- package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +34 -0
- package/dist/communications/contracts/subscriptionPaymentExpired.js +37 -0
- package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +49 -0
- package/dist/communications/contracts/subscriptionPaymentFailed.js +56 -0
- package/dist/communications/contracts/subscriptionPaymentRecovered.d.ts +19 -0
- package/dist/communications/contracts/subscriptionPaymentRecovered.js +22 -0
- package/dist/communications/contracts/subscriptionRenewalAtRisk.d.ts +36 -0
- package/dist/communications/contracts/subscriptionRenewalAtRisk.js +36 -0
- package/dist/communications/contracts/subscriptionRenewalUpcoming.d.ts +49 -0
- package/dist/communications/contracts/subscriptionRenewalUpcoming.js +43 -0
- package/dist/communications/contracts/subscriptionResumed.d.ts +23 -0
- package/dist/communications/contracts/subscriptionResumed.js +25 -0
- package/dist/communications/contracts/subscriptionWelcome.d.ts +44 -0
- package/dist/communications/contracts/subscriptionWelcome.js +45 -0
- package/dist/communications/contracts/subscriptionWinback.d.ts +21 -0
- package/dist/communications/contracts/subscriptionWinback.js +28 -0
- package/dist/communications/contracts/transactionalDelivery.d.ts +42 -0
- package/dist/communications/contracts/transactionalDelivery.js +1 -0
- package/dist/communications/contracts/types.d.ts +46 -0
- package/dist/communications/contracts/types.js +1 -0
- package/dist/communications/contribution.d.ts +10 -0
- package/dist/communications/contribution.js +24 -0
- package/dist/communications/deliveryReconciliation.d.ts +64 -0
- package/dist/communications/deliveryReconciliation.js +60 -0
- package/dist/communications/emailEgress.d.ts +38 -0
- package/dist/communications/emailEgress.js +100 -0
- package/dist/communications/emailHealthAssessment.d.ts +39 -0
- package/dist/communications/emailHealthAssessment.js +59 -0
- package/dist/communications/emailHealthMetricsPort.d.ts +17 -0
- package/dist/communications/emailHealthMetricsPort.js +1 -0
- package/dist/communications/emailNotificationControl.d.ts +27 -0
- package/dist/communications/emailNotificationControl.js +10 -0
- package/dist/communications/emailObservation.d.ts +28 -0
- package/dist/communications/emailObservation.js +23 -0
- package/dist/communications/emailTransport.d.ts +82 -0
- package/dist/communications/emailTransport.js +95 -0
- package/dist/communications/errors.d.ts +15 -0
- package/dist/communications/errors.js +30 -0
- package/dist/communications/index.d.ts +46 -0
- package/dist/communications/index.js +38 -0
- package/dist/communications/integrationEventSignature.d.ts +15 -0
- package/dist/communications/integrationEventSignature.js +37 -0
- package/dist/communications/managedTransactionalDeliveryStore.d.ts +12 -0
- package/dist/communications/managedTransactionalDeliveryStore.js +49 -0
- package/dist/communications/nativeOperations.d.ts +79 -0
- package/dist/communications/nativeOperations.js +3 -0
- package/dist/communications/newsletterIntegrationContracts.d.ts +130 -0
- package/dist/communications/newsletterIntegrationContracts.js +128 -0
- package/dist/communications/newsletterWebhookPort.d.ts +11 -0
- package/dist/communications/newsletterWebhookPort.js +84 -0
- package/dist/communications/newsletterWebhookProcessing.d.ts +17 -0
- package/dist/communications/newsletterWebhookProcessing.js +71 -0
- package/dist/communications/originPolicy.d.ts +40 -0
- package/dist/communications/originPolicy.js +100 -0
- package/dist/communications/postgresCommunicationSync.d.ts +79 -0
- package/dist/communications/postgresCommunicationSync.js +111 -0
- package/dist/communications/postgresConsentEvaluator.d.ts +19 -0
- package/dist/communications/postgresConsentEvaluator.js +43 -0
- package/dist/communications/postgresDeliveryReconciliation.d.ts +3 -0
- package/dist/communications/postgresDeliveryReconciliation.js +113 -0
- package/dist/communications/postgresNativeOperations.d.ts +13 -0
- package/dist/communications/postgresNativeOperations.js +63 -0
- package/dist/communications/postgresNotificationRecipients.d.ts +37 -0
- package/dist/communications/postgresNotificationRecipients.js +60 -0
- package/dist/communications/postgresTransactionalDeliveryStore.d.ts +7 -0
- package/dist/communications/postgresTransactionalDeliveryStore.js +55 -0
- package/dist/communications/preferencesToken.d.ts +10 -0
- package/dist/communications/preferencesToken.js +40 -0
- package/dist/communications/publicPreferenceToken.d.ts +24 -0
- package/dist/communications/publicPreferenceToken.js +53 -0
- package/dist/communications/shipmentNotifications.d.ts +34 -0
- package/dist/communications/shipmentNotifications.js +61 -0
- package/dist/communications/sql.d.ts +26 -0
- package/dist/communications/sql.js +7 -0
- package/dist/communications/standardWebhooks.d.ts +14 -0
- package/dist/communications/standardWebhooks.js +73 -0
- package/dist/communications/status.d.ts +11 -0
- package/dist/communications/status.js +36 -0
- package/dist/communications/transactionalDelivery.d.ts +19 -0
- package/dist/communications/transactionalDelivery.js +96 -0
- package/dist/communications/transactionalDeliveryIdentifier.d.ts +3 -0
- package/dist/communications/transactionalDeliveryIdentifier.js +6 -0
- package/dist/communications/unsubscribeToken.d.ts +77 -0
- package/dist/communications/unsubscribeToken.js +150 -0
- package/dist/communications/validation.d.ts +2 -0
- package/dist/communications/validation.js +3 -0
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.js +1 -0
- package/dist/http/webhookSignature.d.ts +42 -0
- package/dist/http/webhookSignature.js +86 -0
- package/package.json +12 -2
- package/release-gates.json +41 -8
- package/sql/20260807190000_comms_outbox_rail.sql +329 -0
- package/sql/20260810120001_transactional_delivery_receipts.sql +209 -0
- package/sql/20260813120000_communications_control_plane.sql +568 -0
- package/sql/20261019200000_communications_native_operations.sql +68 -0
- package/src/communications/accountingDocumentDelivery.ts +63 -0
- package/src/communications/authEmailError.ts +10 -0
- package/src/communications/authEmailPorts.ts +2 -0
- package/src/communications/authSendEmailHook.ts +310 -0
- package/src/communications/capturedTransactionalDelivery.ts +29 -0
- package/src/communications/communicationSync.ts +96 -0
- package/src/communications/communicationSyncWorker.ts +302 -0
- package/src/communications/consentPolicy.ts +82 -0
- package/src/communications/consentPurposeRegistry.ts +31 -0
- package/src/communications/contracts/abandonedCart.ts +64 -0
- package/src/communications/contracts/authEmail.ts +73 -0
- package/src/communications/contracts/backInStock.ts +69 -0
- package/src/communications/contracts/blocks.ts +47 -0
- package/src/communications/contracts/checkoutExpired.ts +62 -0
- package/src/communications/contracts/checkoutRecovery.ts +68 -0
- package/src/communications/contracts/commerceEmailContent.ts +189 -0
- package/src/communications/contracts/commerceEngagementEmailContent.ts +106 -0
- package/src/communications/contracts/consent.ts +13 -0
- package/src/communications/contracts/email.ts +210 -0
- package/src/communications/contracts/guideCard.ts +34 -0
- package/src/communications/contracts/index.ts +51 -0
- package/src/communications/contracts/inquiryContent.ts +53 -0
- package/src/communications/contracts/invoiceDocumentContent.ts +57 -0
- package/src/communications/contracts/marketingAutomation.ts +45 -0
- package/src/communications/contracts/newsletter.ts +78 -0
- package/src/communications/contracts/newsletterWebhook.ts +23 -0
- package/src/communications/contracts/optOutContent.ts +23 -0
- package/src/communications/contracts/orderCanceled.ts +64 -0
- package/src/communications/contracts/orderDraft.ts +112 -0
- package/src/communications/contracts/orderPaid.ts +147 -0
- package/src/communications/contracts/orderRefunded.ts +63 -0
- package/src/communications/contracts/paymentFailed.ts +59 -0
- package/src/communications/contracts/publicIntake.ts +53 -0
- package/src/communications/contracts/render.ts +271 -0
- package/src/communications/contracts/reorderReminder.ts +59 -0
- package/src/communications/contracts/returnEmails.ts +113 -0
- package/src/communications/contracts/reviewEffects.ts +60 -0
- package/src/communications/contracts/reviewRequest.ts +59 -0
- package/src/communications/contracts/roleNotificationContent.ts +65 -0
- package/src/communications/contracts/shipmentDelivered.ts +59 -0
- package/src/communications/contracts/shipmentDispatched.ts +77 -0
- package/src/communications/contracts/shipmentException.ts +62 -0
- package/src/communications/contracts/subscriptionActivationActionRequired.ts +37 -0
- package/src/communications/contracts/subscriptionAddressChanged.ts +56 -0
- package/src/communications/contracts/subscriptionCancelled.ts +57 -0
- package/src/communications/contracts/subscriptionCycleSkipped.ts +80 -0
- package/src/communications/contracts/subscriptionDeliveryRescheduled.ts +79 -0
- package/src/communications/contracts/subscriptionEmailContent.ts +163 -0
- package/src/communications/contracts/subscriptionPackageChanged.ts +59 -0
- package/src/communications/contracts/subscriptionPauseReminder.ts +63 -0
- package/src/communications/contracts/subscriptionPaused.ts +58 -0
- package/src/communications/contracts/subscriptionPaymentExpired.ts +83 -0
- package/src/communications/contracts/subscriptionPaymentFailed.ts +117 -0
- package/src/communications/contracts/subscriptionPaymentRecovered.ts +50 -0
- package/src/communications/contracts/subscriptionRenewalAtRisk.ts +85 -0
- package/src/communications/contracts/subscriptionRenewalUpcoming.ts +103 -0
- package/src/communications/contracts/subscriptionResumed.ts +60 -0
- package/src/communications/contracts/subscriptionWelcome.ts +102 -0
- package/src/communications/contracts/subscriptionWinback.ts +62 -0
- package/src/communications/contracts/transactionalDelivery.ts +43 -0
- package/src/communications/contracts/types.ts +58 -0
- package/src/communications/contribution.ts +33 -0
- package/src/communications/deliveryReconciliation.ts +108 -0
- package/src/communications/emailEgress.ts +111 -0
- package/src/communications/emailHealthAssessment.ts +106 -0
- package/src/communications/emailHealthMetricsPort.ts +17 -0
- package/src/communications/emailNotificationControl.ts +28 -0
- package/src/communications/emailObservation.ts +43 -0
- package/src/communications/emailTransport.ts +167 -0
- package/src/communications/errors.ts +34 -0
- package/src/communications/index.ts +46 -0
- package/src/communications/integrationEventSignature.ts +49 -0
- package/src/communications/managedTransactionalDeliveryStore.ts +59 -0
- package/src/communications/nativeOperations.ts +40 -0
- package/src/communications/newsletterIntegrationContracts.ts +154 -0
- package/src/communications/newsletterWebhookPort.ts +104 -0
- package/src/communications/newsletterWebhookProcessing.ts +102 -0
- package/src/communications/originPolicy.ts +151 -0
- package/src/communications/postgresCommunicationSync.ts +127 -0
- package/src/communications/postgresConsentEvaluator.ts +49 -0
- package/src/communications/postgresDeliveryReconciliation.ts +101 -0
- package/src/communications/postgresNativeOperations.ts +55 -0
- package/src/communications/postgresNotificationRecipients.ts +57 -0
- package/src/communications/postgresTransactionalDeliveryStore.ts +79 -0
- package/src/communications/preferencesToken.ts +62 -0
- package/src/communications/publicPreferenceToken.ts +68 -0
- package/src/communications/shipmentNotifications.ts +78 -0
- package/src/communications/sql.ts +7 -0
- package/src/communications/standardWebhooks.ts +84 -0
- package/src/communications/status.ts +51 -0
- package/src/communications/transactionalDelivery.ts +114 -0
- package/src/communications/transactionalDeliveryIdentifier.ts +9 -0
- package/src/communications/unsubscribeToken.ts +186 -0
- package/src/communications/validation.ts +3 -0
- package/src/http/index.ts +1 -0
- package/src/http/webhookSignature.ts +96 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readEmailOriginConfiguration, resolveEmailOrigin } from "./originPolicy.js";
|
|
2
|
+
import { buildAuthVerifyUrl } from "../identity/index.js";
|
|
3
|
+
import { AuthEmailContentUnavailableError, } from "./authEmailPorts.js";
|
|
4
|
+
import { guardAuthEmailTransport } from "./emailEgress.js";
|
|
5
|
+
import { verifyStandardWebhookSignature } from "./standardWebhooks.js";
|
|
6
|
+
const CORS_HEADERS = {
|
|
7
|
+
"access-control-allow-origin": "*",
|
|
8
|
+
"access-control-allow-headers": "authorization, x-client-info, apikey, content-type",
|
|
9
|
+
};
|
|
10
|
+
const KNOWN_ACTION_TYPES = new Set([
|
|
11
|
+
"magiclink", "signup", "recovery", "invite", "email_change",
|
|
12
|
+
"email_change_current", "email_change_new", "email",
|
|
13
|
+
]);
|
|
14
|
+
function json(res, status, body) {
|
|
15
|
+
for (const [key, value] of Object.entries(CORS_HEADERS))
|
|
16
|
+
res.setHeader(key, value);
|
|
17
|
+
res.status(status).json(body);
|
|
18
|
+
}
|
|
19
|
+
function hookError(res, httpCode, message) {
|
|
20
|
+
json(res, httpCode, { error: { http_code: httpCode, message } });
|
|
21
|
+
}
|
|
22
|
+
async function readRawBody(req) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of req)
|
|
25
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
26
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
function resolveRedirectTarget(data, defaultRedirectTo) {
|
|
29
|
+
if (data?.redirect_to?.trim())
|
|
30
|
+
return { redirectTo: data.redirect_to, source: "redirect_to" };
|
|
31
|
+
if (data?.site_url?.trim())
|
|
32
|
+
return { redirectTo: data.site_url, source: "site_url" };
|
|
33
|
+
return { redirectTo: defaultRedirectTo ?? "", source: "default_redirect" };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An email carries no base URL, so a relative action link is not a degraded
|
|
37
|
+
* link - it is an unclickable one. The Edge runtime supplied this origin for
|
|
38
|
+
* free; a Node deployment must be configured with it, and one that is not must
|
|
39
|
+
* refuse rather than send mail whose only working path is the OTP fallback.
|
|
40
|
+
*/
|
|
41
|
+
function absoluteAuthServiceUrl(value) {
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(value);
|
|
44
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function uuidOrNull(value) {
|
|
51
|
+
return value && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
52
|
+
? value
|
|
53
|
+
: null;
|
|
54
|
+
}
|
|
55
|
+
function deliveryMode(result, env, recipient, defaultSandboxRecipient) {
|
|
56
|
+
// Preserve the existing Auth-hook audit vocabulary even though the Node
|
|
57
|
+
// transport's lower-level egress seam uses a richer set of route names.
|
|
58
|
+
if (result.outcome.egressMode === "production")
|
|
59
|
+
return "standard";
|
|
60
|
+
if ((env.EMAIL_DELIVERY_PROFILE ?? "").trim().toLowerCase() !== "production_like")
|
|
61
|
+
return "standard";
|
|
62
|
+
const sandboxRecipients = parseEmailList(env.EMAIL_SANDBOX_RECIPIENTS ?? env.COMMS_SANDBOX_SINK_ADDRESS ?? defaultSandboxRecipient);
|
|
63
|
+
return result.outcome.egressMode === "sandbox_sink"
|
|
64
|
+
|| (result.outcome.adminDisabled === true && sandboxRecipients.has(recipient.trim().toLowerCase()))
|
|
65
|
+
? "sandbox_recipient"
|
|
66
|
+
: "production_like_real";
|
|
67
|
+
}
|
|
68
|
+
function parseEmailList(value) {
|
|
69
|
+
return new Set((value ?? "")
|
|
70
|
+
.split(/[,\s;]+/)
|
|
71
|
+
.map((item) => item.trim().toLowerCase())
|
|
72
|
+
.filter(Boolean));
|
|
73
|
+
}
|
|
74
|
+
function selectFromEmail(deps, recipient) {
|
|
75
|
+
const env = deps.env;
|
|
76
|
+
if ((env.EMAIL_DELIVERY_PROFILE ?? "").trim().toLowerCase() !== "production_like")
|
|
77
|
+
return deps.fromEmail;
|
|
78
|
+
const sandboxRecipients = parseEmailList(env.EMAIL_SANDBOX_RECIPIENTS ?? env.COMMS_SANDBOX_SINK_ADDRESS ?? deps.defaultSandboxRecipient);
|
|
79
|
+
if (sandboxRecipients.has(recipient.trim().toLowerCase()))
|
|
80
|
+
return deps.fromEmail;
|
|
81
|
+
return env.EMAIL_REAL_DELIVERY_FROM_EMAIL?.trim()
|
|
82
|
+
|| env.AUTH_EMAIL_REAL_DELIVERY_FROM_EMAIL?.trim()
|
|
83
|
+
|| deps.fromEmail;
|
|
84
|
+
}
|
|
85
|
+
function validateAuthEmailRedirectOrigin(deps, redirectTo, host) {
|
|
86
|
+
const env = deps.env;
|
|
87
|
+
const originConfiguration = readEmailOriginConfiguration(env, {
|
|
88
|
+
defaultOrigin: deps.defaultOrigin,
|
|
89
|
+
productionEmailHosts: deps.productionEmailHosts,
|
|
90
|
+
});
|
|
91
|
+
const environment = env.EMAIL_ENVIRONMENT ?? host.readObservedEnvironment(env);
|
|
92
|
+
const configured = resolveEmailOrigin({
|
|
93
|
+
...originConfiguration,
|
|
94
|
+
explicitBaseUrl: env.APP_BASE_URL?.trim() || deps.legacyBaseUrl,
|
|
95
|
+
customerAuthRedirectOrigin: env.CUSTOMER_AUTH_REDIRECT_ORIGIN,
|
|
96
|
+
siteUrl: env.SITE_URL,
|
|
97
|
+
hiddenPreviewEnabled: host.readHiddenPreviewEnabled(env),
|
|
98
|
+
environment,
|
|
99
|
+
});
|
|
100
|
+
if (configured.ok === false)
|
|
101
|
+
return { ok: false, error: configured.error };
|
|
102
|
+
const redirect = resolveEmailOrigin({
|
|
103
|
+
...originConfiguration,
|
|
104
|
+
explicitBaseUrl: redirectTo,
|
|
105
|
+
hiddenPreviewEnabled: host.readHiddenPreviewEnabled(env),
|
|
106
|
+
environment,
|
|
107
|
+
});
|
|
108
|
+
if (redirect.ok === false)
|
|
109
|
+
return { ok: false, error: redirect.error };
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
environment: configured.resolved.environment,
|
|
113
|
+
origin: redirect.origin,
|
|
114
|
+
production: configured.resolved.production,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function persistenceOutcome(result) {
|
|
118
|
+
return {
|
|
119
|
+
ok: result.outcome.ok,
|
|
120
|
+
messageId: result.outcome.messageId,
|
|
121
|
+
providerError: result.outcome.providerError,
|
|
122
|
+
aborted: result.outcome.aborted,
|
|
123
|
+
skipReason: result.outcome.adminDisabled === true
|
|
124
|
+
? "admin_disabled"
|
|
125
|
+
: result.outcome.suppressed === "drop" ? "egress_suppressed" : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export function createAuthSendEmailHookCandidate(deps, host) {
|
|
129
|
+
return async function authSendEmailHookCandidate(req, res) {
|
|
130
|
+
if (req.method === "OPTIONS") {
|
|
131
|
+
for (const [key, value] of Object.entries(CORS_HEADERS))
|
|
132
|
+
res.setHeader(key, value);
|
|
133
|
+
res.status(200).send("ok");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (req.method !== "POST")
|
|
137
|
+
return hookError(res, 405, "Method not allowed");
|
|
138
|
+
if (!deps.hookSecret) {
|
|
139
|
+
console.error("[auth-send-email] SEND_EMAIL_HOOK_SECRET not configured");
|
|
140
|
+
return hookError(res, 500, "Hook secret not configured");
|
|
141
|
+
}
|
|
142
|
+
if (!absoluteAuthServiceUrl(deps.authServiceUrl)) {
|
|
143
|
+
console.error("[auth-send-email] auth service URL not configured");
|
|
144
|
+
return hookError(res, 500, "Auth service URL not configured");
|
|
145
|
+
}
|
|
146
|
+
let rawBody;
|
|
147
|
+
try {
|
|
148
|
+
rawBody = await readRawBody(req);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return hookError(res, 400, "Invalid JSON payload");
|
|
152
|
+
}
|
|
153
|
+
const valid = await verifyStandardWebhookSignature({
|
|
154
|
+
headers: {
|
|
155
|
+
id: typeof req.headers["webhook-id"] === "string" ? req.headers["webhook-id"] : null,
|
|
156
|
+
timestamp: typeof req.headers["webhook-timestamp"] === "string" ? req.headers["webhook-timestamp"] : null,
|
|
157
|
+
signature: typeof req.headers["webhook-signature"] === "string" ? req.headers["webhook-signature"] : null,
|
|
158
|
+
},
|
|
159
|
+
rawBody,
|
|
160
|
+
secret: deps.hookSecret,
|
|
161
|
+
now: deps.now,
|
|
162
|
+
});
|
|
163
|
+
if (!valid)
|
|
164
|
+
return hookError(res, 401, "Invalid signature");
|
|
165
|
+
let payload;
|
|
166
|
+
try {
|
|
167
|
+
payload = JSON.parse(rawBody);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return hookError(res, 400, "Invalid JSON payload");
|
|
171
|
+
}
|
|
172
|
+
const email = payload.user?.email;
|
|
173
|
+
const data = payload.email_data;
|
|
174
|
+
const actionType = data?.email_action_type;
|
|
175
|
+
const tokenHash = data?.token_hash;
|
|
176
|
+
const redirect = resolveRedirectTarget(data, deps.defaultRedirectTo);
|
|
177
|
+
if (!email || !actionType || !tokenHash)
|
|
178
|
+
return hookError(res, 400, "Missing user email or email_data");
|
|
179
|
+
const redirectPolicy = validateAuthEmailRedirectOrigin(deps, redirect.redirectTo, host);
|
|
180
|
+
if (redirectPolicy.ok === false)
|
|
181
|
+
return hookError(res, 500, `Email origin policy failed: ${redirectPolicy.error}`);
|
|
182
|
+
if (!KNOWN_ACTION_TYPES.has(actionType))
|
|
183
|
+
console.warn("[auth-send-email] unknown email_action_type:", actionType);
|
|
184
|
+
let content;
|
|
185
|
+
try {
|
|
186
|
+
content = deps.content.buildAuthEmailContent({
|
|
187
|
+
actionType, userMetadata: payload.user?.user_metadata, redirectTo: redirect.redirectTo,
|
|
188
|
+
actionLink: buildAuthVerifyUrl(deps.authServiceUrl, actionType, tokenHash, redirect.redirectTo),
|
|
189
|
+
otp: data?.token ?? null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof AuthEmailContentUnavailableError)
|
|
194
|
+
return hookError(res, 503, error.message);
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
const rendered = host.renderEmail({
|
|
198
|
+
brand: deps.emailBrandForOrigin(redirectPolicy.origin), locale: content.locale,
|
|
199
|
+
subject: content.subject, preheader: content.preheader, blocks: content.blocks,
|
|
200
|
+
});
|
|
201
|
+
const sendAttemptId = crypto.randomUUID();
|
|
202
|
+
const authUserId = uuidOrNull(payload.user?.id);
|
|
203
|
+
const aggregateId = authUserId ?? crypto.randomUUID();
|
|
204
|
+
// Match the hosted hook's ordering: persistence/control failure is a
|
|
205
|
+
// retryable refusal before any email side effect, never a post-send false 5xx.
|
|
206
|
+
const templateSlug = `auth-${actionType}`;
|
|
207
|
+
const enabled = await deps.persistence.isTemplateEnabled(templateSlug, new AbortController().signal);
|
|
208
|
+
const sendResult = enabled
|
|
209
|
+
? await guardAuthEmailTransport(deps.transport).send({
|
|
210
|
+
sender: selectFromEmail(deps, email), recipient: email,
|
|
211
|
+
subject: rendered.subject, html: rendered.html, text: rendered.text,
|
|
212
|
+
})
|
|
213
|
+
: {
|
|
214
|
+
outcome: { ok: true, messageId: null, providerError: null, aborted: false, adminDisabled: true },
|
|
215
|
+
providerResponse: { skipped: "admin_disabled" },
|
|
216
|
+
};
|
|
217
|
+
const mode = deliveryMode(sendResult, deps.env, email, deps.defaultSandboxRecipient);
|
|
218
|
+
const originMetadata = {
|
|
219
|
+
emailBaseUrl: redirectPolicy.origin,
|
|
220
|
+
emailOriginSource: redirect.source,
|
|
221
|
+
emailEnvironment: redirectPolicy.environment,
|
|
222
|
+
};
|
|
223
|
+
await deps.persistence.recordSend({
|
|
224
|
+
templateSlug,
|
|
225
|
+
recipientEmail: email,
|
|
226
|
+
authUserId,
|
|
227
|
+
aggregateId,
|
|
228
|
+
dedupeKey: `auth-send-email:${actionType}:${sendAttemptId}`,
|
|
229
|
+
providerKind: deps.transport.providerKind,
|
|
230
|
+
outcome: persistenceOutcome(sendResult),
|
|
231
|
+
providerResponse: sendResult.providerResponse,
|
|
232
|
+
metadata: {
|
|
233
|
+
...originMetadata,
|
|
234
|
+
authEmailDeliveryMode: mode,
|
|
235
|
+
deliveryMode: mode,
|
|
236
|
+
triggerSource: "auth-send-email",
|
|
237
|
+
triggerReason: actionType,
|
|
238
|
+
providerKind: deps.transport.providerKind,
|
|
239
|
+
sendAttemptId,
|
|
240
|
+
aggregateType: "auth_user",
|
|
241
|
+
aggregateId,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
if (!sendResult.outcome.ok) {
|
|
245
|
+
console.error("[auth-send-email] transport send failed:", sendResult.outcome.providerError);
|
|
246
|
+
return hookError(res, 502, "Email delivery failed");
|
|
247
|
+
}
|
|
248
|
+
json(res, 200, {});
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CapturedTransactionalDeliveryPort, TransactionalDeliveryCommand } from "./contracts/transactionalDelivery.js";
|
|
2
|
+
export type CapturedTransactionalDeliveryOutcome = {
|
|
3
|
+
readonly state: "accepted";
|
|
4
|
+
readonly deliveryReference?: string;
|
|
5
|
+
} | {
|
|
6
|
+
readonly state: "failed";
|
|
7
|
+
};
|
|
8
|
+
/** Injected only by tests and the parity proof to demonstrate durable recovery. */
|
|
9
|
+
export type CapturedTransactionalDeliveryOutcomeSource = (command: TransactionalDeliveryCommand) => Promise<CapturedTransactionalDeliveryOutcome> | CapturedTransactionalDeliveryOutcome;
|
|
10
|
+
/** Deterministic no-egress delivery adapter for the portable direct action. */
|
|
11
|
+
export declare function createCapturedTransactionalDelivery(options?: {
|
|
12
|
+
outcome?: CapturedTransactionalDeliveryOutcomeSource;
|
|
13
|
+
}): CapturedTransactionalDeliveryPort;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Deterministic no-egress delivery adapter for the portable direct action. */
|
|
2
|
+
export function createCapturedTransactionalDelivery(options = {}) {
|
|
3
|
+
return {
|
|
4
|
+
async deliver(command) {
|
|
5
|
+
const outcome = await (options.outcome?.(command) ?? { state: "accepted" });
|
|
6
|
+
if (outcome.state === "failed")
|
|
7
|
+
throw new Error("captured_delivery_failed");
|
|
8
|
+
const deliveryReference = outcome.deliveryReference
|
|
9
|
+
?? `captured:${createHash("sha256").update(command.idempotencyKey).digest("hex")}`;
|
|
10
|
+
return { deliveryReference };
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CommunicationSyncStore } from "./communicationSyncWorker.js";
|
|
2
|
+
export interface CommunicationSyncRpcClient {
|
|
3
|
+
rpc(functionName: string, args: Record<string, unknown>): PromiseLike<{
|
|
4
|
+
data: unknown;
|
|
5
|
+
error: {
|
|
6
|
+
code?: string;
|
|
7
|
+
message?: string;
|
|
8
|
+
} | null;
|
|
9
|
+
}>;
|
|
10
|
+
}
|
|
11
|
+
export declare function createSupabaseCommunicationSyncStore(client: CommunicationSyncRpcClient): CommunicationSyncStore;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
function rpcFailure(rpcName, error) {
|
|
2
|
+
return new Error(`${rpcName}_failed: ${error.message ?? error.code ?? "unknown"}`);
|
|
3
|
+
}
|
|
4
|
+
export function createSupabaseCommunicationSyncStore(client) {
|
|
5
|
+
return {
|
|
6
|
+
async claimBatch(input) {
|
|
7
|
+
const { data, error } = await client.rpc("communication_claim_sync_outbox", {
|
|
8
|
+
p_provider_kind: input.providerKind,
|
|
9
|
+
p_batch_size: input.batchSize,
|
|
10
|
+
p_visibility_seconds: input.visibilitySeconds,
|
|
11
|
+
p_max_attempts: input.maxAttempts,
|
|
12
|
+
});
|
|
13
|
+
if (error)
|
|
14
|
+
throw rpcFailure("communication_claim_sync_outbox", error);
|
|
15
|
+
return Array.isArray(data) ? data.map(mapClaimedEvent).filter(isEvent) : [];
|
|
16
|
+
},
|
|
17
|
+
async markSent(input) {
|
|
18
|
+
const { data, error } = await client.rpc("communication_mark_sync_outbox_sent", {
|
|
19
|
+
p_outbox_id: input.eventId,
|
|
20
|
+
p_claim_token: input.claimToken,
|
|
21
|
+
p_provider_kind: input.providerKind,
|
|
22
|
+
p_remote_profile_id: input.remoteProfileId ?? null,
|
|
23
|
+
p_response_summary: input.responseSummary ?? {},
|
|
24
|
+
});
|
|
25
|
+
if (error)
|
|
26
|
+
throw rpcFailure("communication_mark_sync_outbox_sent", error);
|
|
27
|
+
return data === true;
|
|
28
|
+
},
|
|
29
|
+
async markFailed(input) {
|
|
30
|
+
const { data, error } = await client.rpc("communication_mark_sync_outbox_failed", {
|
|
31
|
+
p_outbox_id: input.eventId,
|
|
32
|
+
p_claim_token: input.claimToken,
|
|
33
|
+
p_provider_kind: input.providerKind,
|
|
34
|
+
p_error: input.error,
|
|
35
|
+
p_retry: input.retry,
|
|
36
|
+
p_backoff_seconds: input.backoffSeconds,
|
|
37
|
+
});
|
|
38
|
+
if (error)
|
|
39
|
+
throw rpcFailure("communication_mark_sync_outbox_failed", error);
|
|
40
|
+
return data === true;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function mapClaimedEvent(row) {
|
|
45
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
46
|
+
return null;
|
|
47
|
+
const record = row;
|
|
48
|
+
if (typeof record.id !== "string" ||
|
|
49
|
+
typeof record.claim_token !== "string" ||
|
|
50
|
+
typeof record.contact_id !== "string" ||
|
|
51
|
+
typeof record.normalized_email !== "string" ||
|
|
52
|
+
typeof record.event_type !== "string" ||
|
|
53
|
+
typeof record.purpose !== "string") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
id: record.id,
|
|
58
|
+
claimToken: record.claim_token,
|
|
59
|
+
contactId: record.contact_id,
|
|
60
|
+
normalizedEmail: record.normalized_email,
|
|
61
|
+
eventType: record.event_type,
|
|
62
|
+
purpose: record.purpose,
|
|
63
|
+
payload: isRecord(record.payload) ? record.payload : {},
|
|
64
|
+
attemptCount: typeof record.attempt_count === "number" ? record.attempt_count : 0,
|
|
65
|
+
originProviderKind: readNullableString(record.origin_provider_kind),
|
|
66
|
+
originProviderEventId: readNullableString(record.origin_provider_event_id),
|
|
67
|
+
remoteProfileId: readNullableString(record.remote_profile_id),
|
|
68
|
+
remoteListId: readNullableString(record.remote_list_id),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isEvent(event) {
|
|
72
|
+
return Boolean(event);
|
|
73
|
+
}
|
|
74
|
+
function isRecord(value) {
|
|
75
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
76
|
+
}
|
|
77
|
+
function readNullableString(value) {
|
|
78
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CommunicationPurposeRegistry } from "./contracts/consent.js";
|
|
2
|
+
import type { NewsletterMarketingPurpose } from "./contracts/newsletter.js";
|
|
3
|
+
import type { NewsletterSyncEvent, NewsletterSyncProviderPort } from "./contracts/newsletter.js";
|
|
4
|
+
export interface CommunicationSyncStore<Purpose extends string = NewsletterMarketingPurpose> {
|
|
5
|
+
claimBatch(input: {
|
|
6
|
+
providerKind: string;
|
|
7
|
+
batchSize: number;
|
|
8
|
+
visibilitySeconds: number;
|
|
9
|
+
maxAttempts: number;
|
|
10
|
+
}): Promise<NewsletterSyncEvent<Purpose>[]>;
|
|
11
|
+
markSent(input: {
|
|
12
|
+
eventId: string;
|
|
13
|
+
claimToken: string;
|
|
14
|
+
providerKind: string;
|
|
15
|
+
remoteProfileId?: string | null;
|
|
16
|
+
responseSummary?: Record<string, unknown>;
|
|
17
|
+
}): Promise<boolean>;
|
|
18
|
+
markFailed(input: {
|
|
19
|
+
eventId: string;
|
|
20
|
+
claimToken: string;
|
|
21
|
+
providerKind: string;
|
|
22
|
+
error: string;
|
|
23
|
+
retry: boolean;
|
|
24
|
+
backoffSeconds: number;
|
|
25
|
+
}): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
export interface CommunicationSyncConfig {
|
|
28
|
+
batchSize: number;
|
|
29
|
+
maxAttempts: number;
|
|
30
|
+
visibilitySeconds: number;
|
|
31
|
+
backoffSeconds: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CommunicationSyncRunResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
checked: number;
|
|
36
|
+
updated: number;
|
|
37
|
+
failures: number;
|
|
38
|
+
skipped: boolean;
|
|
39
|
+
reason?: string;
|
|
40
|
+
providerKind: string;
|
|
41
|
+
sent: number;
|
|
42
|
+
retried: number;
|
|
43
|
+
skippedEvents: number;
|
|
44
|
+
}
|
|
45
|
+
export declare const DEFAULT_COMMUNICATION_SYNC_CONFIG: CommunicationSyncConfig;
|
|
46
|
+
export declare function runCommunicationSyncDispatch<Purpose extends string = NewsletterMarketingPurpose>(input: {
|
|
47
|
+
store: CommunicationSyncStore<Purpose>;
|
|
48
|
+
provider: NewsletterSyncProviderPort<Purpose>;
|
|
49
|
+
config?: Partial<CommunicationSyncConfig>;
|
|
50
|
+
registry?: CommunicationPurposeRegistry<Purpose>;
|
|
51
|
+
}): Promise<CommunicationSyncRunResult>;
|
|
52
|
+
export declare function runCommunicationSyncDispatchForProviders<Purpose extends string = NewsletterMarketingPurpose>(input: {
|
|
53
|
+
store: CommunicationSyncStore<Purpose>;
|
|
54
|
+
providers: NewsletterSyncProviderPort<Purpose>[];
|
|
55
|
+
config?: Partial<CommunicationSyncConfig>;
|
|
56
|
+
registry?: CommunicationPurposeRegistry<Purpose>;
|
|
57
|
+
}): Promise<CommunicationSyncRunResult>;
|
|
58
|
+
export interface CommunicationReconcileRunResult {
|
|
59
|
+
ok: boolean;
|
|
60
|
+
checked: number;
|
|
61
|
+
updated: number;
|
|
62
|
+
failures: number;
|
|
63
|
+
skipped: boolean;
|
|
64
|
+
reason?: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function runCommunicationSyncReconcile(input?: {
|
|
67
|
+
providers?: NewsletterSyncProviderPort[];
|
|
68
|
+
}): Promise<CommunicationReconcileRunResult>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { requiredNewsletterCapabilityForEvent, supportsNewsletterSyncEvent, } from "./newsletterIntegrationContracts.js";
|
|
2
|
+
export const DEFAULT_COMMUNICATION_SYNC_CONFIG = {
|
|
3
|
+
batchSize: 25,
|
|
4
|
+
maxAttempts: 8,
|
|
5
|
+
visibilitySeconds: 300,
|
|
6
|
+
backoffSeconds: 300,
|
|
7
|
+
};
|
|
8
|
+
export async function runCommunicationSyncDispatch(input) {
|
|
9
|
+
const config = { ...DEFAULT_COMMUNICATION_SYNC_CONFIG, ...input.config };
|
|
10
|
+
const registry = input.registry;
|
|
11
|
+
const events = await input.store.claimBatch({
|
|
12
|
+
providerKind: input.provider.providerKind,
|
|
13
|
+
batchSize: clamp(config.batchSize, 1, 100),
|
|
14
|
+
visibilitySeconds: clamp(config.visibilitySeconds, 60, 3600),
|
|
15
|
+
maxAttempts: clamp(config.maxAttempts, 1, 20),
|
|
16
|
+
});
|
|
17
|
+
if (registry && events.some(event => !registry.has(event.purpose)))
|
|
18
|
+
throw new Error("unregistered_communication_purpose");
|
|
19
|
+
const result = {
|
|
20
|
+
ok: true,
|
|
21
|
+
checked: events.length,
|
|
22
|
+
updated: 0,
|
|
23
|
+
failures: 0,
|
|
24
|
+
skipped: events.length === 0,
|
|
25
|
+
reason: events.length === 0 ? "empty_queue" : undefined,
|
|
26
|
+
providerKind: input.provider.providerKind,
|
|
27
|
+
sent: 0,
|
|
28
|
+
retried: 0,
|
|
29
|
+
skippedEvents: 0,
|
|
30
|
+
};
|
|
31
|
+
for (const event of events) {
|
|
32
|
+
try {
|
|
33
|
+
if (event.originProviderKind === input.provider.providerKind) {
|
|
34
|
+
const applied = await input.store.markSent({
|
|
35
|
+
eventId: event.id,
|
|
36
|
+
claimToken: event.claimToken,
|
|
37
|
+
providerKind: input.provider.providerKind,
|
|
38
|
+
remoteProfileId: event.remoteProfileId ?? null,
|
|
39
|
+
responseSummary: {
|
|
40
|
+
status: "skipped",
|
|
41
|
+
reason: "origin_provider_echo_loop",
|
|
42
|
+
originProviderKind: event.originProviderKind,
|
|
43
|
+
originProviderEventId: event.originProviderEventId ?? null,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
result.updated += applied ? 1 : 0;
|
|
47
|
+
result.skippedEvents += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!supportsNewsletterSyncEvent(input.provider.capabilities, event.eventType)) {
|
|
51
|
+
const requiredCapability = requiredNewsletterCapabilityForEvent(event.eventType);
|
|
52
|
+
const applied = await input.store.markSent({
|
|
53
|
+
eventId: event.id,
|
|
54
|
+
claimToken: event.claimToken,
|
|
55
|
+
providerKind: input.provider.providerKind,
|
|
56
|
+
remoteProfileId: event.remoteProfileId ?? null,
|
|
57
|
+
responseSummary: {
|
|
58
|
+
status: "skipped",
|
|
59
|
+
reason: "unsupported_provider_capability",
|
|
60
|
+
eventType: event.eventType,
|
|
61
|
+
requiredCapability,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
result.updated += applied ? 1 : 0;
|
|
65
|
+
result.skippedEvents += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const outcome = await input.provider.sync(event);
|
|
69
|
+
if (outcome.status === "sent" || outcome.status === "skipped") {
|
|
70
|
+
const applied = await input.store.markSent({
|
|
71
|
+
eventId: event.id,
|
|
72
|
+
claimToken: event.claimToken,
|
|
73
|
+
providerKind: input.provider.providerKind,
|
|
74
|
+
remoteProfileId: outcome.status === "sent" ? outcome.remoteProfileId : null,
|
|
75
|
+
responseSummary: {
|
|
76
|
+
status: outcome.status,
|
|
77
|
+
...(outcome.responseSummary ?? {}),
|
|
78
|
+
...(outcome.status === "skipped" ? { reason: outcome.reason } : {}),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
result.updated += applied ? 1 : 0;
|
|
82
|
+
if (outcome.status === "sent")
|
|
83
|
+
result.sent += 1;
|
|
84
|
+
if (outcome.status === "skipped")
|
|
85
|
+
result.skippedEvents += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
await input.store.markFailed({
|
|
89
|
+
eventId: event.id,
|
|
90
|
+
claimToken: event.claimToken,
|
|
91
|
+
providerKind: input.provider.providerKind,
|
|
92
|
+
error: outcome.reason,
|
|
93
|
+
retry: true,
|
|
94
|
+
backoffSeconds: outcome.retryAfterSeconds ?? config.backoffSeconds,
|
|
95
|
+
});
|
|
96
|
+
result.retried += 1;
|
|
97
|
+
result.failures += 1;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
await input.store.markFailed({
|
|
101
|
+
eventId: event.id,
|
|
102
|
+
claimToken: event.claimToken,
|
|
103
|
+
providerKind: input.provider.providerKind,
|
|
104
|
+
error: safeMessage(error),
|
|
105
|
+
retry: event.attemptCount + 1 < config.maxAttempts,
|
|
106
|
+
backoffSeconds: config.backoffSeconds,
|
|
107
|
+
});
|
|
108
|
+
result.retried += 1;
|
|
109
|
+
result.failures += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
result.ok = result.failures === 0;
|
|
113
|
+
if (!result.ok)
|
|
114
|
+
result.reason = "communication_sync_failures";
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
export async function runCommunicationSyncDispatchForProviders(input) {
|
|
118
|
+
if (input.providers.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
checked: 0,
|
|
122
|
+
updated: 0,
|
|
123
|
+
failures: 0,
|
|
124
|
+
skipped: true,
|
|
125
|
+
reason: "no_newsletter_providers_enabled",
|
|
126
|
+
providerKind: "provider_registry",
|
|
127
|
+
sent: 0,
|
|
128
|
+
retried: 0,
|
|
129
|
+
skippedEvents: 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const results = [];
|
|
133
|
+
for (const provider of input.providers) {
|
|
134
|
+
results.push(await runCommunicationSyncDispatch({
|
|
135
|
+
store: input.store,
|
|
136
|
+
provider,
|
|
137
|
+
config: input.config,
|
|
138
|
+
registry: input.registry,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
return aggregateProviderResults(results);
|
|
142
|
+
}
|
|
143
|
+
export async function runCommunicationSyncReconcile(input = {}) {
|
|
144
|
+
const providers = input.providers ?? [];
|
|
145
|
+
if (providers.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
checked: 0,
|
|
149
|
+
updated: 0,
|
|
150
|
+
failures: 0,
|
|
151
|
+
skipped: true,
|
|
152
|
+
reason: "no_newsletter_providers_enabled",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
let checked = 0;
|
|
156
|
+
let updated = 0;
|
|
157
|
+
let failures = 0;
|
|
158
|
+
let skipped = 0;
|
|
159
|
+
for (const provider of providers) {
|
|
160
|
+
if (!provider.reconcile) {
|
|
161
|
+
skipped += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const outcome = await provider.reconcile();
|
|
165
|
+
if (outcome.status === "skipped") {
|
|
166
|
+
skipped += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
checked += outcome.checked;
|
|
170
|
+
updated += outcome.updated;
|
|
171
|
+
failures += outcome.failures;
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
checked,
|
|
176
|
+
updated,
|
|
177
|
+
failures,
|
|
178
|
+
skipped: skipped === providers.length,
|
|
179
|
+
reason: skipped === providers.length ? "provider_neutral_reconcile_noop" : undefined,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function aggregateProviderResults(results) {
|
|
183
|
+
const aggregate = results.reduce((acc, item) => ({
|
|
184
|
+
ok: acc.ok && item.ok,
|
|
185
|
+
checked: acc.checked + item.checked,
|
|
186
|
+
updated: acc.updated + item.updated,
|
|
187
|
+
failures: acc.failures + item.failures,
|
|
188
|
+
skipped: acc.skipped && item.skipped,
|
|
189
|
+
providerKind: "provider_registry",
|
|
190
|
+
sent: acc.sent + item.sent,
|
|
191
|
+
retried: acc.retried + item.retried,
|
|
192
|
+
skippedEvents: acc.skippedEvents + item.skippedEvents,
|
|
193
|
+
reason: undefined,
|
|
194
|
+
}), {
|
|
195
|
+
ok: true,
|
|
196
|
+
checked: 0,
|
|
197
|
+
updated: 0,
|
|
198
|
+
failures: 0,
|
|
199
|
+
skipped: true,
|
|
200
|
+
providerKind: "provider_registry",
|
|
201
|
+
sent: 0,
|
|
202
|
+
retried: 0,
|
|
203
|
+
skippedEvents: 0,
|
|
204
|
+
});
|
|
205
|
+
if (!aggregate.ok)
|
|
206
|
+
aggregate.reason = "communication_sync_failures";
|
|
207
|
+
if (aggregate.skipped)
|
|
208
|
+
aggregate.reason = "empty_queue";
|
|
209
|
+
return aggregate;
|
|
210
|
+
}
|
|
211
|
+
function clamp(value, min, max) {
|
|
212
|
+
if (!Number.isFinite(value))
|
|
213
|
+
return min;
|
|
214
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
215
|
+
}
|
|
216
|
+
function safeMessage(error) {
|
|
217
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
218
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommunicationPurposeRegistry } from "./contracts/consent.js";
|
|
2
|
+
import type { CommunicationPermissionState, CommunicationPurpose } from "./contracts/types.js";
|
|
3
|
+
export type CommunicationRecipientKind = "external_contact" | "tester" | "customer" | "lead" | "admin_internal";
|
|
4
|
+
export interface CommunicationPermissionSnapshot {
|
|
5
|
+
state: CommunicationPermissionState | null;
|
|
6
|
+
source?: string | null;
|
|
7
|
+
legacySequencePaused?: boolean | null;
|
|
8
|
+
}
|
|
9
|
+
export interface CommunicationPolicyInput<Purpose extends string = CommunicationPurpose> {
|
|
10
|
+
purpose: Purpose;
|
|
11
|
+
recipientKind: CommunicationRecipientKind;
|
|
12
|
+
permission: CommunicationPermissionSnapshot | null;
|
|
13
|
+
}
|
|
14
|
+
export interface CommunicationPolicyResult {
|
|
15
|
+
decision: "allowed" | "blocked";
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function evaluateCommunicationPolicy<Purpose extends string = CommunicationPurpose>(input: CommunicationPolicyInput<Purpose>, registry?: CommunicationPurposeRegistry<Purpose>): CommunicationPolicyResult;
|
|
19
|
+
export declare function consentStateFromBoolean(value: boolean | null | undefined): CommunicationPermissionState;
|