mymx 0.3.5 → 0.3.7

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.
@@ -1,4 +1,4 @@
1
- import { EmailAddress, EmailReceivedEvent, ParsedDataComplete, ParsedDataFailed, ParsedError, RawContentDownloadOnly, RawContentInline, WEBHOOK_VERSION$1 as WEBHOOK_VERSION, WebhookAttachment } from "./types-DJTmrgCz.js";
1
+ import { EmailAddress, EmailReceivedEvent, ParsedDataComplete, ParsedDataFailed, ParsedError, RawContentDownloadOnly, RawContentInline, WEBHOOK_VERSION$1 as WEBHOOK_VERSION, WebhookAttachment } from "./types-C8JlcpcT.js";
2
2
  import { SignResult, signWebhookPayload$1 as signWebhookPayload } from "./signing-Ecohrukk.js";
3
3
 
4
4
  //#region src/contract.d.ts
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EmailAddress, EmailReceivedEvent, KnownWebhookEvent, ParsedData, ParsedDataComplete, ParsedDataFailed, ParsedError, ParsedStatus, RawContent, RawContentDownloadOnly, RawContentInline, UnknownEvent, WEBHOOK_VERSION$1 as WEBHOOK_VERSION, WebhookAttachment, WebhookEvent } from "./types-DJTmrgCz.js";
1
+ import { AuthConfidence, AuthVerdict, DkimSignature, DkimSignatureResult, DmarcPolicy, DmarcResult, EmailAddress, EmailAuth, EmailReceivedEvent, KnownWebhookEvent, ParsedData, ParsedDataComplete, ParsedDataFailed, ParsedError, ParsedStatus, RawContent, RawContentDownloadOnly, RawContentInline, SpfResult, UnknownEvent, ValidateEmailAuthResult, WEBHOOK_VERSION$1 as WEBHOOK_VERSION, WebhookAttachment, WebhookEvent } from "./types-C8JlcpcT.js";
2
2
  import { MYMX_CONFIRMED_HEADER$1 as MYMX_CONFIRMED_HEADER, MYMX_SIGNATURE_HEADER$1 as MYMX_SIGNATURE_HEADER, VerifyOptions, verifyWebhookSignature$1 as verifyWebhookSignature } from "./signing-Ecohrukk.js";
3
3
  import { MyMXWebhookError$1 as MyMXWebhookError, PAYLOAD_ERRORS$1 as PAYLOAD_ERRORS, RAW_EMAIL_ERRORS$1 as RAW_EMAIL_ERRORS, RawEmailDecodeError$1 as RawEmailDecodeError, RawEmailDecodeErrorCode, VERIFICATION_ERRORS$1 as VERIFICATION_ERRORS, WebhookErrorCode, WebhookPayloadError$1 as WebhookPayloadError, WebhookPayloadErrorCode, WebhookValidationError$1 as WebhookValidationError, WebhookValidationErrorCode, WebhookVerificationError$1 as WebhookVerificationError, WebhookVerificationErrorCode } from "./errors-CSPHzZB_.js";
4
4
 
@@ -171,6 +171,10 @@ declare const emailReceivedEventJsonSchema: {
171
171
  readonly additionalProperties: false;
172
172
  readonly description: "Email analysis and classification results. May be absent if analysis was not performed.";
173
173
  };
174
+ readonly auth: {
175
+ readonly $ref: "#/definitions/EmailAuth";
176
+ readonly description: "Email authentication results (SPF, DKIM, DMARC). May be absent if authentication was not performed.";
177
+ };
174
178
  };
175
179
  readonly required: ["id", "received_at", "smtp", "headers", "content", "parsed"];
176
180
  readonly additionalProperties: false;
@@ -483,10 +487,170 @@ declare const emailReceivedEventJsonSchema: {
483
487
  readonly additionalProperties: false;
484
488
  readonly description: "Error details when email parsing fails.";
485
489
  };
