@easybits.cloud/mailmask 0.4.12 → 0.6.1

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/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // src/client.ts
2
2
  var MailMaskError = class extends Error {
3
- constructor(status, message) {
3
+ constructor(status, message, opts = {}) {
4
4
  super(message);
5
5
  this.status = status;
6
6
  this.name = "MailMaskError";
7
+ if (opts.cause !== void 0) this.cause = opts.cause;
8
+ this.code = opts.code;
9
+ this.requestId = opts.requestId;
10
+ this.retryable = opts.retryable ?? (status === 0 || status === 429 || status >= 500);
7
11
  }
8
12
  };
9
13
  async function request(baseUrl, apiKey, path, opts, doFetch = fetch) {
@@ -585,13 +589,812 @@ function constantTimeEqual(a, b) {
585
589
  async function verifyWebhookSignature(secret, headers, rawBody, toleranceMs = 5 * 6e4) {
586
590
  const ts = Number(headers.timestamp);
587
591
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > toleranceMs) return false;
588
- const enc = new TextEncoder();
589
- const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
590
- const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${headers.timestamp}.${rawBody}`));
592
+ const enc2 = new TextEncoder();
593
+ const key = await crypto.subtle.importKey("raw", enc2.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
594
+ const mac = await crypto.subtle.sign("HMAC", key, enc2.encode(`${headers.timestamp}.${rawBody}`));
591
595
  return constantTimeEqual(`sha256=${hex(mac)}`, headers.signature);
592
596
  }
597
+
598
+ // src/partner-contract.ts
599
+ var PARTNER_API_PREFIX = "/api/partner/v1";
600
+ var PARTNER_HEADERS = {
601
+ /** Id público de la credencial: `mmk_…`. */
602
+ key: "X-MailMask-Key",
603
+ /** Segundos Unix (no milisegundos) del momento de firmar. */
604
+ ts: "X-MailMask-Ts",
605
+ /** HMAC-SHA256 en hex minúsculas (64 caracteres), sin prefijo. */
606
+ sig: "X-MailMask-Sig",
607
+ /** Opcional en `send`/`batch`: mismo efecto que `idempotencyKey` en el cuerpo. */
608
+ idempotencyKey: "Idempotency-Key",
609
+ /**
610
+ * Opcional en `send`/`batch`: `1` = modo de prueba. Valida todo igual (firma, tenant, dominio,
611
+ * cuerpo, variables, supresión, cuota) y responde como de verdad con `test: true`, pero no envía
612
+ * a SES, no consume cuota, no cuenta como tenant activo y no emite eventos al webhook. En las
613
+ * demás rutas se ignora.
614
+ */
615
+ test: "X-MailMask-Test"
616
+ };
617
+ var PARTNER_SIGNATURE_TOLERANCE_S = 300;
618
+ function partnerSignatureBase(ts, keyId, method, pathAndQuery, rawBody) {
619
+ return `${ts}.${keyId}.${method.toUpperCase()}.${pathAndQuery}.${rawBody}`;
620
+ }
621
+ var PARTNER_ERROR_STATUS = {
622
+ /** Firma ausente, mala, vencida, de una credencial revocada o inexistente: SIEMPRE el mismo 401. */
623
+ unauthorized: 401,
624
+ /** Firma válida, pero el partner está desactivado o el tenant suspendido por MailMask. */
625
+ forbidden: 403,
626
+ /** `resume` de una pausa que puso MailMask o SES por reputación: sólo MailMask la levanta. */
627
+ reputation_hold: 403,
628
+ /** El tenant, dominio, lote o recurso no existe PARA ESTE partner (los de otros también dan 404). */
629
+ not_found: 404,
630
+ /** El dominio ya es de otra cuenta o de otro tenant; máximo de credenciales; estado incompatible. */
631
+ conflict: 409,
632
+ /** Validación: campos faltantes o inválidos, remitente fuera de los dominios del tenant,
633
+ * dominio sin verificar. */
634
+ invalid_request: 422,
635
+ /** El destinatario está suprimido para este stream (con `reason`, `stream` y `recipient`). No es
636
+ * un error del cuerpo: no reintentes ni cambies el código, marca a la persona en tu sistema. */
637
+ recipient_suppressed: 422,
638
+ /** El tenant está en pausa (por MailMask o por el partner). No se manda nada hasta `resume`. */
639
+ tenant_paused: 423,
640
+ /** Cuota mensual del tenant agotada. */
641
+ quota_exceeded: 429,
642
+ /** Demasiadas peticiones; reintenta tras `Retry-After`. */
643
+ rate_limited: 429,
644
+ /** Falla nuestra o de SES; reintentable con la misma `idempotencyKey`. */
645
+ internal: 500
646
+ };
647
+ var EXTERNAL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
648
+ function isValidExternalId(id) {
649
+ return EXTERNAL_ID_RE.test(id);
650
+ }
651
+ var MAX_BATCH_RECIPIENTS = 1e4;
652
+ var MAX_TAGS = 10;
653
+ var TAG_RE = /^[A-Za-z0-9_-]{1,64}$/;
654
+ var DOMAIN_CLAIM_TTL_DAYS = 7;
655
+ var FALLBACK_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
656
+ var RESERVED_FALLBACK_LABELS = [
657
+ "www",
658
+ "mail",
659
+ "smtp",
660
+ "imap",
661
+ "pop",
662
+ "mx",
663
+ "bounce",
664
+ "admin",
665
+ "postmaster",
666
+ "abuse",
667
+ "noreply",
668
+ "no-reply"
669
+ ];
670
+ function isValidFallbackLabel(label) {
671
+ return FALLBACK_LABEL_RE.test(label) && !RESERVED_FALLBACK_LABELS.includes(label);
672
+ }
673
+ var MESSAGE_STREAMS = ["transactional", "broadcast"];
674
+ var TEMPLATE_VAR_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
675
+ var MAX_TEMPLATE_VARS = 50;
676
+ var MAX_TEMPLATE_VAR_NAME = 64;
677
+ var MAX_TEMPLATE_VAR_VALUE = 2e3;
678
+ var UNSUBSCRIBE_URL_VAR = "unsubscribe_url";
679
+ var TEMPLATE_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
680
+ function escapeHtml(v) {
681
+ return v.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
682
+ }
683
+ function renderTemplate(template, vars, opts = {}) {
684
+ return template.replace(TEMPLATE_PLACEHOLDER_RE, (_, name) => {
685
+ const v = Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : "";
686
+ return opts.html ? escapeHtml(v) : v;
687
+ });
688
+ }
689
+ function templateUsesVar(template, name) {
690
+ TEMPLATE_PLACEHOLDER_RE.lastIndex = 0;
691
+ for (const m of template.matchAll(TEMPLATE_PLACEHOLDER_RE)) if (m[1] === name) return true;
692
+ return false;
693
+ }
694
+ var UNSUBSCRIBE_FOOTER_HTML = `<p style="margin:32px 0 0 0;font-size:12px;line-height:1.5;color:#8a8a8a;text-align:center">\xBFYa no quieres recibir estos correos? <a href="{{${UNSUBSCRIBE_URL_VAR}}}">Cancelar suscripci\xF3n</a></p>`;
695
+ var UNSUBSCRIBE_FOOTER_TEXT = `
696
+
697
+ --
698
+ \xBFYa no quieres recibir estos correos? Cancela tu suscripci\xF3n: {{${UNSUBSCRIBE_URL_VAR}}}`;
699
+ function appendToHtmlBody(html, fragment) {
700
+ const i = html.toLowerCase().lastIndexOf("</body>");
701
+ return i === -1 ? `${html}${fragment}` : `${html.slice(0, i)}${fragment}${html.slice(i)}`;
702
+ }
703
+ function personalizePartnerMessage(input) {
704
+ const vars = { ...input.vars ?? {} };
705
+ delete vars[UNSUBSCRIBE_URL_VAR];
706
+ if (input.unsubscribeUrl) vars[UNSUBSCRIBE_URL_VAR] = input.unsubscribeUrl;
707
+ let { html, text } = input;
708
+ if (input.unsubscribeUrl) {
709
+ if (html && !templateUsesVar(html, UNSUBSCRIBE_URL_VAR)) html = appendToHtmlBody(html, UNSUBSCRIBE_FOOTER_HTML);
710
+ if (text && !templateUsesVar(text, UNSUBSCRIBE_URL_VAR)) text = `${text.replace(/\s+$/, "")}${UNSUBSCRIBE_FOOTER_TEXT}`;
711
+ }
712
+ return {
713
+ // Un salto de línea en el asunto sería un header inyectado: se aplana.
714
+ subject: renderTemplate(input.subject, vars).replace(/[\r\n]+/g, " "),
715
+ ...html !== void 0 ? { html: renderTemplate(html, vars, { html: true }) } : {},
716
+ ...text !== void 0 ? { text: renderTemplate(text, vars) } : {}
717
+ };
718
+ }
719
+ var PARTNER_WEBHOOK_EVENTS = [
720
+ "email.sent",
721
+ "email.delivered",
722
+ "email.bounced",
723
+ "email.complained",
724
+ "email.received",
725
+ "contact.unsubscribed",
726
+ "tenant.paused",
727
+ "tenant.resumed",
728
+ "domain.verified"
729
+ ];
730
+ var WEBHOOK_HEADERS = {
731
+ event: "X-MailMask-Event",
732
+ delivery: "X-MailMask-Delivery",
733
+ /** MILISEGUNDOS Unix, en texto (distinto de `X-MailMask-Ts` de las peticiones). */
734
+ timestamp: "X-MailMask-Timestamp",
735
+ /**
736
+ * `sha256=` + hex de HMAC-SHA256(secreto `whsec_…`, `webhookSignatureBase(...)`). Durante la gracia
737
+ * de una rotación vienen dos separadas por coma (`sha256=<nueva>,sha256=<vieja>`): el receptor
738
+ * acepta la entrega si CUALQUIERA coincide con su secreto.
739
+ */
740
+ signature: "X-MailMask-Signature"
741
+ };
742
+ var WEBHOOK_TOLERANCE_MS = 5 * 6e4;
743
+ function webhookSignatureBase(timestamp, rawBody) {
744
+ return `${timestamp}.${rawBody}`;
745
+ }
746
+ var STRICT_FIRST_DAYS = 30;
747
+ var PARTNER_ROUTES = {
748
+ putTenant: { method: "PUT", path: "/tenants/:externalId" },
749
+ listTenants: { method: "GET", path: "/tenants" },
750
+ getTenant: { method: "GET", path: "/tenants/:externalId" },
751
+ deleteTenant: { method: "DELETE", path: "/tenants/:externalId" },
752
+ pauseTenant: { method: "POST", path: "/tenants/:externalId/pause" },
753
+ resumeTenant: { method: "POST", path: "/tenants/:externalId/resume" },
754
+ addDomain: { method: "POST", path: "/tenants/:externalId/domains" },
755
+ getDomain: { method: "GET", path: "/tenants/:externalId/domains/:domain" },
756
+ send: { method: "POST", path: "/tenants/:externalId/send" },
757
+ batch: { method: "POST", path: "/tenants/:externalId/batch" },
758
+ getBatch: { method: "GET", path: "/tenants/:externalId/batch/:batchId" },
759
+ usage: { method: "GET", path: "/usage" },
760
+ getWebhook: { method: "GET", path: "/webhook" },
761
+ setWebhook: { method: "PUT", path: "/webhook" },
762
+ testWebhook: { method: "POST", path: "/webhook/test" },
763
+ rotateWebhookSecret: { method: "POST", path: "/webhook/rotate-secret" }
764
+ };
765
+ var PARTNER_ADMIN_ROUTES = {
766
+ listPartners: { method: "GET", path: "/api/partners" },
767
+ createPartner: { method: "POST", path: "/api/partners" },
768
+ setReputationPolicy: { method: "PUT", path: "/api/partners/:partnerId/reputation-policy" },
769
+ /** Sólo MailMask (admin): levanta cualquier pausa, incluida la de reputación. */
770
+ resumeTenant: { method: "POST", path: "/api/partners/:partnerId/tenants/:externalId/resume" },
771
+ /** Sólo MailMask (admin): crea o adopta la zona de respaldo y devuelve los NS a delegar. */
772
+ setFallbackZone: { method: "PUT", path: "/api/partners/:partnerId/fallback-zone" },
773
+ /** La zona de respaldo y si la delegación ya se ve en el DNS público. */
774
+ getFallbackZone: { method: "GET", path: "/api/partners/:partnerId/fallback-zone" },
775
+ listCredentials: { method: "GET", path: "/api/partners/:partnerId/credentials" },
776
+ createCredential: { method: "POST", path: "/api/partners/:partnerId/credentials" },
777
+ rotateCredential: { method: "POST", path: "/api/partners/:partnerId/credentials/:keyId/rotate" },
778
+ revokeCredential: { method: "DELETE", path: "/api/partners/:partnerId/credentials/:keyId" }
779
+ };
780
+
781
+ // src/partner.ts
782
+ var PartnerApiError = class extends MailMaskError {
783
+ constructor(status, message, body = {}, meta = {}) {
784
+ super(status, message, {
785
+ code: body.code,
786
+ requestId: meta.requestId,
787
+ cause: meta.cause,
788
+ // `quota_exceeded` es un 429 que no se arregla reintentando: hasta el mes siguiente.
789
+ retryable: status === 0 || status >= 500 || status === 429 && body.code !== "quota_exceeded"
790
+ });
791
+ this.kind = "unknown";
792
+ this.name = "PartnerApiError";
793
+ this.body = body;
794
+ this.retryAfterMs = meta.retryAfterMs ?? null;
795
+ if (status >= 500) this.kind = "server";
796
+ }
797
+ };
798
+ var AuthError = class extends PartnerApiError {
799
+ constructor() {
800
+ super(...arguments);
801
+ this.name = "AuthError";
802
+ this.kind = "auth";
803
+ /**
804
+ * Diferencia entre el reloj de MailMask (header `Date` de la respuesta) y el tuyo, en ms
805
+ * (positivo = tu reloj va atrasado). null si la respuesta no trajo `Date`. Con más de
806
+ * `PARTNER_SIGNATURE_TOLERANCE_S` segundos la firma nunca va a pasar.
807
+ */
808
+ this.clockSkewMs = null;
809
+ }
810
+ };
811
+ var ForbiddenError = class extends PartnerApiError {
812
+ constructor() {
813
+ super(...arguments);
814
+ this.name = "ForbiddenError";
815
+ this.kind = "forbidden";
816
+ }
817
+ };
818
+ var ReputationHoldError = class extends ForbiddenError {
819
+ constructor() {
820
+ super(...arguments);
821
+ this.name = "ReputationHoldError";
822
+ this.kind = "reputation_hold";
823
+ }
824
+ };
825
+ var NotFoundError = class extends PartnerApiError {
826
+ constructor() {
827
+ super(...arguments);
828
+ this.name = "NotFoundError";
829
+ this.kind = "not_found";
830
+ }
831
+ };
832
+ var ConflictError = class extends PartnerApiError {
833
+ constructor() {
834
+ super(...arguments);
835
+ this.name = "ConflictError";
836
+ this.kind = "conflict";
837
+ }
838
+ };
839
+ var ValidationError = class extends PartnerApiError {
840
+ constructor() {
841
+ super(...arguments);
842
+ this.name = "ValidationError";
843
+ this.kind = "validation";
844
+ }
845
+ get field() {
846
+ return this.body.field;
847
+ }
848
+ };
849
+ var RecipientSuppressedError = class extends PartnerApiError {
850
+ constructor() {
851
+ super(...arguments);
852
+ this.name = "RecipientSuppressedError";
853
+ this.kind = "suppressed";
854
+ }
855
+ get reason() {
856
+ return this.body.reason;
857
+ }
858
+ get stream() {
859
+ return this.body.stream;
860
+ }
861
+ get recipient() {
862
+ return this.body.recipient;
863
+ }
864
+ };
865
+ var PausedError = class extends PartnerApiError {
866
+ constructor() {
867
+ super(...arguments);
868
+ this.name = "PausedError";
869
+ this.kind = "paused";
870
+ }
871
+ };
872
+ var QuotaError = class extends PartnerApiError {
873
+ constructor() {
874
+ super(...arguments);
875
+ this.name = "QuotaError";
876
+ this.kind = "quota";
877
+ }
878
+ get limit() {
879
+ return this.body.limit;
880
+ }
881
+ get resetsAt() {
882
+ return this.body.resetsAt;
883
+ }
884
+ };
885
+ var RateLimitError = class extends QuotaError {
886
+ constructor() {
887
+ super(...arguments);
888
+ this.name = "RateLimitError";
889
+ this.kind = "rate_limit";
890
+ }
891
+ };
892
+ var NetworkError = class extends PartnerApiError {
893
+ constructor(message, cause) {
894
+ super(0, message, {}, { cause });
895
+ this.name = "NetworkError";
896
+ this.kind = "network";
897
+ }
898
+ };
899
+ var TimeoutError = class extends NetworkError {
900
+ constructor() {
901
+ super(...arguments);
902
+ this.name = "TimeoutError";
903
+ this.kind = "timeout";
904
+ }
905
+ };
906
+ function errorFor(status, body, fallback, meta = {}) {
907
+ const msg = body.error || fallback;
908
+ if (body.code === "reputation_hold") return new ReputationHoldError(status, msg, body, meta);
909
+ if (body.code === "recipient_suppressed") return new RecipientSuppressedError(status, msg, body, meta);
910
+ if (status === 429 && body.code !== "quota_exceeded") return new RateLimitError(status, msg, body, meta);
911
+ switch (status) {
912
+ case 401:
913
+ return new AuthError(status, msg, body, meta);
914
+ case 403:
915
+ return new ForbiddenError(status, msg, body, meta);
916
+ case 404:
917
+ return new NotFoundError(status, msg, body, meta);
918
+ case 409:
919
+ return new ConflictError(status, msg, body, meta);
920
+ case 422:
921
+ return new ValidationError(status, msg, body, meta);
922
+ case 423:
923
+ return new PausedError(status, msg, body, meta);
924
+ case 429:
925
+ return new QuotaError(status, msg, body, meta);
926
+ default:
927
+ return new PartnerApiError(status, msg, body, meta);
928
+ }
929
+ }
930
+ var enc = new TextEncoder();
931
+ function hex2(buf) {
932
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
933
+ }
934
+ async function hmacHex(secret, text) {
935
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
936
+ return hex2(await crypto.subtle.sign("HMAC", key, enc.encode(text)));
937
+ }
938
+ function constantTimeEqual2(a, b) {
939
+ if (a.length !== b.length) return false;
940
+ let diff = 0;
941
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
942
+ return diff === 0;
943
+ }
944
+ async function signPartnerRequest(opts) {
945
+ const ts = String(opts.ts ?? Math.floor(Date.now() / 1e3));
946
+ const sig = await hmacHex(opts.secret, partnerSignatureBase(ts, opts.keyId, opts.method, opts.pathAndQuery, opts.rawBody ?? ""));
947
+ return { [PARTNER_HEADERS.key]: opts.keyId, [PARTNER_HEADERS.ts]: ts, [PARTNER_HEADERS.sig]: sig };
948
+ }
949
+ function newIdempotencyKey() {
950
+ return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : hex2(crypto.getRandomValues(new Uint8Array(16)).buffer);
951
+ }
952
+ function parseRetryAfter(value) {
953
+ if (!value) return null;
954
+ const secs = Number(value);
955
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
956
+ const at = Date.parse(value);
957
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
958
+ }
959
+ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE"]);
960
+ var CLOCK_SKEW_HINT_MS = 6e4;
961
+ function resolveTransport(config) {
962
+ return {
963
+ baseUrl: (config.baseUrl ?? "https://www.mailmask.studio").replace(/\/+$/, ""),
964
+ maxRetries: Math.min(10, Math.max(0, Math.floor(config.maxRetries ?? 3))),
965
+ retryBaseMs: Math.max(0, config.retryBaseMs ?? 500),
966
+ retryMaxDelayMs: Math.max(0, config.retryMaxDelayMs ?? 3e4),
967
+ timeoutMs: Math.max(1, config.timeoutMs ?? 3e4),
968
+ onRetry: config.onRetry,
969
+ doFetch: config.fetch ?? ((...a) => fetch(...a))
970
+ };
971
+ }
972
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
973
+ function backoffMs(t, attempt) {
974
+ const exp = Math.min(t.retryMaxDelayMs, t.retryBaseMs * 2 ** attempt);
975
+ return Math.round(exp / 2 + Math.random() * (exp / 2));
976
+ }
977
+ function requestIdOf(res) {
978
+ return res.headers.get("x-request-id") ?? res.headers.get("fly-request-id") ?? void 0;
979
+ }
980
+ function clockSkewOf(res) {
981
+ const date = res.headers.get("date");
982
+ if (!date) return null;
983
+ const at = Date.parse(date);
984
+ return Number.isFinite(at) ? at - Date.now() : null;
985
+ }
986
+ function safeJson(text) {
987
+ try {
988
+ return text ? JSON.parse(text) : void 0;
989
+ } catch {
990
+ return void 0;
991
+ }
992
+ }
993
+ async function attemptOnce(t, url, init) {
994
+ const ctrl = new AbortController();
995
+ const timer = setTimeout(() => ctrl.abort(), t.timeoutMs);
996
+ try {
997
+ const res = await t.doFetch(url, { ...init, signal: ctrl.signal });
998
+ return { res, text: await res.text() };
999
+ } catch (err) {
1000
+ if (ctrl.signal.aborted) throw new TimeoutError(`MailMask no respondi\xF3 en ${t.timeoutMs} ms`, err);
1001
+ const reason = err instanceof Error ? err.message : String(err);
1002
+ throw new NetworkError(`No se pudo conectar con MailMask: ${reason}`, err);
1003
+ } finally {
1004
+ clearTimeout(timer);
1005
+ }
1006
+ }
1007
+ async function transportRequest(t, opts) {
1008
+ for (let attempt = 0; ; attempt++) {
1009
+ const headers = await opts.headersFor();
1010
+ if (opts.rawBody) headers["Content-Type"] = "application/json";
1011
+ let error;
1012
+ try {
1013
+ const { res, text } = await attemptOnce(t, `${t.baseUrl}${opts.path}`, { method: opts.method, headers, body: opts.rawBody || void 0 });
1014
+ if (res.ok) return { data: safeJson(text), status: res.status, headers: res.headers };
1015
+ const raw = safeJson(text);
1016
+ const body = raw && typeof raw === "object" ? raw : {};
1017
+ error = errorFor(res.status, body, res.statusText || `HTTP ${res.status}`, {
1018
+ requestId: requestIdOf(res),
1019
+ retryAfterMs: parseRetryAfter(res.headers.get("Retry-After"))
1020
+ });
1021
+ if (error instanceof AuthError) {
1022
+ error.clockSkewMs = clockSkewOf(res);
1023
+ if (error.clockSkewMs !== null && Math.abs(error.clockSkewMs) > CLOCK_SKEW_HINT_MS) {
1024
+ const secs = Math.round(Math.abs(error.clockSkewMs) / 1e3);
1025
+ error.message += ` (tu reloj va ${secs} s ${error.clockSkewMs > 0 ? "atrasado" : "adelantado"} respecto al de MailMask y la firma vence a los ${PARTNER_SIGNATURE_TOLERANCE_S} s: sincroniza la hora con NTP)`;
1026
+ }
1027
+ }
1028
+ } catch (err) {
1029
+ if (!(err instanceof PartnerApiError)) throw err;
1030
+ error = err;
1031
+ }
1032
+ if (!error.retryable || !opts.canRetry || attempt >= t.maxRetries) throw error;
1033
+ if (error.retryAfterMs !== null && error.retryAfterMs > t.retryMaxDelayMs) throw error;
1034
+ const delayMs = error.retryAfterMs ?? backoffMs(t, attempt);
1035
+ t.onRetry?.({ attempt: attempt + 1, delayMs, method: opts.method, path: opts.path, error });
1036
+ await sleep(delayMs);
1037
+ }
1038
+ }
1039
+ var SECRETS = /* @__PURE__ */ new WeakMap();
1040
+ var MailMaskPartner = class {
1041
+ constructor(config) {
1042
+ if (!config?.keyId || !config?.secret) throw new Error("MailMaskPartner: faltan keyId y secret");
1043
+ this.keyId = config.keyId;
1044
+ SECRETS.set(this, config.secret);
1045
+ this.transport = resolveTransport(config);
1046
+ this.test = config.test === true;
1047
+ this.tenants = new PartnerTenantsResource(this);
1048
+ this.domains = new PartnerDomainsResource(this);
1049
+ this.webhook = new PartnerWebhookResource(this);
1050
+ }
1051
+ /** Base URL efectiva (sin barra final). */
1052
+ get baseUrl() {
1053
+ return this.transport.baseUrl;
1054
+ }
1055
+ /**
1056
+ * Petición firmada. Cada intento se firma de nuevo (ts fresco) con el MISMO cuerpo, así que la
1057
+ * `idempotencyKey` del cuerpo viaja idéntica en los reintentos.
1058
+ * @internal
1059
+ */
1060
+ async request(method, path, body, extraHeaders = {}, opts = {}) {
1061
+ const pathAndQuery = `${PARTNER_API_PREFIX}${path}`;
1062
+ const rawBody = body === void 0 ? "" : JSON.stringify(body);
1063
+ const secret = SECRETS.get(this);
1064
+ const test = opts.test ?? this.test;
1065
+ const res = await transportRequest(this.transport, {
1066
+ method,
1067
+ path: pathAndQuery,
1068
+ rawBody,
1069
+ canRetry: opts.idempotent === true || IDEMPOTENT_METHODS.has(method) || PARTNER_HEADERS.idempotencyKey in extraHeaders,
1070
+ headersFor: async () => ({
1071
+ ...test ? { [PARTNER_HEADERS.test]: "1" } : {},
1072
+ ...extraHeaders,
1073
+ ...await signPartnerRequest({ keyId: this.keyId, secret, method, pathAndQuery, rawBody })
1074
+ })
1075
+ });
1076
+ return { data: res.data, meta: { status: res.status, idempotentReplayed: res.headers.get("Idempotent-Replayed") === "true" } };
1077
+ }
1078
+ /**
1079
+ * Manda un correo a nombre de un tenant. Pone `idempotencyKey` si no viene (la misma en cada
1080
+ * reintento). `stream` default `transactional` (sin baja de un clic); usa `broadcast` para
1081
+ * boletines. `vars` sustituye los `{{nombre}}`. Un destinatario suprimido lanza `RecipientSuppressedError`.
1082
+ */
1083
+ async send(externalId, input, opts = {}) {
1084
+ const idempotencyKey = input.idempotencyKey ?? newIdempotencyKey();
1085
+ const { data, meta } = await this.request(
1086
+ "POST",
1087
+ routePath(PARTNER_ROUTES.send.path, { externalId }),
1088
+ { ...input, idempotencyKey },
1089
+ { [PARTNER_HEADERS.idempotencyKey]: idempotencyKey },
1090
+ { test: opts.test }
1091
+ );
1092
+ return { ...data, idempotencyKey, replayed: meta.idempotentReplayed };
1093
+ }
1094
+ /**
1095
+ * Encola un lote (hasta 10,000 destinatarios: correos pelados o `{ to, vars }`). Pone
1096
+ * `idempotencyKey` si no viene. `stream` default `broadcast` (cada correo lleva baja de un clic);
1097
+ * los suprimidos se saltan.
1098
+ */
1099
+ async batch(externalId, input, opts = {}) {
1100
+ const idempotencyKey = input.idempotencyKey ?? newIdempotencyKey();
1101
+ const { data, meta } = await this.request(
1102
+ "POST",
1103
+ routePath(PARTNER_ROUTES.batch.path, { externalId }),
1104
+ { ...input, idempotencyKey },
1105
+ { [PARTNER_HEADERS.idempotencyKey]: idempotencyKey },
1106
+ { test: opts.test }
1107
+ );
1108
+ return { ...data, idempotencyKey, replayed: meta.idempotentReplayed };
1109
+ }
1110
+ /** Estado de un lote (`batchId` del resultado de `batch`): envíos y, después, entregas, rebotes, quejas y bajas. */
1111
+ async batchStatus(externalId, batchId) {
1112
+ return (await this.request("GET", routePath(PARTNER_ROUTES.getBatch.path, { externalId, batchId }))).data;
1113
+ }
1114
+ /** Uso del mes (`YYYY-MM`, default el mes en curso UTC): tenants activos y envíos. */
1115
+ async usage(opts = {}) {
1116
+ const q = opts.month ? `?month=${encodeURIComponent(opts.month)}` : "";
1117
+ return (await this.request("GET", `${PARTNER_ROUTES.usage.path}${q}`)).data;
1118
+ }
1119
+ };
1120
+ function routePath(path, params) {
1121
+ return path.replace(/:([A-Za-z]+)/g, (_, k) => {
1122
+ const v = params[k];
1123
+ if (v === void 0 || v === "") throw new Error(`MailMask: falta el par\xE1metro ${k}`);
1124
+ return encodeURIComponent(v);
1125
+ });
1126
+ }
1127
+ var PartnerTenantsResource = class {
1128
+ constructor(c) {
1129
+ this.c = c;
1130
+ }
1131
+ /** Crea o actualiza el tenant (idempotente por `externalId`). */
1132
+ async put(externalId, input = {}) {
1133
+ return (await this.c.request("PUT", routePath(PARTNER_ROUTES.putTenant.path, { externalId }), input)).data;
1134
+ }
1135
+ async get(externalId) {
1136
+ return (await this.c.request("GET", routePath(PARTNER_ROUTES.getTenant.path, { externalId }))).data;
1137
+ }
1138
+ /** Una página de tenants; sigue `nextCursor` para la siguiente. */
1139
+ async list(query = {}) {
1140
+ const qs = new URLSearchParams();
1141
+ if (query.status) qs.set("status", query.status);
1142
+ if (query.limit !== void 0) qs.set("limit", String(query.limit));
1143
+ if (query.cursor) qs.set("cursor", query.cursor);
1144
+ const q = qs.toString();
1145
+ return (await this.c.request("GET", `${PARTNER_ROUTES.listTenants.path}${q ? `?${q}` : ""}`)).data;
1146
+ }
1147
+ /**
1148
+ * Borra el tenant y suelta sus dominios (los que nadie más usa se borran con sus recursos en SES).
1149
+ * Sin vuelta atrás. `ConflictError` si está en pausa por reputación.
1150
+ */
1151
+ async delete(externalId) {
1152
+ return (await this.c.request("DELETE", routePath(PARTNER_ROUTES.deleteTenant.path, { externalId }))).data;
1153
+ }
1154
+ async pause(externalId, reason) {
1155
+ return (await this.c.request("POST", routePath(PARTNER_ROUTES.pauseTenant.path, { externalId }), reason === void 0 ? {} : { reason }, {}, { idempotent: true })).data;
1156
+ }
1157
+ /**
1158
+ * Levanta una pausa que puso el partner (`pausedBy: "partner"`). Sobre una pausa por reputación
1159
+ * (`mailmask` o `ses`) lanza `ReputationHoldError`.
1160
+ */
1161
+ async resume(externalId) {
1162
+ return (await this.c.request("POST", routePath(PARTNER_ROUTES.resumeTenant.path, { externalId }), void 0, {}, { idempotent: true })).data;
1163
+ }
1164
+ };
1165
+ var PartnerDomainsResource = class {
1166
+ constructor(c) {
1167
+ this.c = c;
1168
+ }
1169
+ /** Da de alta el dominio en el tenant y devuelve los registros DNS a pegar. */
1170
+ async add(externalId, domain) {
1171
+ return (await this.c.request("POST", routePath(PARTNER_ROUTES.addDomain.path, { externalId }), { domain }, {}, { idempotent: true })).data;
1172
+ }
1173
+ /** Estado en vivo (SES y DNS público) de cada registro. */
1174
+ async status(externalId, domain) {
1175
+ return (await this.c.request("GET", routePath(PARTNER_ROUTES.getDomain.path, { externalId, domain }))).data;
1176
+ }
1177
+ };
1178
+ var PartnerWebhookResource = class {
1179
+ constructor(c) {
1180
+ this.c = c;
1181
+ }
1182
+ async get() {
1183
+ return (await this.c.request("GET", PARTNER_ROUTES.getWebhook.path)).data;
1184
+ }
1185
+ /**
1186
+ * Configura la URL. `secret` (`whsec_…`) sólo viene la primera vez; después es null porque cambiar
1187
+ * la URL no rota el secreto (para eso, `rotateSecret`).
1188
+ */
1189
+ async set(input) {
1190
+ return (await this.c.request("PUT", PARTNER_ROUTES.setWebhook.path, input)).data;
1191
+ }
1192
+ /**
1193
+ * Secreto nuevo (se ve UNA vez). Durante la gracia (default 24 h, 0–10080 min) cada entrega lleva
1194
+ * las dos firmas: despliega el nuevo y pasa los dos a `verifyWebhook` mientras tanto. No se
1195
+ * reintenta solo: un segundo intento rotaría otra vez y el secreto de la respuesta perdida no
1196
+ * lo vería nadie.
1197
+ */
1198
+ async rotateSecret(input = {}) {
1199
+ return (await this.c.request("POST", PARTNER_ROUTES.rotateWebhookSecret.path, input)).data;
1200
+ }
1201
+ /** Encola un `ping` al webhook. No se reintenta solo (un reintento encolaría otro ping). */
1202
+ async test() {
1203
+ return (await this.c.request("POST", PARTNER_ROUTES.testWebhook.path)).data;
1204
+ }
1205
+ };
1206
+ function headerValue(headers, name) {
1207
+ if (typeof headers.get === "function") return headers.get(name) ?? void 0;
1208
+ const lower = name.toLowerCase();
1209
+ for (const [k, v] of Object.entries(headers)) {
1210
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;
1211
+ }
1212
+ return void 0;
1213
+ }
1214
+ async function verifyWebhook(opts) {
1215
+ const signature = headerValue(opts.headers, WEBHOOK_HEADERS.signature);
1216
+ const timestamp = headerValue(opts.headers, WEBHOOK_HEADERS.timestamp);
1217
+ const secrets = (typeof opts.secret === "string" ? [opts.secret] : [...opts.secret ?? []]).filter(Boolean);
1218
+ if (!signature || !timestamp || secrets.length === 0) return null;
1219
+ const ts = Number(timestamp);
1220
+ const tolerance = opts.toleranceMs ?? WEBHOOK_TOLERANCE_MS;
1221
+ if (!Number.isFinite(ts) || Math.abs((opts.now ?? Date.now()) - ts) > tolerance) return null;
1222
+ const provided = signature.split(",").map((x) => x.trim()).filter(Boolean);
1223
+ let ok = false;
1224
+ for (const secret of secrets) {
1225
+ const expected = `sha256=${await hmacHex(secret, webhookSignatureBase(timestamp, opts.rawBody))}`;
1226
+ for (const p of provided) ok = constantTimeEqual2(expected, p) || ok;
1227
+ }
1228
+ if (!ok) return null;
1229
+ try {
1230
+ return JSON.parse(opts.rawBody);
1231
+ } catch {
1232
+ return null;
1233
+ }
1234
+ }
1235
+ function createWebhookHandler(opts) {
1236
+ return async (request2) => {
1237
+ const rawBody = await request2.text();
1238
+ const event = await verifyWebhook({ rawBody, headers: request2.headers, secret: opts.secret, toleranceMs: opts.toleranceMs });
1239
+ if (!event) return new Response(JSON.stringify({ error: "firma inv\xE1lida" }), { status: 401, headers: { "content-type": "application/json" } });
1240
+ try {
1241
+ await opts.onEvent(event);
1242
+ } catch {
1243
+ return new Response(JSON.stringify({ error: "onEvent fall\xF3" }), { status: 500, headers: { "content-type": "application/json" } });
1244
+ }
1245
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
1246
+ };
1247
+ }
1248
+ var PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
1249
+ function templateVarNames(template) {
1250
+ return [...new Set([...template.matchAll(PLACEHOLDER_RE)].map((m) => m[1]))];
1251
+ }
1252
+ function templateVarsProblem(vars) {
1253
+ if (vars === void 0 || vars === null) return null;
1254
+ if (typeof vars !== "object" || Array.isArray(vars)) return "vars debe ser un objeto { nombre: valor }";
1255
+ const entries = Object.entries(vars);
1256
+ if (entries.length > MAX_TEMPLATE_VARS) return `M\xE1ximo ${MAX_TEMPLATE_VARS} variables por destinatario`;
1257
+ for (const [k, v] of entries) {
1258
+ if (!TEMPLATE_VAR_NAME_RE.test(k) || k.length > MAX_TEMPLATE_VAR_NAME) return `Nombre de variable inv\xE1lido: \xAB${k.slice(0, 64)}\xBB`;
1259
+ if (k === UNSUBSCRIBE_URL_VAR) return `${UNSUBSCRIBE_URL_VAR} es reservada: la pone MailMask en los broadcast`;
1260
+ if (typeof v !== "string") return `El valor de ${k} debe ser texto`;
1261
+ if (v.length > MAX_TEMPLATE_VAR_VALUE) return `El valor de ${k} excede ${MAX_TEMPLATE_VAR_VALUE} caracteres`;
1262
+ }
1263
+ return null;
1264
+ }
1265
+ function previewMessage(input, opts = {}) {
1266
+ const stream = input.stream ?? "transactional";
1267
+ const unsubscribeUrl = stream === "broadcast" ? opts.unsubscribeUrl ?? "https://www.mailmask.studio/u/vista-previa" : null;
1268
+ const out = personalizePartnerMessage({ subject: input.subject, html: input.html, text: input.text, vars: input.vars, unsubscribeUrl });
1269
+ const used = new Set([input.subject, input.html ?? "", input.text ?? ""].flatMap(templateVarNames));
1270
+ const vars = input.vars ?? {};
1271
+ const footer = unsubscribeUrl !== null && [input.html, input.text].some((t) => t !== void 0 && !templateUsesVar(t, UNSUBSCRIBE_URL_VAR));
1272
+ return {
1273
+ ...out,
1274
+ stream,
1275
+ unsubscribeFooter: footer,
1276
+ missingVars: [...used].filter((n) => n !== UNSUBSCRIBE_URL_VAR && !Object.prototype.hasOwnProperty.call(vars, n)),
1277
+ unusedVars: Object.keys(vars).filter((n) => !used.has(n))
1278
+ };
1279
+ }
1280
+ function adminRoutePath(route, params = {}) {
1281
+ return routePath(PARTNER_ADMIN_ROUTES[route].path, params);
1282
+ }
1283
+ var MailMaskPartnerAdmin = class {
1284
+ constructor(config) {
1285
+ if (!config?.apiKey) throw new Error("MailMaskPartnerAdmin: falta apiKey");
1286
+ SECRETS.set(this, config.apiKey);
1287
+ this.transport = resolveTransport(config);
1288
+ }
1289
+ async req(route, params = {}, body) {
1290
+ const { method } = PARTNER_ADMIN_ROUTES[route];
1291
+ const apiKey = SECRETS.get(this);
1292
+ const res = await transportRequest(this.transport, {
1293
+ method,
1294
+ path: adminRoutePath(route, params),
1295
+ rawBody: body === void 0 ? "" : JSON.stringify(body),
1296
+ // Emitir o rotar dos veces deja secretos que nadie vio: sólo se reintentan lecturas y borrados.
1297
+ canRetry: method === "GET" || method === "DELETE" || method === "PUT",
1298
+ headersFor: async () => ({ Authorization: `Bearer ${apiKey}` })
1299
+ });
1300
+ return res.data;
1301
+ }
1302
+ /** Los partners de la cuenta. */
1303
+ listPartners() {
1304
+ return this.req("listPartners");
1305
+ }
1306
+ /** Sólo MailMask (admin). */
1307
+ createPartner(input) {
1308
+ return this.req("createPartner", {}, input);
1309
+ }
1310
+ /** Sólo MailMask (admin); null = automática (Strict el primer mes, Standard después). */
1311
+ setReputationPolicy(partnerId, policy) {
1312
+ return this.req("setReputationPolicy", { partnerId }, { policy });
1313
+ }
1314
+ /** Sólo MailMask (admin): levanta cualquier pausa, también la de reputación. */
1315
+ resumeTenant(partnerId, externalId) {
1316
+ return this.req("resumeTenant", { partnerId, externalId });
1317
+ }
1318
+ /**
1319
+ * Sólo MailMask (admin): crea o adopta la hosted zone de la zona de respaldo (`marketing.denik.me`)
1320
+ * y devuelve los NS que el partner tiene que delegar. Idempotente con la misma zona.
1321
+ */
1322
+ setFallbackZone(partnerId, zone) {
1323
+ return this.req("setFallbackZone", { partnerId }, { zone });
1324
+ }
1325
+ /** La zona de respaldo y si el DNS público ya ve la delegación. */
1326
+ getFallbackZone(partnerId) {
1327
+ return this.req("getFallbackZone", { partnerId });
1328
+ }
1329
+ listCredentials(partnerId) {
1330
+ return this.req("listCredentials", { partnerId });
1331
+ }
1332
+ /** El secreto `mms_…` viene UNA vez en la respuesta. */
1333
+ createCredential(partnerId, input = {}) {
1334
+ return this.req("createCredential", { partnerId }, input);
1335
+ }
1336
+ /** Secreto nuevo (una vez); el anterior sigue firmando `graceMinutes` (default 60). */
1337
+ rotateCredential(partnerId, keyId, input = {}) {
1338
+ return this.req("rotateCredential", { partnerId, keyId }, input);
1339
+ }
1340
+ /** Inmediato y sin vuelta. */
1341
+ revokeCredential(partnerId, keyId) {
1342
+ return this.req("revokeCredential", { partnerId, keyId });
1343
+ }
1344
+ };
593
1345
  export {
1346
+ AuthError,
1347
+ ConflictError,
1348
+ DOMAIN_CLAIM_TTL_DAYS,
1349
+ EXTERNAL_ID_RE,
1350
+ FALLBACK_LABEL_RE,
1351
+ ForbiddenError,
1352
+ MAX_BATCH_RECIPIENTS,
1353
+ MAX_TAGS,
1354
+ MAX_TEMPLATE_VARS,
1355
+ MAX_TEMPLATE_VAR_NAME,
1356
+ MAX_TEMPLATE_VAR_VALUE,
1357
+ MESSAGE_STREAMS,
594
1358
  MailMask,
595
1359
  MailMaskError,
596
- verifyWebhookSignature
1360
+ MailMaskPartner,
1361
+ MailMaskPartnerAdmin,
1362
+ NetworkError,
1363
+ NotFoundError,
1364
+ PARTNER_ADMIN_ROUTES,
1365
+ PARTNER_API_PREFIX,
1366
+ PARTNER_ERROR_STATUS,
1367
+ PARTNER_HEADERS,
1368
+ PARTNER_ROUTES,
1369
+ PARTNER_SIGNATURE_TOLERANCE_S,
1370
+ PARTNER_WEBHOOK_EVENTS,
1371
+ PartnerApiError,
1372
+ PausedError,
1373
+ QuotaError,
1374
+ RESERVED_FALLBACK_LABELS,
1375
+ RateLimitError,
1376
+ RecipientSuppressedError,
1377
+ ReputationHoldError,
1378
+ STRICT_FIRST_DAYS,
1379
+ TAG_RE,
1380
+ TEMPLATE_VAR_NAME_RE,
1381
+ TimeoutError,
1382
+ UNSUBSCRIBE_URL_VAR,
1383
+ ValidationError,
1384
+ WEBHOOK_HEADERS,
1385
+ WEBHOOK_TOLERANCE_MS,
1386
+ createWebhookHandler,
1387
+ isValidExternalId,
1388
+ isValidFallbackLabel,
1389
+ partnerSignatureBase,
1390
+ personalizePartnerMessage,
1391
+ previewMessage,
1392
+ renderTemplate,
1393
+ signPartnerRequest,
1394
+ templateUsesVar,
1395
+ templateVarNames,
1396
+ templateVarsProblem,
1397
+ verifyWebhook,
1398
+ verifyWebhookSignature,
1399
+ webhookSignatureBase
597
1400
  };