490
+ readonly EmailAuth: {
491
+ readonly type: "object";
492
+ readonly properties: {
493
+ readonly spf: {
494
+ readonly $ref: "#/definitions/SpfResult";
495
+ readonly description: "SPF verification result.\n\nSPF checks if the sending IP is authorized by the envelope sender's domain. \"pass\" means the IP is authorized; \"fail\" means it's explicitly not allowed.";
496
+ };
497
+ readonly dmarc: {
498
+ readonly $ref: "#/definitions/DmarcResult";
499
+ readonly description: "DMARC verification result.\n\nDMARC passes if either SPF or DKIM passes AND aligns with the From: domain. \"pass\" means the email is authenticated according to the sender's policy.";
500
+ };
501
+ readonly dmarcPolicy: {
502
+ readonly $ref: "#/definitions/DmarcPolicy";
503
+ readonly description: "DMARC policy from the sender's DNS record.\n\n- `reject`: Domain wants receivers to reject failing emails\n- `quarantine`: Domain wants failing emails marked as suspicious\n- `none`: Domain is monitoring only (no action requested)\n- `null`: No DMARC record found for this domain";
504
+ };
505
+ readonly dmarcFromDomain: {
506
+ readonly type: ["string", "null"];
507
+ readonly description: "The organizational domain used for DMARC lookups.\n\nFor example, if the From: address is `user@mail.example.com`, the DMARC lookup checks `_dmarc.mail.example.com`, then falls back to `_dmarc.example.com`. This field shows which domain's policy was used.";
508
+ };
509
+ readonly dmarcSpfAligned: {
510
+ readonly type: "boolean";
511
+ readonly description: "Whether SPF aligned with the From: domain for DMARC purposes.\n\nTrue if the envelope sender domain matches the From: domain (per alignment mode).";
512
+ };
513
+ readonly dmarcDkimAligned: {
514
+ readonly type: "boolean";
515
+ readonly description: "Whether DKIM aligned with the From: domain for DMARC purposes.\n\nTrue if at least one DKIM signature's domain matches the From: domain.";
516
+ };
517
+ readonly dmarcSpfStrict: {
518
+ readonly type: ["boolean", "null"];
519
+ readonly description: "Whether DMARC SPF alignment mode is strict.\n\n- `true`: Strict alignment required (exact domain match)\n- `false`: Relaxed alignment allowed (organizational domain match)\n- `null`: No DMARC record found";
520
+ };
521
+ readonly dmarcDkimStrict: {
522
+ readonly type: ["boolean", "null"];
523
+ readonly description: "Whether DMARC DKIM alignment mode is strict.\n\n- `true`: Strict alignment required (exact domain match)\n- `false`: Relaxed alignment allowed (organizational domain match)\n- `null`: No DMARC record found";
524
+ };
525
+ readonly dkimSignatures: {
526
+ readonly type: "array";
527
+ readonly items: {
528
+ readonly $ref: "#/definitions/DkimSignature";
529
+ };
530
+ readonly description: "All DKIM signatures found in the email with their verification results.\n\nMay be empty if no DKIM signatures were present.";
531
+ };
532
+ };
533
+ readonly required: ["spf", "dmarc", "dmarcPolicy", "dmarcFromDomain", "dmarcSpfAligned", "dmarcDkimAligned", "dmarcSpfStrict", "dmarcDkimStrict", "dkimSignatures"];
534
+ readonly additionalProperties: false;
535
+ readonly description: "Email authentication results for SPF, DKIM, and DMARC.\n\nUse `validateEmailAuth()` to compute a verdict based on these results.";
536
+ };
537
+ readonly SpfResult: {
538
+ readonly type: "string";
539
+ readonly enum: ["pass", "fail", "softfail", "neutral", "none", "temperror", "permerror"];
540
+ readonly description: "SPF verification result.";
541
+ };
542
+ readonly DmarcResult: {
543
+ readonly type: "string";
544
+ readonly enum: ["pass", "fail", "none", "temperror", "permerror"];
545
+ readonly description: "DMARC verification result.";
546
+ };
547
+ readonly DmarcPolicy: {
548
+ readonly type: ["string", "null"];
549
+ readonly enum: ["reject", "quarantine", "none", null];
550
+ readonly description: "DMARC policy action specified in the domain's DMARC record.\n\n- `reject`: The domain owner requests that receivers reject failing emails\n- `quarantine`: The domain owner requests that failing emails be treated as suspicious\n- `none`: The domain owner is only monitoring (no action requested)\n- `null`: No DMARC policy was found for the domain";
551
+ };
552
+ readonly DkimSignature: {
553
+ readonly type: "object";
554
+ readonly properties: {
555
+ readonly domain: {
556
+ readonly type: "string";
557
+ readonly description: "The domain that signed this DKIM signature (d= tag). This may differ from the From: domain (that's what alignment checks).";
558
+ };
559
+ readonly selector: {
560
+ readonly type: "string";
561
+ readonly description: "The DKIM selector used to locate the public key (s= tag). Combined with the domain to form the DNS lookup: `selector._domainkey.domain`";
562
+ };
563
+ readonly result: {
564
+ readonly $ref: "#/definitions/DkimSignatureResult";
565
+ readonly description: "Verification result for this specific signature.";
566
+ };
567
+ readonly aligned: {
568
+ readonly type: "boolean";
569
+ readonly description: "Whether this signature's domain aligns with the From: domain (for DMARC).\n\nAlignment can be \"strict\" (exact match) or \"relaxed\" (organizational domain match). For example, if From: is `user@sub.example.com` and DKIM is signed by `example.com`:\n- Relaxed alignment: true (same organizational domain)\n- Strict alignment: false (not exact match)";
570
+ };
571
+ readonly keyBits: {
572
+ readonly type: ["number", "null"];
573
+ readonly description: "Key size in bits (e.g., 1024, 2048). Null if the key size couldn't be determined.";
574
+ };
575
+ readonly algo: {
576
+ readonly type: "string";
577
+ readonly description: "Signing algorithm (e.g., \"rsa-sha256\", \"ed25519-sha256\").";
578
+ };
579
+ };
580
+ readonly required: ["domain", "selector", "result", "aligned", "keyBits", "algo"];
581
+ readonly additionalProperties: false;
582
+ readonly description: "Details about a single DKIM signature found in the email.\n\nAn email may have multiple DKIM signatures (e.g., one from the sending domain and one from the ESP). Each signature is verified independently.";
583
+ };
584
+ readonly DkimSignatureResult: {
585
+ readonly type: "string";
586
+ readonly enum: ["pass", "fail", "temperror", "permerror"];
587
+ readonly description: "DKIM signature verification result for a single signature.";
588
+ };
486
589
  };
487
590
  }; //#endregion
488
- //#region src/index.d.ts
591
+ //#region src/auth.d.ts
489
592
 
593
+ /**
594
+ * Validate email authentication and compute a verdict.
595
+ *
596
+ * This function analyzes SPF, DKIM, and DMARC results to determine
597
+ * whether an email is likely authentic ("legit"), potentially spoofed
598
+ * ("suspicious"), or indeterminate ("unknown").
599
+ *
600
+ * ## Verdict Logic
601
+ *
602
+ * **Legit (high confidence):**
603
+ * - DMARC pass with DKIM alignment (cryptographic proof of authenticity)
604
+ *
605
+ * **Legit (medium confidence):**
606
+ * - DMARC pass with SPF alignment only (no DKIM)
607
+ * - Note: SPF can break through forwarding, but DMARC pass is still meaningful
608
+ *
609
+ * **Suspicious (high confidence):**
610
+ * - DMARC fail when domain has `reject` or `quarantine` policy
611
+ * - The domain owner explicitly says to distrust failing emails
612
+ * - SPF explicitly fails (IP not authorized by sender)
613
+ *
614
+ * **Suspicious (low confidence):**
615
+ * - DMARC fail when domain has `none` policy (monitoring mode)
616
+ * - No DMARC record but SPF/DKIM fail
617
+ *
618
+ * **Unknown:**
619
+ * - No DMARC record and no clear pass/fail
620
+ * - Temporary errors during authentication
621
+ * - No authentication data available
622
+ *
623
+ * @param auth - Email authentication results from the webhook
624
+ * @returns Verdict, confidence level, and explanatory reasons
625
+ *
626
+ * @example
627
+ * ```typescript
628
+ * const result = validateEmailAuth({
629
+ * spf: 'pass',
630
+ * dmarc: 'pass',
631
+ * dmarcPolicy: 'reject',
632
+ * dmarcFromDomain: 'example.com',
633
+ * dmarcSpfAligned: true,
634
+ * dmarcDkimAligned: true,
635
+ * dmarcSpfStrict: false,
636
+ * dmarcDkimStrict: false,
637
+ * dkimSignatures: [{
638
+ * domain: 'example.com',
639
+ * selector: 'default',
640
+ * result: 'pass',
641
+ * aligned: true,
642
+ * keyBits: 2048,
643
+ * algo: 'rsa-sha256',
644
+ * }],
645
+ * });
646
+ *
647
+ * // result.verdict === 'legit'
648
+ * // result.confidence === 'high'
649
+ * // result.reasons === ['DMARC passed with DKIM alignment']
650
+ * ```
651
+ */
652
+ declare function validateEmailAuth(auth: EmailAuth): ValidateEmailAuthResult; //#endregion
653
+ //#region src/index.d.ts
490
654
  /**
491
655
  * Parse a webhook payload, returning typed events for known types
492
656
  * and UnknownEvent for future event types.
@@ -760,5 +924,7 @@ declare function decodeRawEmail(event: EmailReceivedEvent, options?: DecodeRawEm
760
924
  * }
761
925
  * ```
762
926
  */
763
- declare function verifyRawEmailDownload(downloaded: Buffer | ArrayBuffer | Uint8Array, event: EmailReceivedEvent): Buffer; //#endregion
764
- export { DecodeRawEmailOptions, EmailAddress, EmailReceivedEvent, HandleWebhookOptions, KnownWebhookEvent, MYMX_CONFIRMED_HEADER, MYMX_SIGNATURE_HEADER, MyMXWebhookError, PAYLOAD_ERRORS, ParsedData, ParsedDataComplete, ParsedDataFailed, ParsedError, ParsedStatus, RAW_EMAIL_ERRORS, RawContent, RawContentDownloadOnly, RawContentInline, RawEmailDecodeError, RawEmailDecodeErrorCode, UnknownEvent, VERIFICATION_ERRORS, VerifyOptions, WEBHOOK_VERSION, WebhookAttachment, WebhookErrorCode, WebhookEvent, WebhookHeaders, WebhookPayloadError, WebhookPayloadErrorCode, WebhookValidationError, WebhookValidationErrorCode, WebhookVerificationError, WebhookVerificationErrorCode, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, verifyRawEmailDownload, verifyWebhookSignature };
927
+ declare function verifyRawEmailDownload(downloaded: Buffer | ArrayBuffer | Uint8Array, event: EmailReceivedEvent): Buffer;
928
+
929
+ //#endregion
930
+ export { AuthConfidence, AuthVerdict, DecodeRawEmailOptions, DkimSignature, DkimSignatureResult, DmarcPolicy, DmarcResult, EmailAddress, EmailAuth, EmailReceivedEvent, HandleWebhookOptions, KnownWebhookEvent, MYMX_CONFIRMED_HEADER, MYMX_SIGNATURE_HEADER, MyMXWebhookError, PAYLOAD_ERRORS, ParsedData, ParsedDataComplete, ParsedDataFailed, ParsedError, ParsedStatus, RAW_EMAIL_ERRORS, RawContent, RawContentDownloadOnly, RawContentInline, RawEmailDecodeError, RawEmailDecodeErrorCode, SpfResult, UnknownEvent, VERIFICATION_ERRORS, ValidateEmailAuthResult, VerifyOptions, WEBHOOK_VERSION, WebhookAttachment, WebhookErrorCode, WebhookEvent, WebhookHeaders, WebhookPayloadError, WebhookPayloadErrorCode, WebhookValidationError, WebhookValidationErrorCode, WebhookVerificationError, WebhookVerificationErrorCode, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, validateEmailAuth, verifyRawEmailDownload, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { MyMXWebhookError, PAYLOAD_ERRORS, RAW_EMAIL_ERRORS, RawEmailDecodeError, VERIFICATION_ERRORS, WEBHOOK_VERSION, WebhookPayloadError, WebhookValidationError, WebhookVerificationError } from "./errors-2CwICC_t.js";
2
2
  import { MYMX_CONFIRMED_HEADER, MYMX_SIGNATURE_HEADER, bufferToString, verifyWebhookSignature } from "./signing-kfnmqAcK.js";
3
- import { validateEmailReceivedEvent } from "./zod-Ced9Kav9.js";
3
+ import { validateEmailReceivedEvent } from "./zod-CalKEwR4.js";
4
4
  import { createHash } from "node:crypto";
5
5
 
6
6
  //#region src/schema.generated.ts
@@ -167,6 +167,10 @@ const emailReceivedEventJsonSchema = {
167
167
  "required": ["spamassassin"],
168
168
  "additionalProperties": false,
169
169
  "description": "Email analysis and classification results. May be absent if analysis was not performed."
170
+ },
171
+ "auth": {
172
+ "$ref": "#/definitions/EmailAuth",
173
+ "description": "Email authentication results (SPF, DKIM, DMARC). May be absent if authentication was not performed."
170
174
  }
171
175
  },
172
176
  "required": [
@@ -508,10 +512,345 @@ const emailReceivedEventJsonSchema = {
508
512
  ],
509
513
  "additionalProperties": false,
510
514
  "description": "Error details when email parsing fails."
515
+ },
516
+ "EmailAuth": {
517
+ "type": "object",
518
+ "properties": {
519
+ "spf": {
520
+ "$ref": "#/definitions/SpfResult",
521
+ "description": "SPF verification result.\n\nSPF checks if the sending IP is authorized by the envelope sender's domain. \"pass\" means the IP is authorized; \"fail\" means it's explicitly not allowed."
522
+ },
523
+ "dmarc": {
524
+ "$ref": "#/definitions/DmarcResult",
525
+ "description": "DMARC verification result.\n\nDMARC passes if either SPF or DKIM passes AND aligns with the From: domain. \"pass\" means the email is authenticated according to the sender's policy."
526
+ },
527
+ "dmarcPolicy": {
528
+ "$ref": "#/definitions/DmarcPolicy",
529
+ "description": "DMARC policy from the sender's DNS record.\n\n- `reject`: Domain wants receivers to reject failing emails\n- `quarantine`: Domain wants failing emails marked as suspicious\n- `none`: Domain is monitoring only (no action requested)\n- `null`: No DMARC record found for this domain"
530
+ },
531
+ "dmarcFromDomain": {
532
+ "type": ["string", "null"],
533
+ "description": "The organizational domain used for DMARC lookups.\n\nFor example, if the From: address is `user@mail.example.com`, the DMARC lookup checks `_dmarc.mail.example.com`, then falls back to `_dmarc.example.com`. This field shows which domain's policy was used."
534
+ },
535
+ "dmarcSpfAligned": {
536
+ "type": "boolean",
537
+ "description": "Whether SPF aligned with the From: domain for DMARC purposes.\n\nTrue if the envelope sender domain matches the From: domain (per alignment mode)."
538
+ },
539
+ "dmarcDkimAligned": {
540
+ "type": "boolean",
541
+ "description": "Whether DKIM aligned with the From: domain for DMARC purposes.\n\nTrue if at least one DKIM signature's domain matches the From: domain."
542
+ },
543
+ "dmarcSpfStrict": {
544
+ "type": ["boolean", "null"],
545
+ "description": "Whether DMARC SPF alignment mode is strict.\n\n- `true`: Strict alignment required (exact domain match)\n- `false`: Relaxed alignment allowed (organizational domain match)\n- `null`: No DMARC record found"
546
+ },
547
+ "dmarcDkimStrict": {
548
+ "type": ["boolean", "null"],
549
+ "description": "Whether DMARC DKIM alignment mode is strict.\n\n- `true`: Strict alignment required (exact domain match)\n- `false`: Relaxed alignment allowed (organizational domain match)\n- `null`: No DMARC record found"
550
+ },
551
+ "dkimSignatures": {
552
+ "type": "array",
553
+ "items": { "$ref": "#/definitions/DkimSignature" },
554
+ "description": "All DKIM signatures found in the email with their verification results.\n\nMay be empty if no DKIM signatures were present."
555
+ }
556
+ },
557
+ "required": [
558
+ "spf",
559
+ "dmarc",
560
+ "dmarcPolicy",
561
+ "dmarcFromDomain",
562
+ "dmarcSpfAligned",
563
+ "dmarcDkimAligned",
564
+ "dmarcSpfStrict",
565
+ "dmarcDkimStrict",
566
+ "dkimSignatures"
567
+ ],
568
+ "additionalProperties": false,
569
+ "description": "Email authentication results for SPF, DKIM, and DMARC.\n\nUse `validateEmailAuth()` to compute a verdict based on these results."
570
+ },
571
+ "SpfResult": {
572
+ "type": "string",
573
+ "enum": [
574
+ "pass",
575
+ "fail",
576
+ "softfail",
577
+ "neutral",
578
+ "none",
579
+ "temperror",
580
+ "permerror"
581
+ ],
582
+ "description": "SPF verification result."
583
+ },
584
+ "DmarcResult": {
585
+ "type": "string",
586
+ "enum": [
587
+ "pass",
588
+ "fail",
589
+ "none",
590
+ "temperror",
591
+ "permerror"
592
+ ],
593
+ "description": "DMARC verification result."
594
+ },
595
+ "DmarcPolicy": {
596
+ "type": ["string", "null"],
597
+ "enum": [
598
+ "reject",
599
+ "quarantine",
600
+ "none",
601
+ null
602
+ ],
603
+ "description": "DMARC policy action specified in the domain's DMARC record.\n\n- `reject`: The domain owner requests that receivers reject failing emails\n- `quarantine`: The domain owner requests that failing emails be treated as suspicious\n- `none`: The domain owner is only monitoring (no action requested)\n- `null`: No DMARC policy was found for the domain"
604
+ },
605
+ "DkimSignature": {
606
+ "type": "object",
607
+ "properties": {
608
+ "domain": {
609
+ "type": "string",
610
+ "description": "The domain that signed this DKIM signature (d= tag). This may differ from the From: domain (that's what alignment checks)."
611
+ },
612
+ "selector": {
613
+ "type": "string",
614
+ "description": "The DKIM selector used to locate the public key (s= tag). Combined with the domain to form the DNS lookup: `selector._domainkey.domain`"
615
+ },
616
+ "result": {
617
+ "$ref": "#/definitions/DkimSignatureResult",
618
+ "description": "Verification result for this specific signature."
619
+ },
620
+ "aligned": {
621
+ "type": "boolean",
622
+ "description": "Whether this signature's domain aligns with the From: domain (for DMARC).\n\nAlignment can be \"strict\" (exact match) or \"relaxed\" (organizational domain match). For example, if From: is `user@sub.example.com` and DKIM is signed by `example.com`:\n- Relaxed alignment: true (same organizational domain)\n- Strict alignment: false (not exact match)"
623
+ },
624
+ "keyBits": {
625
+ "type": ["number", "null"],
626
+ "description": "Key size in bits (e.g., 1024, 2048). Null if the key size couldn't be determined."
627
+ },
628
+ "algo": {
629
+ "type": "string",
630
+ "description": "Signing algorithm (e.g., \"rsa-sha256\", \"ed25519-sha256\")."
631
+ }
632
+ },
633
+ "required": [
634
+ "domain",
635
+ "selector",
636
+ "result",
637
+ "aligned",
638
+ "keyBits",
639
+ "algo"
640
+ ],
641
+ "additionalProperties": false,
642
+ "description": "Details about a single DKIM signature found in the email.\n\nAn email may have multiple DKIM signatures (e.g., one from the sending domain and one from the ESP). Each signature is verified independently."
643
+ },
644
+ "DkimSignatureResult": {
645
+ "type": "string",
646
+ "enum": [
647
+ "pass",
648
+ "fail",
649
+ "temperror",
650
+ "permerror"
651
+ ],
652
+ "description": "DKIM signature verification result for a single signature."
511
653
  }
512
654
  }
513
655
  };
514
656
 
657
+ //#endregion
658
+ //#region src/auth.ts
659
+ /**
660
+ * Minimum DKIM key size considered acceptable.
661
+ *
662
+ * 1024-bit RSA keys are cryptographically weak by modern standards (NIST
663
+ * deprecated them in 2013), but they remain extremely common in email due to:
664
+ * - DNS TXT record size limits (255 bytes per string)
665
+ * - Legacy infrastructure constraints
666
+ * - Major ESPs like Amazon SES and Resend still use 1024-bit keys
667
+ *
668
+ * We flag keys <1024 bits as weak (these are truly dangerous), while accepting
669
+ * ≥1024 bits to avoid false positives against legitimate senders. For maximum
670
+ * security, domain owners should use 2048+ bit keys where possible.
671
+ */
672
+ const MIN_SECURE_KEY_BITS = 1024;
673
+ /**
674
+ * Validate email authentication and compute a verdict.
675
+ *
676
+ * This function analyzes SPF, DKIM, and DMARC results to determine
677
+ * whether an email is likely authentic ("legit"), potentially spoofed
678
+ * ("suspicious"), or indeterminate ("unknown").
679
+ *
680
+ * ## Verdict Logic
681
+ *
682
+ * **Legit (high confidence):**
683
+ * - DMARC pass with DKIM alignment (cryptographic proof of authenticity)
684
+ *
685
+ * **Legit (medium confidence):**
686
+ * - DMARC pass with SPF alignment only (no DKIM)
687
+ * - Note: SPF can break through forwarding, but DMARC pass is still meaningful
688
+ *
689
+ * **Suspicious (high confidence):**
690
+ * - DMARC fail when domain has `reject` or `quarantine` policy
691
+ * - The domain owner explicitly says to distrust failing emails
692
+ * - SPF explicitly fails (IP not authorized by sender)
693
+ *
694
+ * **Suspicious (low confidence):**
695
+ * - DMARC fail when domain has `none` policy (monitoring mode)
696
+ * - No DMARC record but SPF/DKIM fail
697
+ *
698
+ * **Unknown:**
699
+ * - No DMARC record and no clear pass/fail
700
+ * - Temporary errors during authentication
701
+ * - No authentication data available
702
+ *
703
+ * @param auth - Email authentication results from the webhook
704
+ * @returns Verdict, confidence level, and explanatory reasons
705
+ *
706
+ * @example
707
+ * ```typescript
708
+ * const result = validateEmailAuth({
709
+ * spf: 'pass',
710
+ * dmarc: 'pass',
711
+ * dmarcPolicy: 'reject',
712
+ * dmarcFromDomain: 'example.com',
713
+ * dmarcSpfAligned: true,
714
+ * dmarcDkimAligned: true,
715
+ * dmarcSpfStrict: false,
716
+ * dmarcDkimStrict: false,
717
+ * dkimSignatures: [{
718
+ * domain: 'example.com',
719
+ * selector: 'default',
720
+ * result: 'pass',
721
+ * aligned: true,
722
+ * keyBits: 2048,
723
+ * algo: 'rsa-sha256',
724
+ * }],
725
+ * });
726
+ *
727
+ * // result.verdict === 'legit'
728
+ * // result.confidence === 'high'
729
+ * // result.reasons === ['DMARC passed with DKIM alignment']
730
+ * ```
731
+ */
732
+ function validateEmailAuth(auth) {
733
+ const reasons = [];
734
+ let verdict;
735
+ let confidence;
736
+ if (auth.dmarc === "temperror" || auth.dmarc === "permerror") return {
737
+ verdict: "unknown",
738
+ confidence: "low",
739
+ reasons: [`DMARC verification error (${auth.dmarc})`, "Cannot determine email authenticity due to DNS or policy errors"]
740
+ };
741
+ if (auth.spf === "temperror" || auth.spf === "permerror") reasons.push(`SPF verification error (${auth.spf})`);
742
+ const weakKeySignatures = auth.dkimSignatures.filter((sig) => sig.keyBits !== null && sig.keyBits < MIN_SECURE_KEY_BITS);
743
+ if (weakKeySignatures.length > 0) for (const sig of weakKeySignatures) reasons.push(`Weak DKIM key (${sig.keyBits} bits) for ${sig.domain} - minimum ${MIN_SECURE_KEY_BITS} bits recommended`);
744
+ if (auth.dmarc === "pass") {
745
+ const alignedSigs = auth.dkimSignatures.filter((sig) => sig.result === "pass" && sig.aligned);
746
+ if (auth.dmarcDkimAligned && alignedSigs.length > 0) {
747
+ const domains = alignedSigs.map((sig) => sig.domain).join(", ");
748
+ reasons.unshift(`DMARC passed with DKIM alignment (${domains})`);
749
+ verdict = "legit";
750
+ confidence = weakKeySignatures.length > 0 ? "medium" : "high";
751
+ return {
752
+ verdict,
753
+ confidence,
754
+ reasons
755
+ };
756
+ }
757
+ if (auth.dmarcSpfAligned && auth.spf === "pass") {
758
+ reasons.unshift("DMARC passed with SPF alignment");
759
+ reasons.push("No aligned DKIM signature (SPF can break through forwarding)");
760
+ return {
761
+ verdict: "legit",
762
+ confidence: "medium",
763
+ reasons
764
+ };
765
+ }
766
+ reasons.unshift("DMARC passed");
767
+ return {
768
+ verdict: "legit",
769
+ confidence: "medium",
770
+ reasons
771
+ };
772
+ }
773
+ if (auth.dmarc === "fail") {
774
+ if (auth.dmarcPolicy === "reject") {
775
+ reasons.unshift("DMARC failed and domain has reject policy");
776
+ reasons.push("The sender's domain explicitly rejects emails that fail authentication");
777
+ return {
778
+ verdict: "suspicious",
779
+ confidence: "high",
780
+ reasons
781
+ };
782
+ }
783
+ if (auth.dmarcPolicy === "quarantine") {
784
+ reasons.unshift("DMARC failed and domain has quarantine policy");
785
+ reasons.push("The sender's domain marks failing emails as suspicious");
786
+ return {
787
+ verdict: "suspicious",
788
+ confidence: "high",
789
+ reasons
790
+ };
791
+ }
792
+ reasons.unshift("DMARC failed (domain is in monitoring mode)");
793
+ if (auth.spf === "fail") {
794
+ reasons.push("SPF failed - sending IP not authorized");
795
+ return {
796
+ verdict: "suspicious",
797
+ confidence: "medium",
798
+ reasons
799
+ };
800
+ }
801
+ return {
802
+ verdict: "suspicious",
803
+ confidence: "low",
804
+ reasons
805
+ };
806
+ }
807
+ if (auth.dmarc === "none") {
808
+ if (auth.spf === "fail") {
809
+ reasons.push("No DMARC record for sender domain");
810
+ reasons.push("SPF failed - sending IP not authorized");
811
+ return {
812
+ verdict: "suspicious",
813
+ confidence: "medium",
814
+ reasons
815
+ };
816
+ }
817
+ const passingDkim = auth.dkimSignatures.filter((sig) => sig.result === "pass");
818
+ if (passingDkim.length > 0) {
819
+ const domains = passingDkim.map((sig) => sig.domain).join(", ");
820
+ reasons.push("No DMARC record for sender domain");
821
+ reasons.push(`DKIM verified for: ${domains}`);
822
+ if (auth.spf === "pass") reasons.push("SPF passed");
823
+ return {
824
+ verdict: "unknown",
825
+ confidence: "low",
826
+ reasons
827
+ };
828
+ }
829
+ if (auth.spf === "pass") {
830
+ reasons.push("No DMARC record for sender domain");
831
+ reasons.push("No DKIM signatures present");
832
+ reasons.push("SPF passed (but SPF alone is weak authentication)");
833
+ return {
834
+ verdict: "unknown",
835
+ confidence: "low",
836
+ reasons
837
+ };
838
+ }
839
+ reasons.push("No DMARC record for sender domain");
840
+ reasons.push("No valid authentication found");
841
+ return {
842
+ verdict: "unknown",
843
+ confidence: "low",
844
+ reasons
845
+ };
846
+ }
847
+ return {
848
+ verdict: "unknown",
849
+ confidence: "low",
850
+ reasons: ["Unable to determine email authenticity"]
851
+ };
852
+ }
853
+
515
854
  //#endregion
516
855
  //#region src/parsing.ts
517
856
  /**
@@ -844,4 +1183,4 @@ function verifyRawEmailDownload(downloaded, event) {
844
1183
  }
845
1184
 
846
1185
  //#endregion
847
- export { MYMX_CONFIRMED_HEADER, MYMX_SIGNATURE_HEADER, MyMXWebhookError, PAYLOAD_ERRORS, RAW_EMAIL_ERRORS, RawEmailDecodeError, VERIFICATION_ERRORS, WEBHOOK_VERSION, WebhookPayloadError, WebhookValidationError, WebhookVerificationError, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, verifyRawEmailDownload, verifyWebhookSignature };
1186
+ export { MYMX_CONFIRMED_HEADER, MYMX_SIGNATURE_HEADER, MyMXWebhookError, PAYLOAD_ERRORS, RAW_EMAIL_ERRORS, RawEmailDecodeError, VERIFICATION_ERRORS, WEBHOOK_VERSION, WebhookPayloadError, WebhookValidationError, WebhookVerificationError, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, validateEmailAuth, verifyRawEmailDownload, verifyWebhookSignature };