@primitivedotdev/sdk 1.26.1 → 1.29.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/README.md +56 -0
- package/dist/api/index.d.ts +4 -4
- package/dist/api/index.js +4 -4
- package/dist/{api-BEv_YML6.js → api-27BvnkVA.js} +552 -4
- package/dist/contract/index.d.ts +14 -5
- package/dist/contract/index.js +6 -3
- package/dist/{errors-CCJX3I_T.d.ts → errors-Li0PXZXa.d.ts} +5 -1
- package/dist/{index-B1a5L2zU.d.ts → index-Bjc-cR2b.d.ts} +360 -6
- package/dist/{index-JpXUV16f.d.ts → index-BxPkB7GU.d.ts} +39 -46
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{operations.generated-DVHnicf5.js → operations.generated-Dra-xGtu.js} +663 -6
- package/dist/parser/address.js +1 -1
- package/dist/parser/index.d.ts +1 -1
- package/dist/parser/index.js +1 -1
- package/dist/payloads/index.js +1 -1
- package/dist/{webhook-CLVRgb-L.js → trust-m3dNPmWL.js} +835 -2190
- package/dist/{types-DcXtFARL.d.ts → types-Rn4qu5Q7.d.ts} +12 -5
- package/dist/webhook/index.d.ts +3 -3
- package/dist/webhook/index.js +2 -2
- package/dist/webhook-DArt1Ipq.js +2139 -0
- package/package.json +3 -3
- package/dist/errors-BVnAu5jX.js +0 -688
- /package/dist/{address-parser-C2JJzLdA.js → address-parser-BxXv4vx0.js} +0 -0
- /package/dist/{payloads-BfJAtqW9.js → payloads-BMngnJlE.js} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { n as parseFromHeaderLoose, t as parseFromHeader } from "./address-parser-BxXv4vx0.js";
|
|
2
|
+
import isEmail from "validator/lib/isEmail.js";
|
|
3
3
|
//#region src/generated/email-received-event.validator.generated.ts
|
|
4
4
|
/**
|
|
5
5
|
* AUTO-GENERATED - DO NOT EDIT
|
|
@@ -138,13 +138,16 @@ const schema12 = {
|
|
|
138
138
|
},
|
|
139
139
|
"content": {
|
|
140
140
|
"type": "object",
|
|
141
|
+
"if": { "properties": { "raw": { "type": "null" } } },
|
|
142
|
+
"then": { "properties": { "download": { "type": "null" } } },
|
|
143
|
+
"else": { "properties": { "download": { "type": "object" } } },
|
|
141
144
|
"properties": {
|
|
142
145
|
"raw": {
|
|
143
|
-
"$ref": "#/definitions/RawContent",
|
|
144
|
-
"description": "Raw
|
|
146
|
+
"anyOf": [{ "$ref": "#/definitions/RawContent" }, { "type": "null" }],
|
|
147
|
+
"description": "Raw MIME is null for address-scoped listeners. Use email.parsed for message content."
|
|
145
148
|
},
|
|
146
149
|
"download": {
|
|
147
|
-
"type": "object",
|
|
150
|
+
"type": ["object", "null"],
|
|
148
151
|
"properties": {
|
|
149
152
|
"url": {
|
|
150
153
|
"type": "string",
|
|
@@ -159,7 +162,7 @@ const schema12 = {
|
|
|
159
162
|
}
|
|
160
163
|
},
|
|
161
164
|
"required": ["url", "expires_at"],
|
|
162
|
-
"description": "
|
|
165
|
+
"description": "Raw download information. Null for address-scoped listeners, which do not receive bearer download links."
|
|
163
166
|
}
|
|
164
167
|
},
|
|
165
168
|
"required": ["raw", "download"],
|
|
@@ -7801,33 +7804,111 @@ function validate11(data, { instancePath = "", parentData, parentDataProperty, r
|
|
|
7801
7804
|
}
|
|
7802
7805
|
if (data14.content !== void 0) {
|
|
7803
7806
|
let data29 = data14.content;
|
|
7807
|
+
const _errs63 = errors;
|
|
7808
|
+
let valid10 = true;
|
|
7809
|
+
const _errs64 = errors;
|
|
7810
|
+
if (data29 && typeof data29 == "object" && !Array.isArray(data29)) {
|
|
7811
|
+
if (data29.raw !== void 0) {
|
|
7812
|
+
if (data29.raw !== null) {
|
|
7813
|
+
const err65 = {};
|
|
7814
|
+
if (vErrors === null) vErrors = [err65];
|
|
7815
|
+
else vErrors.push(err65);
|
|
7816
|
+
errors++;
|
|
7817
|
+
}
|
|
7818
|
+
}
|
|
7819
|
+
}
|
|
7820
|
+
var _valid0 = _errs64 === errors;
|
|
7821
|
+
errors = _errs63;
|
|
7822
|
+
if (vErrors !== null) if (_errs63) vErrors.length = _errs63;
|
|
7823
|
+
else vErrors = null;
|
|
7824
|
+
let ifClause0;
|
|
7825
|
+
if (_valid0) {
|
|
7826
|
+
const _errs67 = errors;
|
|
7827
|
+
if (data29 && typeof data29 == "object" && !Array.isArray(data29)) {
|
|
7828
|
+
if (data29.download !== void 0) {
|
|
7829
|
+
if (data29.download !== null) {
|
|
7830
|
+
const err66 = {
|
|
7831
|
+
instancePath: instancePath + "/email/content/download",
|
|
7832
|
+
schemaPath: "#/properties/email/properties/content/then/properties/download/type",
|
|
7833
|
+
keyword: "type",
|
|
7834
|
+
params: { type: "null" },
|
|
7835
|
+
message: "must be null"
|
|
7836
|
+
};
|
|
7837
|
+
if (vErrors === null) vErrors = [err66];
|
|
7838
|
+
else vErrors.push(err66);
|
|
7839
|
+
errors++;
|
|
7840
|
+
}
|
|
7841
|
+
}
|
|
7842
|
+
}
|
|
7843
|
+
var _valid0 = _errs67 === errors;
|
|
7844
|
+
valid10 = _valid0;
|
|
7845
|
+
ifClause0 = "then";
|
|
7846
|
+
} else {
|
|
7847
|
+
const _errs70 = errors;
|
|
7848
|
+
if (data29 && typeof data29 == "object" && !Array.isArray(data29)) {
|
|
7849
|
+
if (data29.download !== void 0) {
|
|
7850
|
+
let data32 = data29.download;
|
|
7851
|
+
if (!(data32 && typeof data32 == "object" && !Array.isArray(data32))) {
|
|
7852
|
+
const err67 = {
|
|
7853
|
+
instancePath: instancePath + "/email/content/download",
|
|
7854
|
+
schemaPath: "#/properties/email/properties/content/else/properties/download/type",
|
|
7855
|
+
keyword: "type",
|
|
7856
|
+
params: { type: "object" },
|
|
7857
|
+
message: "must be object"
|
|
7858
|
+
};
|
|
7859
|
+
if (vErrors === null) vErrors = [err67];
|
|
7860
|
+
else vErrors.push(err67);
|
|
7861
|
+
errors++;
|
|
7862
|
+
}
|
|
7863
|
+
}
|
|
7864
|
+
}
|
|
7865
|
+
var _valid0 = _errs70 === errors;
|
|
7866
|
+
valid10 = _valid0;
|
|
7867
|
+
ifClause0 = "else";
|
|
7868
|
+
}
|
|
7869
|
+
if (!valid10) {
|
|
7870
|
+
const err68 = {
|
|
7871
|
+
instancePath: instancePath + "/email/content",
|
|
7872
|
+
schemaPath: "#/properties/email/properties/content/if",
|
|
7873
|
+
keyword: "if",
|
|
7874
|
+
params: { failingKeyword: ifClause0 },
|
|
7875
|
+
message: "must match \"" + ifClause0 + "\" schema"
|
|
7876
|
+
};
|
|
7877
|
+
if (vErrors === null) vErrors = [err68];
|
|
7878
|
+
else vErrors.push(err68);
|
|
7879
|
+
errors++;
|
|
7880
|
+
}
|
|
7804
7881
|
if (data29 && typeof data29 == "object" && !Array.isArray(data29)) {
|
|
7805
7882
|
if (data29.raw === void 0) {
|
|
7806
|
-
const
|
|
7883
|
+
const err69 = {
|
|
7807
7884
|
instancePath: instancePath + "/email/content",
|
|
7808
7885
|
schemaPath: "#/properties/email/properties/content/required",
|
|
7809
7886
|
keyword: "required",
|
|
7810
7887
|
params: { missingProperty: "raw" },
|
|
7811
7888
|
message: "must have required property 'raw'"
|
|
7812
7889
|
};
|
|
7813
|
-
if (vErrors === null) vErrors = [
|
|
7814
|
-
else vErrors.push(
|
|
7890
|
+
if (vErrors === null) vErrors = [err69];
|
|
7891
|
+
else vErrors.push(err69);
|
|
7815
7892
|
errors++;
|
|
7816
7893
|
}
|
|
7817
7894
|
if (data29.download === void 0) {
|
|
7818
|
-
const
|
|
7895
|
+
const err70 = {
|
|
7819
7896
|
instancePath: instancePath + "/email/content",
|
|
7820
7897
|
schemaPath: "#/properties/email/properties/content/required",
|
|
7821
7898
|
keyword: "required",
|
|
7822
7899
|
params: { missingProperty: "download" },
|
|
7823
7900
|
message: "must have required property 'download'"
|
|
7824
7901
|
};
|
|
7825
|
-
if (vErrors === null) vErrors = [
|
|
7826
|
-
else vErrors.push(
|
|
7902
|
+
if (vErrors === null) vErrors = [err70];
|
|
7903
|
+
else vErrors.push(err70);
|
|
7827
7904
|
errors++;
|
|
7828
7905
|
}
|
|
7829
7906
|
if (data29.raw !== void 0) {
|
|
7830
|
-
|
|
7907
|
+
let data33 = data29.raw;
|
|
7908
|
+
const _errs74 = errors;
|
|
7909
|
+
let valid15 = false;
|
|
7910
|
+
const _errs75 = errors;
|
|
7911
|
+
if (!validate12(data33, {
|
|
7831
7912
|
instancePath: instancePath + "/email/content/raw",
|
|
7832
7913
|
parentData: data29,
|
|
7833
7914
|
parentDataProperty: "raw",
|
|
@@ -7836,125 +7917,161 @@ function validate11(data, { instancePath = "", parentData, parentDataProperty, r
|
|
|
7836
7917
|
vErrors = vErrors === null ? validate12.errors : vErrors.concat(validate12.errors);
|
|
7837
7918
|
errors = vErrors.length;
|
|
7838
7919
|
}
|
|
7920
|
+
var _valid1 = _errs75 === errors;
|
|
7921
|
+
valid15 = valid15 || _valid1;
|
|
7922
|
+
if (!valid15) {
|
|
7923
|
+
const _errs76 = errors;
|
|
7924
|
+
if (data33 !== null) {
|
|
7925
|
+
const err71 = {
|
|
7926
|
+
instancePath: instancePath + "/email/content/raw",
|
|
7927
|
+
schemaPath: "#/properties/email/properties/content/properties/raw/anyOf/1/type",
|
|
7928
|
+
keyword: "type",
|
|
7929
|
+
params: { type: "null" },
|
|
7930
|
+
message: "must be null"
|
|
7931
|
+
};
|
|
7932
|
+
if (vErrors === null) vErrors = [err71];
|
|
7933
|
+
else vErrors.push(err71);
|
|
7934
|
+
errors++;
|
|
7935
|
+
}
|
|
7936
|
+
var _valid1 = _errs76 === errors;
|
|
7937
|
+
valid15 = valid15 || _valid1;
|
|
7938
|
+
}
|
|
7939
|
+
if (!valid15) {
|
|
7940
|
+
const err72 = {
|
|
7941
|
+
instancePath: instancePath + "/email/content/raw",
|
|
7942
|
+
schemaPath: "#/properties/email/properties/content/properties/raw/anyOf",
|
|
7943
|
+
keyword: "anyOf",
|
|
7944
|
+
params: {},
|
|
7945
|
+
message: "must match a schema in anyOf"
|
|
7946
|
+
};
|
|
7947
|
+
if (vErrors === null) vErrors = [err72];
|
|
7948
|
+
else vErrors.push(err72);
|
|
7949
|
+
errors++;
|
|
7950
|
+
} else {
|
|
7951
|
+
errors = _errs74;
|
|
7952
|
+
if (vErrors !== null) if (_errs74) vErrors.length = _errs74;
|
|
7953
|
+
else vErrors = null;
|
|
7954
|
+
}
|
|
7839
7955
|
}
|
|
7840
7956
|
if (data29.download !== void 0) {
|
|
7841
|
-
let
|
|
7842
|
-
if (
|
|
7843
|
-
|
|
7844
|
-
|
|
7957
|
+
let data34 = data29.download;
|
|
7958
|
+
if (!(data34 && typeof data34 == "object" && !Array.isArray(data34)) && data34 !== null) {
|
|
7959
|
+
const err73 = {
|
|
7960
|
+
instancePath: instancePath + "/email/content/download",
|
|
7961
|
+
schemaPath: "#/properties/email/properties/content/properties/download/type",
|
|
7962
|
+
keyword: "type",
|
|
7963
|
+
params: { type: schema12.properties.email.properties.content.properties.download.type },
|
|
7964
|
+
message: "must be object,null"
|
|
7965
|
+
};
|
|
7966
|
+
if (vErrors === null) vErrors = [err73];
|
|
7967
|
+
else vErrors.push(err73);
|
|
7968
|
+
errors++;
|
|
7969
|
+
}
|
|
7970
|
+
if (data34 && typeof data34 == "object" && !Array.isArray(data34)) {
|
|
7971
|
+
if (data34.url === void 0) {
|
|
7972
|
+
const err74 = {
|
|
7845
7973
|
instancePath: instancePath + "/email/content/download",
|
|
7846
7974
|
schemaPath: "#/properties/email/properties/content/properties/download/required",
|
|
7847
7975
|
keyword: "required",
|
|
7848
7976
|
params: { missingProperty: "url" },
|
|
7849
7977
|
message: "must have required property 'url'"
|
|
7850
7978
|
};
|
|
7851
|
-
if (vErrors === null) vErrors = [
|
|
7852
|
-
else vErrors.push(
|
|
7979
|
+
if (vErrors === null) vErrors = [err74];
|
|
7980
|
+
else vErrors.push(err74);
|
|
7853
7981
|
errors++;
|
|
7854
7982
|
}
|
|
7855
|
-
if (
|
|
7856
|
-
const
|
|
7983
|
+
if (data34.expires_at === void 0) {
|
|
7984
|
+
const err75 = {
|
|
7857
7985
|
instancePath: instancePath + "/email/content/download",
|
|
7858
7986
|
schemaPath: "#/properties/email/properties/content/properties/download/required",
|
|
7859
7987
|
keyword: "required",
|
|
7860
7988
|
params: { missingProperty: "expires_at" },
|
|
7861
7989
|
message: "must have required property 'expires_at'"
|
|
7862
7990
|
};
|
|
7863
|
-
if (vErrors === null) vErrors = [
|
|
7864
|
-
else vErrors.push(
|
|
7991
|
+
if (vErrors === null) vErrors = [err75];
|
|
7992
|
+
else vErrors.push(err75);
|
|
7865
7993
|
errors++;
|
|
7866
7994
|
}
|
|
7867
|
-
if (
|
|
7868
|
-
let
|
|
7869
|
-
if (typeof
|
|
7870
|
-
if (!pattern4.test(
|
|
7871
|
-
const
|
|
7995
|
+
if (data34.url !== void 0) {
|
|
7996
|
+
let data35 = data34.url;
|
|
7997
|
+
if (typeof data35 === "string") {
|
|
7998
|
+
if (!pattern4.test(data35)) {
|
|
7999
|
+
const err76 = {
|
|
7872
8000
|
instancePath: instancePath + "/email/content/download/url",
|
|
7873
8001
|
schemaPath: "#/properties/email/properties/content/properties/download/properties/url/pattern",
|
|
7874
8002
|
keyword: "pattern",
|
|
7875
8003
|
params: { pattern: "^https?://" },
|
|
7876
8004
|
message: "must match pattern \"^https?://\""
|
|
7877
8005
|
};
|
|
7878
|
-
if (vErrors === null) vErrors = [
|
|
7879
|
-
else vErrors.push(
|
|
8006
|
+
if (vErrors === null) vErrors = [err76];
|
|
8007
|
+
else vErrors.push(err76);
|
|
7880
8008
|
errors++;
|
|
7881
8009
|
}
|
|
7882
|
-
if (!formats4.test(
|
|
7883
|
-
const
|
|
8010
|
+
if (!formats4.test(data35)) {
|
|
8011
|
+
const err77 = {
|
|
7884
8012
|
instancePath: instancePath + "/email/content/download/url",
|
|
7885
8013
|
schemaPath: "#/properties/email/properties/content/properties/download/properties/url/format",
|
|
7886
8014
|
keyword: "format",
|
|
7887
8015
|
params: { format: "uri" },
|
|
7888
8016
|
message: "must match format \"uri\""
|
|
7889
8017
|
};
|
|
7890
|
-
if (vErrors === null) vErrors = [
|
|
7891
|
-
else vErrors.push(
|
|
8018
|
+
if (vErrors === null) vErrors = [err77];
|
|
8019
|
+
else vErrors.push(err77);
|
|
7892
8020
|
errors++;
|
|
7893
8021
|
}
|
|
7894
8022
|
} else {
|
|
7895
|
-
const
|
|
8023
|
+
const err78 = {
|
|
7896
8024
|
instancePath: instancePath + "/email/content/download/url",
|
|
7897
8025
|
schemaPath: "#/properties/email/properties/content/properties/download/properties/url/type",
|
|
7898
8026
|
keyword: "type",
|
|
7899
8027
|
params: { type: "string" },
|
|
7900
8028
|
message: "must be string"
|
|
7901
8029
|
};
|
|
7902
|
-
if (vErrors === null) vErrors = [
|
|
7903
|
-
else vErrors.push(
|
|
8030
|
+
if (vErrors === null) vErrors = [err78];
|
|
8031
|
+
else vErrors.push(err78);
|
|
7904
8032
|
errors++;
|
|
7905
8033
|
}
|
|
7906
8034
|
}
|
|
7907
|
-
if (
|
|
7908
|
-
let
|
|
7909
|
-
if (typeof
|
|
7910
|
-
if (!formats0.test(
|
|
7911
|
-
const
|
|
8035
|
+
if (data34.expires_at !== void 0) {
|
|
8036
|
+
let data36 = data34.expires_at;
|
|
8037
|
+
if (typeof data36 === "string") {
|
|
8038
|
+
if (!formats0.test(data36)) {
|
|
8039
|
+
const err79 = {
|
|
7912
8040
|
instancePath: instancePath + "/email/content/download/expires_at",
|
|
7913
8041
|
schemaPath: "#/properties/email/properties/content/properties/download/properties/expires_at/format",
|
|
7914
8042
|
keyword: "format",
|
|
7915
8043
|
params: { format: "date-time" },
|
|
7916
8044
|
message: "must match format \"date-time\""
|
|
7917
8045
|
};
|
|
7918
|
-
if (vErrors === null) vErrors = [
|
|
7919
|
-
else vErrors.push(
|
|
8046
|
+
if (vErrors === null) vErrors = [err79];
|
|
8047
|
+
else vErrors.push(err79);
|
|
7920
8048
|
errors++;
|
|
7921
8049
|
}
|
|
7922
8050
|
} else {
|
|
7923
|
-
const
|
|
8051
|
+
const err80 = {
|
|
7924
8052
|
instancePath: instancePath + "/email/content/download/expires_at",
|
|
7925
8053
|
schemaPath: "#/properties/email/properties/content/properties/download/properties/expires_at/type",
|
|
7926
8054
|
keyword: "type",
|
|
7927
8055
|
params: { type: "string" },
|
|
7928
8056
|
message: "must be string"
|
|
7929
8057
|
};
|
|
7930
|
-
if (vErrors === null) vErrors = [
|
|
7931
|
-
else vErrors.push(
|
|
8058
|
+
if (vErrors === null) vErrors = [err80];
|
|
8059
|
+
else vErrors.push(err80);
|
|
7932
8060
|
errors++;
|
|
7933
8061
|
}
|
|
7934
8062
|
}
|
|
7935
|
-
} else {
|
|
7936
|
-
const err74 = {
|
|
7937
|
-
instancePath: instancePath + "/email/content/download",
|
|
7938
|
-
schemaPath: "#/properties/email/properties/content/properties/download/type",
|
|
7939
|
-
keyword: "type",
|
|
7940
|
-
params: { type: "object" },
|
|
7941
|
-
message: "must be object"
|
|
7942
|
-
};
|
|
7943
|
-
if (vErrors === null) vErrors = [err74];
|
|
7944
|
-
else vErrors.push(err74);
|
|
7945
|
-
errors++;
|
|
7946
8063
|
}
|
|
7947
8064
|
}
|
|
7948
8065
|
} else {
|
|
7949
|
-
const
|
|
8066
|
+
const err81 = {
|
|
7950
8067
|
instancePath: instancePath + "/email/content",
|
|
7951
8068
|
schemaPath: "#/properties/email/properties/content/type",
|
|
7952
8069
|
keyword: "type",
|
|
7953
8070
|
params: { type: "object" },
|
|
7954
8071
|
message: "must be object"
|
|
7955
8072
|
};
|
|
7956
|
-
if (vErrors === null) vErrors = [
|
|
7957
|
-
else vErrors.push(
|
|
8073
|
+
if (vErrors === null) vErrors = [err81];
|
|
8074
|
+
else vErrors.push(err81);
|
|
7958
8075
|
errors++;
|
|
7959
8076
|
}
|
|
7960
8077
|
}
|
|
@@ -7992,28 +8109,28 @@ function validate11(data, { instancePath = "", parentData, parentDataProperty, r
|
|
|
7992
8109
|
}
|
|
7993
8110
|
}
|
|
7994
8111
|
} else {
|
|
7995
|
-
const
|
|
8112
|
+
const err82 = {
|
|
7996
8113
|
instancePath: instancePath + "/email",
|
|
7997
8114
|
schemaPath: "#/properties/email/type",
|
|
7998
8115
|
keyword: "type",
|
|
7999
8116
|
params: { type: "object" },
|
|
8000
8117
|
message: "must be object"
|
|
8001
8118
|
};
|
|
8002
|
-
if (vErrors === null) vErrors = [
|
|
8003
|
-
else vErrors.push(
|
|
8119
|
+
if (vErrors === null) vErrors = [err82];
|
|
8120
|
+
else vErrors.push(err82);
|
|
8004
8121
|
errors++;
|
|
8005
8122
|
}
|
|
8006
8123
|
}
|
|
8007
8124
|
} else {
|
|
8008
|
-
const
|
|
8125
|
+
const err83 = {
|
|
8009
8126
|
instancePath,
|
|
8010
8127
|
schemaPath: "#/type",
|
|
8011
8128
|
keyword: "type",
|
|
8012
8129
|
params: { type: "object" },
|
|
8013
8130
|
message: "must be object"
|
|
8014
8131
|
};
|
|
8015
|
-
if (vErrors === null) vErrors = [
|
|
8016
|
-
else vErrors.push(
|
|
8132
|
+
if (vErrors === null) vErrors = [err83];
|
|
8133
|
+
else vErrors.push(err83);
|
|
8017
8134
|
errors++;
|
|
8018
8135
|
}
|
|
8019
8136
|
validate11.errors = vErrors;
|
|
@@ -8035,6 +8152,228 @@ function validate10(data, { instancePath = "", parentData, parentDataProperty, r
|
|
|
8035
8152
|
return errors === 0;
|
|
8036
8153
|
}
|
|
8037
8154
|
//#endregion
|
|
8155
|
+
//#region src/webhook/errors.ts
|
|
8156
|
+
/**
|
|
8157
|
+
* Verification error definitions.
|
|
8158
|
+
* Use these for documentation, dashboards, and i18n.
|
|
8159
|
+
*/
|
|
8160
|
+
const VERIFICATION_ERRORS = {
|
|
8161
|
+
INVALID_SIGNATURE_HEADER: {
|
|
8162
|
+
message: "Missing or malformed Primitive-Signature header",
|
|
8163
|
+
suggestion: "Check that you're reading the correct header (Primitive-Signature) and it's being passed correctly from your web framework."
|
|
8164
|
+
},
|
|
8165
|
+
TIMESTAMP_OUT_OF_RANGE: {
|
|
8166
|
+
message: "Timestamp is too old (possible replay attack)",
|
|
8167
|
+
suggestion: "This could indicate a replay attack, network delay, or server clock drift. Check your server's time is synced."
|
|
8168
|
+
},
|
|
8169
|
+
SIGNATURE_MISMATCH: {
|
|
8170
|
+
message: "Signature doesn't match expected value",
|
|
8171
|
+
suggestion: "Verify the webhook secret matches and you're using the raw request body (not re-serialized JSON)."
|
|
8172
|
+
},
|
|
8173
|
+
MISSING_SECRET: {
|
|
8174
|
+
message: "No webhook secret was provided",
|
|
8175
|
+
suggestion: "Pass your webhook secret from the Primitive dashboard. Check that the environment variable is set."
|
|
8176
|
+
}
|
|
8177
|
+
};
|
|
8178
|
+
/**
|
|
8179
|
+
* Payload parsing error definitions.
|
|
8180
|
+
* Use these for documentation, dashboards, and i18n.
|
|
8181
|
+
*/
|
|
8182
|
+
const PAYLOAD_ERRORS = {
|
|
8183
|
+
PAYLOAD_NULL: {
|
|
8184
|
+
message: "Webhook payload is null",
|
|
8185
|
+
suggestion: "Ensure you're passing the parsed JSON body, not null. Check your framework's body parsing middleware."
|
|
8186
|
+
},
|
|
8187
|
+
PAYLOAD_UNDEFINED: {
|
|
8188
|
+
message: "Webhook payload is undefined",
|
|
8189
|
+
suggestion: "The payload was not provided. Make sure you're passing the request body to the handler."
|
|
8190
|
+
},
|
|
8191
|
+
PAYLOAD_WRONG_TYPE: {
|
|
8192
|
+
message: "Webhook payload must be an object",
|
|
8193
|
+
suggestion: "The payload should be a parsed JSON object. Check that you're not passing a string or other primitive."
|
|
8194
|
+
},
|
|
8195
|
+
PAYLOAD_IS_ARRAY: {
|
|
8196
|
+
message: "Webhook payload is an array, expected object",
|
|
8197
|
+
suggestion: "Primitive webhooks are single event objects, not arrays. Check the payload structure."
|
|
8198
|
+
},
|
|
8199
|
+
PAYLOAD_MISSING_EVENT: {
|
|
8200
|
+
message: "Webhook payload missing 'event' field",
|
|
8201
|
+
suggestion: "All webhook payloads must have an 'event' field. This may not be a valid Primitive webhook."
|
|
8202
|
+
},
|
|
8203
|
+
PAYLOAD_UNKNOWN_EVENT: {
|
|
8204
|
+
message: "Unknown webhook event type",
|
|
8205
|
+
suggestion: "This event type is not recognized. You may need to update your SDK or handle unknown events gracefully."
|
|
8206
|
+
},
|
|
8207
|
+
PAYLOAD_EMPTY_BODY: {
|
|
8208
|
+
message: "Request body is empty",
|
|
8209
|
+
suggestion: "The request body was empty. Ensure the webhook is sending data and your framework is parsing it correctly."
|
|
8210
|
+
},
|
|
8211
|
+
JSON_PARSE_FAILED: {
|
|
8212
|
+
message: "Failed to parse JSON body",
|
|
8213
|
+
suggestion: "The request body is not valid JSON. Check the raw body content and Content-Type header."
|
|
8214
|
+
},
|
|
8215
|
+
INVALID_ENCODING: {
|
|
8216
|
+
message: "Invalid body encoding",
|
|
8217
|
+
suggestion: "The request body encoding is not supported. Primitive webhooks use UTF-8 encoded JSON."
|
|
8218
|
+
}
|
|
8219
|
+
};
|
|
8220
|
+
/**
|
|
8221
|
+
* Raw email decode error definitions.
|
|
8222
|
+
* Use these for documentation, dashboards, and i18n.
|
|
8223
|
+
*/
|
|
8224
|
+
const RAW_EMAIL_ERRORS = {
|
|
8225
|
+
UNAVAILABLE: {
|
|
8226
|
+
message: "Raw email content is unavailable for this credential",
|
|
8227
|
+
suggestion: "Use event.email.parsed for message content. This credential cannot download raw email."
|
|
8228
|
+
},
|
|
8229
|
+
NOT_INCLUDED: {
|
|
8230
|
+
message: "Raw email content not included inline",
|
|
8231
|
+
suggestion: "Use the download URL at event.email.content.download.url to fetch the raw email."
|
|
8232
|
+
},
|
|
8233
|
+
INVALID_BASE64: {
|
|
8234
|
+
message: "Raw email content is not valid base64",
|
|
8235
|
+
suggestion: "The raw email data is malformed. Fetch the raw email from the download URL or regenerate the webhook payload."
|
|
8236
|
+
},
|
|
8237
|
+
HASH_MISMATCH: {
|
|
8238
|
+
message: "SHA-256 hash verification failed",
|
|
8239
|
+
suggestion: "The raw email data may be corrupted. Try downloading from the URL instead."
|
|
8240
|
+
}
|
|
8241
|
+
};
|
|
8242
|
+
/**
|
|
8243
|
+
* Base class for all Primitive webhook errors.
|
|
8244
|
+
*
|
|
8245
|
+
* Catch this to handle any error from the SDK in a single catch block.
|
|
8246
|
+
*
|
|
8247
|
+
* @example
|
|
8248
|
+
* ```typescript
|
|
8249
|
+
* import { handleWebhook, PrimitiveWebhookError } from '@primitivedotdev/sdk';
|
|
8250
|
+
*
|
|
8251
|
+
* try {
|
|
8252
|
+
* const event = handleWebhook({ body, headers, secret });
|
|
8253
|
+
* } catch (err) {
|
|
8254
|
+
* if (err instanceof PrimitiveWebhookError) {
|
|
8255
|
+
* console.error(`[${err.code}] ${err.message}`);
|
|
8256
|
+
* return res.status(400).json({ error: err.code });
|
|
8257
|
+
* }
|
|
8258
|
+
* throw err;
|
|
8259
|
+
* }
|
|
8260
|
+
* ```
|
|
8261
|
+
*/
|
|
8262
|
+
var PrimitiveWebhookError = class extends Error {
|
|
8263
|
+
/**
|
|
8264
|
+
* Formats the error for logging/display.
|
|
8265
|
+
*/
|
|
8266
|
+
toString() {
|
|
8267
|
+
return `${this.name} [${this.code}]: ${this.message}\n\nSuggestion: ${this.suggestion}`;
|
|
8268
|
+
}
|
|
8269
|
+
/**
|
|
8270
|
+
* Serializes cleanly for structured logging (Datadog, CloudWatch, etc.)
|
|
8271
|
+
*/
|
|
8272
|
+
toJSON() {
|
|
8273
|
+
return {
|
|
8274
|
+
name: this.name,
|
|
8275
|
+
code: this.code,
|
|
8276
|
+
message: this.message,
|
|
8277
|
+
suggestion: this.suggestion
|
|
8278
|
+
};
|
|
8279
|
+
}
|
|
8280
|
+
};
|
|
8281
|
+
/**
|
|
8282
|
+
* Error thrown when webhook signature verification fails.
|
|
8283
|
+
*
|
|
8284
|
+
* Use the `code` property to programmatically handle specific error cases.
|
|
8285
|
+
*/
|
|
8286
|
+
var WebhookVerificationError = class extends PrimitiveWebhookError {
|
|
8287
|
+
code;
|
|
8288
|
+
suggestion;
|
|
8289
|
+
constructor(code, message, suggestion) {
|
|
8290
|
+
super(message ?? VERIFICATION_ERRORS[code].message);
|
|
8291
|
+
this.name = "WebhookVerificationError";
|
|
8292
|
+
this.code = code;
|
|
8293
|
+
this.suggestion = suggestion ?? VERIFICATION_ERRORS[code].suggestion;
|
|
8294
|
+
}
|
|
8295
|
+
};
|
|
8296
|
+
/**
|
|
8297
|
+
* Error thrown when webhook payload parsing fails (lightweight parser).
|
|
8298
|
+
*
|
|
8299
|
+
* Use the `code` property for programmatic handling and monitoring.
|
|
8300
|
+
* The `suggestion` property contains actionable guidance for fixing the issue.
|
|
8301
|
+
*/
|
|
8302
|
+
var WebhookPayloadError = class extends PrimitiveWebhookError {
|
|
8303
|
+
code;
|
|
8304
|
+
suggestion;
|
|
8305
|
+
/** Original error if this wraps another error (e.g., JSON.parse failure) */
|
|
8306
|
+
cause;
|
|
8307
|
+
constructor(code, message, suggestion, cause) {
|
|
8308
|
+
super(message ?? PAYLOAD_ERRORS[code].message);
|
|
8309
|
+
this.name = "WebhookPayloadError";
|
|
8310
|
+
this.code = code;
|
|
8311
|
+
this.suggestion = suggestion ?? PAYLOAD_ERRORS[code].suggestion;
|
|
8312
|
+
this.cause = cause;
|
|
8313
|
+
}
|
|
8314
|
+
};
|
|
8315
|
+
/**
|
|
8316
|
+
* Error thrown when schema validation fails.
|
|
8317
|
+
*/
|
|
8318
|
+
var WebhookValidationError = class extends PrimitiveWebhookError {
|
|
8319
|
+
code = "SCHEMA_VALIDATION_FAILED";
|
|
8320
|
+
suggestion;
|
|
8321
|
+
/** The specific field path that failed (e.g., "email.headers.from") */
|
|
8322
|
+
field;
|
|
8323
|
+
/** Original schema validation errors for advanced debugging */
|
|
8324
|
+
validationErrors;
|
|
8325
|
+
/** Number of additional validation errors beyond the first */
|
|
8326
|
+
additionalErrorCount;
|
|
8327
|
+
constructor(field, message, suggestion, validationErrors) {
|
|
8328
|
+
super(message);
|
|
8329
|
+
this.name = "WebhookValidationError";
|
|
8330
|
+
this.field = field;
|
|
8331
|
+
this.suggestion = suggestion;
|
|
8332
|
+
this.validationErrors = validationErrors;
|
|
8333
|
+
this.additionalErrorCount = Math.max(0, validationErrors.length - 1);
|
|
8334
|
+
}
|
|
8335
|
+
/**
|
|
8336
|
+
* Formats the error for logging/display.
|
|
8337
|
+
* Includes error count and suggestion.
|
|
8338
|
+
*/
|
|
8339
|
+
toString() {
|
|
8340
|
+
let output = `${this.name} [${this.code}]: ${this.message}`;
|
|
8341
|
+
if (this.additionalErrorCount > 0) output += ` (and ${this.additionalErrorCount} more validation error${this.additionalErrorCount > 1 ? "s" : ""})`;
|
|
8342
|
+
output += `\n\nSuggestion: ${this.suggestion}`;
|
|
8343
|
+
return output;
|
|
8344
|
+
}
|
|
8345
|
+
/**
|
|
8346
|
+
* Serializes cleanly for structured logging (Datadog, CloudWatch, etc.)
|
|
8347
|
+
*/
|
|
8348
|
+
toJSON() {
|
|
8349
|
+
return {
|
|
8350
|
+
name: this.name,
|
|
8351
|
+
code: this.code,
|
|
8352
|
+
field: this.field,
|
|
8353
|
+
message: this.message,
|
|
8354
|
+
suggestion: this.suggestion,
|
|
8355
|
+
additionalErrorCount: this.additionalErrorCount
|
|
8356
|
+
};
|
|
8357
|
+
}
|
|
8358
|
+
};
|
|
8359
|
+
/**
|
|
8360
|
+
* Error thrown when raw email decoding or verification fails.
|
|
8361
|
+
*
|
|
8362
|
+
* Use the `code` property to determine the failure reason:
|
|
8363
|
+
* - `NOT_INCLUDED`: Raw email not inline, must download from URL
|
|
8364
|
+
* - `HASH_MISMATCH`: SHA-256 verification failed, content may be corrupted
|
|
8365
|
+
*/
|
|
8366
|
+
var RawEmailDecodeError = class extends PrimitiveWebhookError {
|
|
8367
|
+
code;
|
|
8368
|
+
suggestion;
|
|
8369
|
+
constructor(code, message) {
|
|
8370
|
+
super(message ?? RAW_EMAIL_ERRORS[code].message);
|
|
8371
|
+
this.name = "RawEmailDecodeError";
|
|
8372
|
+
this.code = code;
|
|
8373
|
+
this.suggestion = RAW_EMAIL_ERRORS[code].suggestion;
|
|
8374
|
+
}
|
|
8375
|
+
};
|
|
8376
|
+
//#endregion
|
|
8038
8377
|
//#region src/validation.ts
|
|
8039
8378
|
const validateSchema = email_received_event_validator_generated_default;
|
|
8040
8379
|
function toFieldPath(instancePath) {
|
|
@@ -8222,121 +8561,6 @@ function safeValidateEmailReceivedEvent(input) {
|
|
|
8222
8561
|
};
|
|
8223
8562
|
}
|
|
8224
8563
|
//#endregion
|
|
8225
|
-
//#region src/webhook/download-tokens.ts
|
|
8226
|
-
/**
|
|
8227
|
-
* Signed download tokens.
|
|
8228
|
-
*
|
|
8229
|
-
* A download token is a self-describing bearer credential for fetching a
|
|
8230
|
-
* specific email's raw bytes or attachment bundle from a per-deployment
|
|
8231
|
-
* download endpoint. It binds:
|
|
8232
|
-
*
|
|
8233
|
-
* - `email_id`: the specific email the token authorizes.
|
|
8234
|
-
* - `aud`: a caller-chosen audience label (e.g. the resource kind being
|
|
8235
|
-
* downloaded). Tokens minted for one audience will not verify under another.
|
|
8236
|
-
* - `exp`: an absolute expiration time (unix seconds).
|
|
8237
|
-
*
|
|
8238
|
-
* Format: `<base64url(payload)>.<base64url(signature)>` where `signature`
|
|
8239
|
-
* is HMAC-SHA256 over the base64url-encoded payload using the shared secret.
|
|
8240
|
-
*
|
|
8241
|
-
* The audience is an opaque caller-chosen string. Both the issuer and the
|
|
8242
|
-
* verifier must agree on the exact bytes; the SDK does not prescribe a
|
|
8243
|
-
* convention. New integrations are encouraged to namespace audiences
|
|
8244
|
-
* (e.g. `primitive:raw-download`).
|
|
8245
|
-
*
|
|
8246
|
-
* Tokens are stateless: verification needs only the shared secret. Keep
|
|
8247
|
-
* expirations as short as operationally tolerable.
|
|
8248
|
-
*/
|
|
8249
|
-
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
8250
|
-
/**
|
|
8251
|
-
* Issue a signed download token.
|
|
8252
|
-
*
|
|
8253
|
-
* The resulting token is `<base64url-payload>.<base64url-signature>`, where
|
|
8254
|
-
* the payload is `{"email_id":"...","exp":...,"aud":"..."}` (snake_case,
|
|
8255
|
-
* field order fixed) and the signature is HMAC-SHA256 of the base64url
|
|
8256
|
-
* payload string using `secret`.
|
|
8257
|
-
*
|
|
8258
|
-
* @param params - Token inputs.
|
|
8259
|
-
* @returns The signed token string.
|
|
8260
|
-
*/
|
|
8261
|
-
function generateDownloadToken(params) {
|
|
8262
|
-
const { emailId, expiresAt, audience, secret } = params;
|
|
8263
|
-
const payloadJson = JSON.stringify({
|
|
8264
|
-
email_id: emailId,
|
|
8265
|
-
exp: expiresAt,
|
|
8266
|
-
aud: audience
|
|
8267
|
-
});
|
|
8268
|
-
const payloadStr = Buffer.from(payloadJson, "utf8").toString("base64url");
|
|
8269
|
-
return `${payloadStr}.${createHmac("sha256", secret).update(payloadStr).digest("base64url")}`;
|
|
8270
|
-
}
|
|
8271
|
-
/**
|
|
8272
|
-
* Verify a signed download token.
|
|
8273
|
-
*
|
|
8274
|
-
* Returns a discriminated-union result. The function never throws for
|
|
8275
|
-
* verification failures. Only malformed inputs at the crypto layer would
|
|
8276
|
-
* surface. Callers should check `result.valid` and log `result.error`.
|
|
8277
|
-
*
|
|
8278
|
-
* @param params - Verification inputs.
|
|
8279
|
-
* @returns Whether the token is valid, plus a reason on failure.
|
|
8280
|
-
*/
|
|
8281
|
-
function verifyDownloadToken(params) {
|
|
8282
|
-
const { token, emailId, audience, secret, nowSeconds } = params;
|
|
8283
|
-
if (typeof token !== "string" || token.length === 0) return {
|
|
8284
|
-
valid: false,
|
|
8285
|
-
error: "Token is empty"
|
|
8286
|
-
};
|
|
8287
|
-
const firstDot = token.indexOf(".");
|
|
8288
|
-
const lastDot = token.lastIndexOf(".");
|
|
8289
|
-
if (firstDot === -1 || firstDot !== lastDot) return {
|
|
8290
|
-
valid: false,
|
|
8291
|
-
error: "Token is malformed: expected one '.'"
|
|
8292
|
-
};
|
|
8293
|
-
const payloadStr = token.slice(0, firstDot);
|
|
8294
|
-
const providedSignature = token.slice(firstDot + 1);
|
|
8295
|
-
if (payloadStr.length === 0 || providedSignature.length === 0) return {
|
|
8296
|
-
valid: false,
|
|
8297
|
-
error: "Token is malformed: empty part"
|
|
8298
|
-
};
|
|
8299
|
-
const expectedSignature = createHmac("sha256", secret).update(payloadStr).digest("base64url");
|
|
8300
|
-
const providedBytes = Buffer.from(providedSignature, "base64url");
|
|
8301
|
-
const expectedBytes = Buffer.from(expectedSignature, "base64url");
|
|
8302
|
-
if (providedBytes.length !== expectedBytes.length || !timingSafeEqual(providedBytes, expectedBytes)) return {
|
|
8303
|
-
valid: false,
|
|
8304
|
-
error: "Invalid signature"
|
|
8305
|
-
};
|
|
8306
|
-
if (!BASE64URL_PATTERN.test(payloadStr)) return {
|
|
8307
|
-
valid: false,
|
|
8308
|
-
error: "Token payload is not valid base64url"
|
|
8309
|
-
};
|
|
8310
|
-
const decodedJson = Buffer.from(payloadStr, "base64url").toString("utf8");
|
|
8311
|
-
let payload;
|
|
8312
|
-
try {
|
|
8313
|
-
payload = JSON.parse(decodedJson);
|
|
8314
|
-
} catch {
|
|
8315
|
-
return {
|
|
8316
|
-
valid: false,
|
|
8317
|
-
error: "Token payload is not valid JSON"
|
|
8318
|
-
};
|
|
8319
|
-
}
|
|
8320
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload) || typeof payload.email_id !== "string" || typeof payload.aud !== "string" || typeof payload.exp !== "number") return {
|
|
8321
|
-
valid: false,
|
|
8322
|
-
error: "Token payload has wrong shape"
|
|
8323
|
-
};
|
|
8324
|
-
const { email_id, aud, exp } = payload;
|
|
8325
|
-
if (aud !== audience) return {
|
|
8326
|
-
valid: false,
|
|
8327
|
-
error: "Audience mismatch"
|
|
8328
|
-
};
|
|
8329
|
-
if (email_id !== emailId) return {
|
|
8330
|
-
valid: false,
|
|
8331
|
-
error: "Email ID mismatch"
|
|
8332
|
-
};
|
|
8333
|
-
if (exp <= (nowSeconds ?? Math.floor(Date.now() / 1e3))) return {
|
|
8334
|
-
valid: false,
|
|
8335
|
-
error: "Token is expired"
|
|
8336
|
-
};
|
|
8337
|
-
return { valid: true };
|
|
8338
|
-
}
|
|
8339
|
-
//#endregion
|
|
8340
8564
|
//#region src/webhook/events.ts
|
|
8341
8565
|
/**
|
|
8342
8566
|
* The five first-party email events (subject = an email).
|
|
@@ -8425,2078 +8649,499 @@ function isInteractionX402Event(event) {
|
|
|
8425
8649
|
return eventName(event)?.startsWith("interaction.x402.") ?? false;
|
|
8426
8650
|
}
|
|
8427
8651
|
//#endregion
|
|
8428
|
-
//#region src/webhook/
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
|
|
8435
|
-
|
|
8436
|
-
|
|
8437
|
-
|
|
8438
|
-
|
|
8439
|
-
|
|
8440
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
8444
|
-
|
|
8445
|
-
|
|
8446
|
-
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
|
|
8652
|
+
//#region src/webhook/parse-event.ts
|
|
8653
|
+
function parseWebhookEvent(input, eventType) {
|
|
8654
|
+
if (input === null) throw new WebhookPayloadError("PAYLOAD_NULL", "Received null instead of webhook payload", "Check that your request body variable is defined.");
|
|
8655
|
+
if (input === void 0) throw new WebhookPayloadError("PAYLOAD_UNDEFINED", "Received undefined instead of webhook payload", "Make sure you're passing the request body to parseWebhookEvent()");
|
|
8656
|
+
if (Array.isArray(input)) throw new WebhookPayloadError("PAYLOAD_IS_ARRAY", "Received array instead of webhook payload object", "Webhook payloads must be objects, not arrays.");
|
|
8657
|
+
if (typeof input !== "object") throw new WebhookPayloadError("PAYLOAD_WRONG_TYPE", `Received ${typeof input} instead of webhook payload object`, "Webhook payloads must be objects.");
|
|
8658
|
+
const obj = input;
|
|
8659
|
+
const resolvedEvent = typeof eventType === "string" && eventType || (typeof obj.event === "string" ? obj.event : void 0);
|
|
8660
|
+
if (!resolvedEvent) throw new WebhookPayloadError("PAYLOAD_MISSING_EVENT", "Missing event discriminator: no X-Webhook-Event header and no 'event' field in payload", "Pass the X-Webhook-Event header (the canonical discriminator) or call handleWebhookEvent, which reads it for you.");
|
|
8661
|
+
switch (resolvedEvent) {
|
|
8662
|
+
case "email.received": return validateEmailReceivedEvent(input);
|
|
8663
|
+
case "payment.settled":
|
|
8664
|
+
case "payment.failed": return {
|
|
8665
|
+
...obj,
|
|
8666
|
+
event: resolvedEvent
|
|
8667
|
+
};
|
|
8668
|
+
default:
|
|
8669
|
+
if (isKnownWebhookEventType(resolvedEvent)) return {
|
|
8670
|
+
...obj,
|
|
8671
|
+
event: resolvedEvent
|
|
8672
|
+
};
|
|
8673
|
+
return {
|
|
8674
|
+
...obj,
|
|
8675
|
+
event: resolvedEvent
|
|
8676
|
+
};
|
|
8450
8677
|
}
|
|
8451
8678
|
}
|
|
8452
8679
|
//#endregion
|
|
8453
|
-
//#region src/webhook/
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
*/
|
|
8468
|
-
/**
|
|
8469
|
-
* WeakMap cache for computed signatures.
|
|
8470
|
-
* Only works for Buffer bodies (strings cannot be WeakMap keys).
|
|
8471
|
-
* Automatically garbage collected when Buffer is no longer referenced.
|
|
8472
|
-
*/
|
|
8473
|
-
const signatureCache = /* @__PURE__ */ new WeakMap();
|
|
8474
|
-
/**
|
|
8475
|
-
* Hash a secret for cache key comparison.
|
|
8476
|
-
* @internal
|
|
8477
|
-
*/
|
|
8478
|
-
function hashSecret(secret) {
|
|
8479
|
-
return createHash("sha256").update(secret).digest("hex");
|
|
8480
|
-
}
|
|
8481
|
-
/** Header name for incoming webhook signature */
|
|
8482
|
-
const PRIMITIVE_SIGNATURE_HEADER = "Primitive-Signature";
|
|
8483
|
-
/** Legacy header name kept for backward compatibility with older servers */
|
|
8484
|
-
const LEGACY_SIGNATURE_HEADER = "MyMX-Signature";
|
|
8485
|
-
/** Header name to confirm webhook was processed (prevents retries) */
|
|
8486
|
-
const PRIMITIVE_CONFIRMED_HEADER = "X-Primitive-Confirmed";
|
|
8487
|
-
/** Legacy confirmed header name kept for backward compatibility */
|
|
8488
|
-
const LEGACY_CONFIRMED_HEADER = "X-MyMX-Confirmed";
|
|
8489
|
-
/** Default max age for webhook requests (5 minutes) */
|
|
8490
|
-
const DEFAULT_TOLERANCE_SECONDS$1 = 300;
|
|
8491
|
-
/** Future clock skew tolerance (1 minute) */
|
|
8492
|
-
const FUTURE_TOLERANCE_SECONDS$1 = 60;
|
|
8493
|
-
/** Valid hex pattern for signature verification */
|
|
8494
|
-
const HEX_PATTERN = /^[0-9a-f]+$/i;
|
|
8495
|
-
/** Strict unix-seconds pattern for the timestamp component */
|
|
8496
|
-
const UNIX_SECONDS_PATTERN = /^\d+$/;
|
|
8497
|
-
/**
|
|
8498
|
-
* Sign a webhook payload using HMAC-SHA256.
|
|
8499
|
-
*
|
|
8500
|
-
* Useful for:
|
|
8501
|
-
* - Internal testing and dogfooding
|
|
8502
|
-
* - Generating test vectors
|
|
8503
|
-
* - Integration tests
|
|
8504
|
-
*
|
|
8505
|
-
* @param rawBody - The raw JSON body string to sign
|
|
8506
|
-
* @param secret - The webhook secret key
|
|
8507
|
-
* @param timestamp - Unix timestamp in seconds (defaults to current time)
|
|
8508
|
-
*/
|
|
8509
|
-
function signWebhookPayload(rawBody, secret, timestamp) {
|
|
8510
|
-
const ts = timestamp ?? Math.floor(Date.now() / 1e3);
|
|
8511
|
-
const signedPayloadString = `${ts}.${typeof rawBody === "string" ? rawBody : bufferToString(rawBody, "rawBody")}`;
|
|
8512
|
-
const hmac = createHmac("sha256", secret);
|
|
8513
|
-
hmac.update(signedPayloadString);
|
|
8514
|
-
const v1 = hmac.digest("hex");
|
|
8680
|
+
//#region src/webhook/received-email.ts
|
|
8681
|
+
const REPLY_PREFIX_RE = /^re\s*:/i;
|
|
8682
|
+
const FORWARD_PREFIX_RE = /^(fwd?|fw)\s*:/i;
|
|
8683
|
+
function normalizeReceivedEmail(event) {
|
|
8684
|
+
const receivedBy = event.email.smtp.rcpt_to[0];
|
|
8685
|
+
if (!receivedBy) throw new Error("email.smtp.rcpt_to must contain at least one recipient");
|
|
8686
|
+
const sender = parseHeaderAddress(event.email.headers.from) ?? {
|
|
8687
|
+
address: event.email.smtp.mail_from.trim().toLowerCase(),
|
|
8688
|
+
name: null
|
|
8689
|
+
};
|
|
8690
|
+
const replyTarget = firstStructuredAddress(event.email.parsed.reply_to) ?? sender;
|
|
8691
|
+
const subject = event.email.headers.subject ?? null;
|
|
8692
|
+
const references = event.email.parsed.references ?? [];
|
|
8693
|
+
const messageId = event.email.headers.message_id ?? null;
|
|
8515
8694
|
return {
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8695
|
+
id: event.email.id,
|
|
8696
|
+
eventId: event.id,
|
|
8697
|
+
receivedAt: event.email.received_at,
|
|
8698
|
+
sender,
|
|
8699
|
+
replyTarget,
|
|
8700
|
+
receivedBy,
|
|
8701
|
+
receivedByAll: [...event.email.smtp.rcpt_to],
|
|
8702
|
+
subject,
|
|
8703
|
+
replySubject: buildReplySubject(subject),
|
|
8704
|
+
forwardSubject: buildForwardSubject(subject),
|
|
8705
|
+
text: event.email.parsed.body_text ?? null,
|
|
8706
|
+
thread: {
|
|
8707
|
+
messageId,
|
|
8708
|
+
inReplyTo: event.email.parsed.in_reply_to ?? [],
|
|
8709
|
+
references
|
|
8710
|
+
},
|
|
8711
|
+
attachments: event.email.parsed.attachments ?? [],
|
|
8712
|
+
auth: event.email.auth,
|
|
8713
|
+
analysis: event.email.analysis,
|
|
8714
|
+
raw: event
|
|
8519
8715
|
};
|
|
8520
8716
|
}
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
if (
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
if (!key || !value) continue;
|
|
8538
|
-
if (key === "t") {
|
|
8539
|
-
if (!UNIX_SECONDS_PATTERN.test(value)) continue;
|
|
8540
|
-
const parsed = Number(value);
|
|
8541
|
-
if (Number.isSafeInteger(parsed)) timestamp = parsed;
|
|
8542
|
-
} else if (key === "v1") signatures.push(value);
|
|
8543
|
-
}
|
|
8544
|
-
if (timestamp === null || signatures.length === 0) return null;
|
|
8717
|
+
function buildReplySubject(subject) {
|
|
8718
|
+
const trimmed = subject?.trim() ?? "";
|
|
8719
|
+
if (trimmed.length === 0) return "Re:";
|
|
8720
|
+
return REPLY_PREFIX_RE.test(trimmed) ? trimmed : `Re: ${trimmed}`;
|
|
8721
|
+
}
|
|
8722
|
+
function buildForwardSubject(subject) {
|
|
8723
|
+
const trimmed = subject?.trim() ?? "";
|
|
8724
|
+
if (trimmed.length === 0) return "Fwd:";
|
|
8725
|
+
return FORWARD_PREFIX_RE.test(trimmed) ? trimmed : `Fwd: ${trimmed}`;
|
|
8726
|
+
}
|
|
8727
|
+
function formatAddress(address) {
|
|
8728
|
+
return address.name ? `${address.name} <${address.address}>` : address.address;
|
|
8729
|
+
}
|
|
8730
|
+
function firstStructuredAddress(addresses) {
|
|
8731
|
+
const address = addresses?.[0];
|
|
8732
|
+
if (!address) return null;
|
|
8545
8733
|
return {
|
|
8546
|
-
|
|
8547
|
-
|
|
8734
|
+
address: address.address.trim().toLowerCase(),
|
|
8735
|
+
name: address.name ?? null
|
|
8548
8736
|
};
|
|
8549
8737
|
}
|
|
8550
|
-
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
|
|
8738
|
+
function parseHeaderAddress(value) {
|
|
8739
|
+
const parsed = parseFromHeaderLoose(value);
|
|
8740
|
+
if (!parsed) return null;
|
|
8741
|
+
return {
|
|
8742
|
+
address: parsed.address,
|
|
8743
|
+
name: parsed.name?.trim() || null
|
|
8744
|
+
};
|
|
8555
8745
|
}
|
|
8746
|
+
//#endregion
|
|
8747
|
+
//#region src/types.ts
|
|
8748
|
+
const EventType = {
|
|
8749
|
+
EmailReceived: "email.received",
|
|
8750
|
+
EmailBounced: "email.bounced",
|
|
8751
|
+
EmailTlsReport: "email.tls_report",
|
|
8752
|
+
EmailDmarcReport: "email.dmarc_report",
|
|
8753
|
+
EmailDmarcFailure: "email.dmarc_failure"
|
|
8754
|
+
};
|
|
8755
|
+
const ParsedStatus = {
|
|
8756
|
+
Complete: "complete",
|
|
8757
|
+
Failed: "failed"
|
|
8758
|
+
};
|
|
8759
|
+
const ForwardVerdict = {
|
|
8760
|
+
Legit: "legit",
|
|
8761
|
+
Unknown: "unknown"
|
|
8762
|
+
};
|
|
8763
|
+
const SpfResult = {
|
|
8764
|
+
Pass: "pass",
|
|
8765
|
+
Fail: "fail",
|
|
8766
|
+
Softfail: "softfail",
|
|
8767
|
+
Neutral: "neutral",
|
|
8768
|
+
None: "none",
|
|
8769
|
+
Temperror: "temperror",
|
|
8770
|
+
Permerror: "permerror"
|
|
8771
|
+
};
|
|
8772
|
+
const DmarcResult = {
|
|
8773
|
+
Pass: "pass",
|
|
8774
|
+
Fail: "fail",
|
|
8775
|
+
None: "none",
|
|
8776
|
+
Temperror: "temperror",
|
|
8777
|
+
Permerror: "permerror"
|
|
8778
|
+
};
|
|
8779
|
+
const DmarcPolicy = {
|
|
8780
|
+
Reject: "reject",
|
|
8781
|
+
Quarantine: "quarantine",
|
|
8782
|
+
None: "none"
|
|
8783
|
+
};
|
|
8784
|
+
const DkimResult = {
|
|
8785
|
+
Pass: "pass",
|
|
8786
|
+
Fail: "fail",
|
|
8787
|
+
Temperror: "temperror",
|
|
8788
|
+
Permerror: "permerror"
|
|
8789
|
+
};
|
|
8790
|
+
const AuthConfidence = {
|
|
8791
|
+
High: "high",
|
|
8792
|
+
Medium: "medium",
|
|
8793
|
+
Low: "low"
|
|
8794
|
+
};
|
|
8795
|
+
const AuthVerdict = {
|
|
8796
|
+
Legit: "legit",
|
|
8797
|
+
Suspicious: "suspicious",
|
|
8798
|
+
Unknown: "unknown"
|
|
8799
|
+
};
|
|
8800
|
+
//#endregion
|
|
8801
|
+
//#region src/webhook/auth.ts
|
|
8556
8802
|
/**
|
|
8557
|
-
*
|
|
8803
|
+
* Minimum DKIM key size considered acceptable.
|
|
8558
8804
|
*
|
|
8559
|
-
*
|
|
8805
|
+
* 1024-bit RSA keys are cryptographically weak by modern standards (NIST
|
|
8806
|
+
* deprecated them in 2013), but they remain extremely common in email due to:
|
|
8807
|
+
* - DNS TXT record size limits (255 bytes per string)
|
|
8808
|
+
* - Legacy infrastructure constraints
|
|
8809
|
+
* - Major ESPs like Amazon SES and Resend still use 1024-bit keys
|
|
8560
8810
|
*
|
|
8561
|
-
*
|
|
8562
|
-
*
|
|
8563
|
-
*
|
|
8564
|
-
*
|
|
8565
|
-
* try {
|
|
8566
|
-
* const signatureHeader = req.headers['primitive-signature'];
|
|
8567
|
-
* if (typeof signatureHeader !== 'string') {
|
|
8568
|
-
* throw new Error('Missing Primitive-Signature header');
|
|
8569
|
-
* }
|
|
8570
|
-
* verifyWebhookSignature({
|
|
8571
|
-
* rawBody: req.body, // raw string, NOT parsed JSON
|
|
8572
|
-
* signatureHeader,
|
|
8573
|
-
* secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
|
|
8574
|
-
* });
|
|
8575
|
-
* // Signature is valid, process the webhook
|
|
8576
|
-
* } catch (err) {
|
|
8577
|
-
* if (err instanceof WebhookVerificationError) {
|
|
8578
|
-
* console.error('Invalid webhook:', err.code, err.message);
|
|
8579
|
-
* }
|
|
8580
|
-
* return res.status(400).send('Invalid signature');
|
|
8581
|
-
* }
|
|
8582
|
-
* ```
|
|
8583
|
-
*/
|
|
8584
|
-
function verifyWebhookSignature(opts) {
|
|
8585
|
-
const { rawBody, signatureHeader, secret, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS$1, nowSeconds } = opts;
|
|
8586
|
-
if (!secret || typeof secret === "string" && secret.length === 0 || Buffer.isBuffer(secret) && secret.length === 0) throw new WebhookVerificationError("MISSING_SECRET", "Webhook secret is required but was empty or not provided");
|
|
8587
|
-
const parsed = parseSignatureHeader(signatureHeader);
|
|
8588
|
-
if (!parsed) throw new WebhookVerificationError("INVALID_SIGNATURE_HEADER", "Invalid Primitive-Signature header format. Expected: t={timestamp},v1={signature}");
|
|
8589
|
-
const { timestamp, signatures } = parsed;
|
|
8590
|
-
const age = (nowSeconds ?? Math.floor(Date.now() / 1e3)) - timestamp;
|
|
8591
|
-
if (age > toleranceSeconds) throw new WebhookVerificationError("TIMESTAMP_OUT_OF_RANGE", `Webhook timestamp too old (${age}s). Max age is ${toleranceSeconds}s.`);
|
|
8592
|
-
if (age < -FUTURE_TOLERANCE_SECONDS$1) throw new WebhookVerificationError("TIMESTAMP_OUT_OF_RANGE", "Webhook timestamp is too far in the future. Check server clock sync.");
|
|
8593
|
-
const body = typeof rawBody === "string" ? rawBody : bufferToString(rawBody, "request body");
|
|
8594
|
-
let expectedHex;
|
|
8595
|
-
if (Buffer.isBuffer(rawBody)) {
|
|
8596
|
-
const cached = signatureCache.get(rawBody);
|
|
8597
|
-
const currentSecretHash = hashSecret(secret);
|
|
8598
|
-
if (cached && cached.body === body && cached.timestamp === timestamp && cached.secretHash === currentSecretHash) expectedHex = cached.computed;
|
|
8599
|
-
else {
|
|
8600
|
-
const signedPayloadString = `${timestamp}.${body}`;
|
|
8601
|
-
const hmac = createHmac("sha256", secret);
|
|
8602
|
-
hmac.update(signedPayloadString);
|
|
8603
|
-
expectedHex = hmac.digest("hex");
|
|
8604
|
-
signatureCache.set(rawBody, {
|
|
8605
|
-
secretHash: currentSecretHash,
|
|
8606
|
-
timestamp,
|
|
8607
|
-
body,
|
|
8608
|
-
computed: expectedHex
|
|
8609
|
-
});
|
|
8610
|
-
}
|
|
8611
|
-
} else {
|
|
8612
|
-
const signedPayloadString = `${timestamp}.${body}`;
|
|
8613
|
-
const hmac = createHmac("sha256", secret);
|
|
8614
|
-
hmac.update(signedPayloadString);
|
|
8615
|
-
expectedHex = hmac.digest("hex");
|
|
8616
|
-
}
|
|
8617
|
-
for (const receivedHex of signatures) {
|
|
8618
|
-
if (!isValidHex(receivedHex, 64)) continue;
|
|
8619
|
-
if (timingSafeEqual(Buffer.from(receivedHex, "hex"), Buffer.from(expectedHex, "hex"))) return true;
|
|
8620
|
-
}
|
|
8621
|
-
const reserializationHint = detectReserializedBody(body);
|
|
8622
|
-
throw new WebhookVerificationError("SIGNATURE_MISMATCH", reserializationHint ? `No valid signature found. ${reserializationHint}` : "No valid signature found. Verify the webhook secret matches and you're using the raw request body (not re-serialized JSON).");
|
|
8623
|
-
}
|
|
8624
|
-
/**
|
|
8625
|
-
* Detect if a body looks like it was re-serialized by a framework.
|
|
8626
|
-
* Returns a helpful message if detected, null otherwise.
|
|
8627
|
-
* @internal
|
|
8811
|
+
* We flag keys <1024 bits as weak (these are truly dangerous), while accepting
|
|
8812
|
+
* >=1024 bits to avoid false positives against legitimate senders. For maximum
|
|
8813
|
+
* security, domain owners should use 2048+ bit keys where possible.
|
|
8628
8814
|
*/
|
|
8629
|
-
|
|
8630
|
-
if (/^\s*\{[\s\S]*\n\s{2,}/.test(body)) return "Request body appears re-serialized (pretty-printed). Use the raw request body before any JSON.parse() or JSON.stringify() calls.";
|
|
8631
|
-
return null;
|
|
8632
|
-
}
|
|
8633
|
-
//#endregion
|
|
8634
|
-
//#region src/webhook/standard-webhooks.ts
|
|
8815
|
+
const MIN_SECURE_KEY_BITS = 1024;
|
|
8635
8816
|
/**
|
|
8636
|
-
*
|
|
8817
|
+
* Validate email authentication and compute a verdict.
|
|
8637
8818
|
*
|
|
8638
|
-
*
|
|
8639
|
-
*
|
|
8819
|
+
* This function analyzes SPF, DKIM, and DMARC results to determine
|
|
8820
|
+
* whether an email is likely authentic ("legit"), potentially spoofed
|
|
8821
|
+
* ("suspicious"), or indeterminate ("unknown").
|
|
8640
8822
|
*
|
|
8641
|
-
*
|
|
8642
|
-
* webhook-id: <msg_id>
|
|
8643
|
-
* webhook-timestamp: <unix_seconds>
|
|
8644
|
-
* webhook-signature: v1,<base64_hmac_sha256> [v1,<base64_2> ...]
|
|
8823
|
+
* ## Verdict Logic
|
|
8645
8824
|
*
|
|
8646
|
-
*
|
|
8825
|
+
* **Legit (high confidence):**
|
|
8826
|
+
* - DMARC pass with DKIM alignment (cryptographic proof of authenticity)
|
|
8647
8827
|
*
|
|
8648
|
-
*
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
const BASE64_PATTERN$1 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
8652
|
-
/** Default max age for webhook requests (5 minutes) */
|
|
8653
|
-
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
8654
|
-
/** Future clock skew tolerance (1 minute) */
|
|
8655
|
-
const FUTURE_TOLERANCE_SECONDS = 60;
|
|
8656
|
-
/** Standard Webhooks header names */
|
|
8657
|
-
const STANDARD_WEBHOOK_ID_HEADER = "webhook-id";
|
|
8658
|
-
const STANDARD_WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp";
|
|
8659
|
-
const STANDARD_WEBHOOK_SIGNATURE_HEADER = "webhook-signature";
|
|
8660
|
-
/**
|
|
8661
|
-
* Prepare a Standard Webhooks secret for HMAC computation.
|
|
8828
|
+
* **Legit (medium confidence):**
|
|
8829
|
+
* - DMARC pass with SPF alignment only (no DKIM)
|
|
8830
|
+
* - Note: SPF can break through forwarding, but DMARC pass is still meaningful
|
|
8662
8831
|
*
|
|
8663
|
-
*
|
|
8664
|
-
*
|
|
8832
|
+
* **Suspicious (high confidence):**
|
|
8833
|
+
* - DMARC fail when domain has `reject` or `quarantine` policy
|
|
8834
|
+
* - The domain owner explicitly says to distrust failing emails
|
|
8835
|
+
* - SPF explicitly fails (IP not authorized by sender)
|
|
8665
8836
|
*
|
|
8666
|
-
*
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
if (Buffer.isBuffer(secret)) {
|
|
8670
|
-
if (secret.length === 0) throw new WebhookVerificationError("MISSING_SECRET", "Webhook secret is required but was empty or not provided");
|
|
8671
|
-
return secret;
|
|
8672
|
-
}
|
|
8673
|
-
let keyStr = secret;
|
|
8674
|
-
if (keyStr.startsWith(WHSEC_PREFIX)) keyStr = keyStr.slice(6);
|
|
8675
|
-
if (!keyStr || !BASE64_PATTERN$1.test(keyStr)) throw new WebhookVerificationError("MISSING_SECRET", "Standard Webhooks secret must be base64-encoded (optionally with whsec_ prefix)");
|
|
8676
|
-
const decoded = Buffer.from(keyStr, "base64");
|
|
8677
|
-
if (decoded.length === 0) throw new WebhookVerificationError("MISSING_SECRET", "Webhook secret is required but was empty or not provided");
|
|
8678
|
-
return decoded;
|
|
8679
|
-
}
|
|
8680
|
-
/**
|
|
8681
|
-
* Parse the webhook-signature header into base64 signature strings.
|
|
8837
|
+
* **Suspicious (low confidence):**
|
|
8838
|
+
* - DMARC fail when domain has `none` policy (monitoring mode)
|
|
8839
|
+
* - No DMARC record but SPF/DKIM fail
|
|
8682
8840
|
*
|
|
8683
|
-
*
|
|
8684
|
-
*
|
|
8841
|
+
* **Unknown:**
|
|
8842
|
+
* - No DMARC record and no clear pass/fail
|
|
8843
|
+
* - Temporary errors during authentication
|
|
8844
|
+
* - No authentication data available
|
|
8685
8845
|
*
|
|
8686
|
-
*
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
const signatures = [];
|
|
8691
|
-
for (const entry of header.split(" ")) {
|
|
8692
|
-
const trimmed = entry.trim();
|
|
8693
|
-
if (!trimmed) continue;
|
|
8694
|
-
const commaIdx = trimmed.indexOf(",");
|
|
8695
|
-
if (commaIdx === -1) continue;
|
|
8696
|
-
const version = trimmed.slice(0, commaIdx);
|
|
8697
|
-
const sig = trimmed.slice(commaIdx + 1);
|
|
8698
|
-
if (version === "v1" && sig) signatures.push(sig);
|
|
8699
|
-
}
|
|
8700
|
-
return signatures;
|
|
8701
|
-
}
|
|
8702
|
-
/**
|
|
8703
|
-
* Sign a webhook payload using the Standard Webhooks format.
|
|
8704
|
-
*
|
|
8705
|
-
* @param rawBody - The raw JSON body string to sign
|
|
8706
|
-
* @param secret - The webhook secret (with or without whsec_ prefix)
|
|
8707
|
-
* @param msgId - The message ID (used in webhook-id header)
|
|
8708
|
-
* @param timestamp - Unix timestamp in seconds (defaults to current time)
|
|
8709
|
-
*/
|
|
8710
|
-
function signStandardWebhooksPayload(rawBody, secret, msgId, timestamp) {
|
|
8711
|
-
const ts = timestamp ?? Math.floor(Date.now() / 1e3);
|
|
8712
|
-
const body = typeof rawBody === "string" ? rawBody : bufferToString(rawBody, "rawBody");
|
|
8713
|
-
const key = prepareStandardWebhooksSecret(secret);
|
|
8714
|
-
if (key.length === 0) throw new WebhookVerificationError("MISSING_SECRET", "Webhook secret is required but was empty or not provided");
|
|
8715
|
-
const signedPayload = `${msgId}.${ts}.${body}`;
|
|
8716
|
-
return {
|
|
8717
|
-
signature: `v1,${createHmac("sha256", key).update(signedPayload).digest("base64")}`,
|
|
8718
|
-
msgId,
|
|
8719
|
-
timestamp: ts
|
|
8720
|
-
};
|
|
8721
|
-
}
|
|
8722
|
-
/**
|
|
8723
|
-
* Verify a Standard Webhooks signature.
|
|
8724
|
-
*
|
|
8725
|
-
* Throws `WebhookVerificationError` on failure with a specific error code.
|
|
8726
|
-
*/
|
|
8727
|
-
function verifyStandardWebhooksSignature(opts) {
|
|
8728
|
-
const { rawBody, msgId, timestamp: timestampStr, signatureHeader, secret, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, nowSeconds } = opts;
|
|
8729
|
-
const key = prepareStandardWebhooksSecret(secret);
|
|
8730
|
-
if (key.length === 0) throw new WebhookVerificationError("MISSING_SECRET", "Webhook secret is required but was empty or not provided");
|
|
8731
|
-
if (!timestampStr || !/^\d+$/.test(timestampStr)) throw new WebhookVerificationError("INVALID_SIGNATURE_HEADER", `Invalid webhook-timestamp header: "${timestampStr}". Expected a unix timestamp in seconds`);
|
|
8732
|
-
const timestamp = Number(timestampStr);
|
|
8733
|
-
if (!Number.isInteger(timestamp) || timestamp < 0) throw new WebhookVerificationError("INVALID_SIGNATURE_HEADER", `Invalid webhook-timestamp header: "${timestampStr}". Expected a unix timestamp in seconds`);
|
|
8734
|
-
const age = (nowSeconds ?? Math.floor(Date.now() / 1e3)) - timestamp;
|
|
8735
|
-
if (age > toleranceSeconds) throw new WebhookVerificationError("TIMESTAMP_OUT_OF_RANGE", `Webhook timestamp too old (${age}s). Max age is ${toleranceSeconds}s.`);
|
|
8736
|
-
if (age < -FUTURE_TOLERANCE_SECONDS) throw new WebhookVerificationError("TIMESTAMP_OUT_OF_RANGE", "Webhook timestamp is too far in the future. Check server clock sync.");
|
|
8737
|
-
const signedPayload = `${msgId}.${timestamp}.${typeof rawBody === "string" ? rawBody : bufferToString(rawBody, "request body")}`;
|
|
8738
|
-
const expectedSig = createHmac("sha256", key).update(signedPayload).digest("base64");
|
|
8739
|
-
const signatures = parseStandardWebhooksSignatures(signatureHeader);
|
|
8740
|
-
if (signatures.length === 0) throw new WebhookVerificationError("INVALID_SIGNATURE_HEADER", "Invalid webhook-signature header format. Expected: \"v1,<base64>\"");
|
|
8741
|
-
const expectedBytes = Buffer.from(expectedSig, "base64");
|
|
8742
|
-
for (const sig of signatures) {
|
|
8743
|
-
if (!BASE64_PATTERN$1.test(sig)) continue;
|
|
8744
|
-
const sigBytes = Buffer.from(sig, "base64");
|
|
8745
|
-
if (sigBytes.length === expectedBytes.length && timingSafeEqual(sigBytes, expectedBytes)) return true;
|
|
8746
|
-
}
|
|
8747
|
-
throw new WebhookVerificationError("SIGNATURE_MISMATCH", "No valid signature found. Verify the webhook secret matches and you're using the raw request body (not re-serialized JSON).");
|
|
8748
|
-
}
|
|
8749
|
-
//#endregion
|
|
8750
|
-
//#region src/schema.generated.ts
|
|
8751
|
-
const emailReceivedEventJsonSchema = {
|
|
8752
|
-
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
8753
|
-
"$ref": "#/definitions/EmailReceivedEvent",
|
|
8754
|
-
"definitions": {
|
|
8755
|
-
"EmailReceivedEvent": {
|
|
8756
|
-
"type": "object",
|
|
8757
|
-
"properties": {
|
|
8758
|
-
"id": {
|
|
8759
|
-
"type": "string",
|
|
8760
|
-
"pattern": "^evt_[a-f0-9]{64}$",
|
|
8761
|
-
"description": "Unique delivery event ID.\n\nThis ID is stable across retries to the same endpoint - use it as your idempotency/dedupe key. Note that the same email delivered to different endpoints will have different event IDs.\n\nFormat: `evt_` prefix followed by a SHA-256 hash (64 hex characters). Example: `evt_a1b2c3d4e5f6...` (68 characters total)"
|
|
8762
|
-
},
|
|
8763
|
-
"event": {
|
|
8764
|
-
"type": "string",
|
|
8765
|
-
"enum": [
|
|
8766
|
-
"email.received",
|
|
8767
|
-
"email.bounced",
|
|
8768
|
-
"email.tls_report",
|
|
8769
|
-
"email.dmarc_report",
|
|
8770
|
-
"email.dmarc_failure"
|
|
8771
|
-
],
|
|
8772
|
-
"description": "Event type identifier.\n\n- `email.received` - A normal inbound email.\n- `email.bounced` - A delivery status notification (DSN) reporting that a message delivery failed. Carries `email.analysis.bounce`.\n- `email.tls_report` - An SMTP TLS report (RFC 8460). Carries `email.analysis.tls_report`.\n- `email.dmarc_report` - A DMARC aggregate report (RFC 7489). Carries `email.analysis.dmarc_report`.\n- `email.dmarc_failure` - A DMARC failure (forensic) report.\n\nMachine-generated mail (bounces and the report types above) is delivered under its own event type rather than `email.received`, so an endpoint subscribed only to `email.received` receives just normal inbound mail. The payload shape is otherwise identical across event types; the type-specific details live under `email.analysis`."
|
|
8773
|
-
},
|
|
8774
|
-
"version": {
|
|
8775
|
-
"$ref": "#/definitions/WebhookVersion",
|
|
8776
|
-
"description": "API version in date format (YYYY-MM-DD). Use this to detect version mismatches between webhook and SDK."
|
|
8777
|
-
},
|
|
8778
|
-
"routing": {
|
|
8779
|
-
"$ref": "#/definitions/RoutingDecision",
|
|
8780
|
-
"description": "The recipient-routing decision for this email: where it was delivered and why. Present only when recipient routing ran for the organization; omitted otherwise. This is the compact decision also recorded on the email's routing metadata."
|
|
8781
|
-
},
|
|
8782
|
-
"delivery": {
|
|
8783
|
-
"type": "object",
|
|
8784
|
-
"properties": {
|
|
8785
|
-
"endpoint_id": {
|
|
8786
|
-
"type": "string",
|
|
8787
|
-
"description": "ID of the webhook endpoint receiving this event. Matches the endpoint ID from your Primitive dashboard."
|
|
8788
|
-
},
|
|
8789
|
-
"attempt": {
|
|
8790
|
-
"type": "integer",
|
|
8791
|
-
"minimum": 1,
|
|
8792
|
-
"description": "Delivery attempt number, starting at 1. Increments with each retry if previous attempts failed."
|
|
8793
|
-
},
|
|
8794
|
-
"attempted_at": {
|
|
8795
|
-
"type": "string",
|
|
8796
|
-
"format": "date-time",
|
|
8797
|
-
"description": "ISO 8601 timestamp (UTC) when this delivery was attempted.",
|
|
8798
|
-
"examples": ["2025-01-15T10:30:00.000Z"]
|
|
8799
|
-
}
|
|
8800
|
-
},
|
|
8801
|
-
"required": [
|
|
8802
|
-
"endpoint_id",
|
|
8803
|
-
"attempt",
|
|
8804
|
-
"attempted_at"
|
|
8805
|
-
],
|
|
8806
|
-
"description": "Metadata about this webhook delivery."
|
|
8807
|
-
},
|
|
8808
|
-
"email": {
|
|
8809
|
-
"type": "object",
|
|
8810
|
-
"properties": {
|
|
8811
|
-
"id": {
|
|
8812
|
-
"type": "string",
|
|
8813
|
-
"description": "Unique email ID in Primitive. Use this ID when calling Primitive APIs to reference this email."
|
|
8814
|
-
},
|
|
8815
|
-
"thread_id": {
|
|
8816
|
-
"type": ["string", "null"],
|
|
8817
|
-
"description": "Conversation thread this email belongs to. Inbound and outbound messages in the same conversation share a thread_id; fetch GET /v1/threads/{thread_id} for the full thread. Null on messages received before threading was enabled."
|
|
8818
|
-
},
|
|
8819
|
-
"received_at": {
|
|
8820
|
-
"type": "string",
|
|
8821
|
-
"format": "date-time",
|
|
8822
|
-
"description": "ISO 8601 timestamp (UTC) when Primitive received the email.",
|
|
8823
|
-
"examples": ["2025-01-15T10:29:55.123Z"]
|
|
8824
|
-
},
|
|
8825
|
-
"smtp": {
|
|
8826
|
-
"type": "object",
|
|
8827
|
-
"properties": {
|
|
8828
|
-
"helo": {
|
|
8829
|
-
"type": ["string", "null"],
|
|
8830
|
-
"description": "HELO/EHLO hostname from the sending server. Null if not provided during SMTP transaction."
|
|
8831
|
-
},
|
|
8832
|
-
"mail_from": {
|
|
8833
|
-
"type": "string",
|
|
8834
|
-
"description": "SMTP envelope sender (MAIL FROM command). This is the bounce address, which may differ from the From header."
|
|
8835
|
-
},
|
|
8836
|
-
"rcpt_to": {
|
|
8837
|
-
"type": "array",
|
|
8838
|
-
"items": { "type": "string" },
|
|
8839
|
-
"minItems": 1,
|
|
8840
|
-
"description": "SMTP envelope recipients (RCPT TO commands). All addresses that received this email in a single delivery."
|
|
8841
|
-
}
|
|
8842
|
-
},
|
|
8843
|
-
"required": [
|
|
8844
|
-
"helo",
|
|
8845
|
-
"mail_from",
|
|
8846
|
-
"rcpt_to"
|
|
8847
|
-
],
|
|
8848
|
-
"description": "SMTP envelope information. This is the \"real\" sender/recipient info from the SMTP transaction, which may differ from the headers (e.g., BCC recipients)."
|
|
8849
|
-
},
|
|
8850
|
-
"headers": {
|
|
8851
|
-
"type": "object",
|
|
8852
|
-
"properties": {
|
|
8853
|
-
"message_id": {
|
|
8854
|
-
"type": ["string", "null"],
|
|
8855
|
-
"description": "Message-ID header value. Null if the email had no Message-ID header."
|
|
8856
|
-
},
|
|
8857
|
-
"subject": {
|
|
8858
|
-
"type": ["string", "null"],
|
|
8859
|
-
"description": "Subject header value. Null if the email had no Subject header."
|
|
8860
|
-
},
|
|
8861
|
-
"from": {
|
|
8862
|
-
"type": "string",
|
|
8863
|
-
"minLength": 1,
|
|
8864
|
-
"description": "From header value. May include display name: `\"John Doe\" <john@example.com>`"
|
|
8865
|
-
},
|
|
8866
|
-
"to": {
|
|
8867
|
-
"type": "string",
|
|
8868
|
-
"minLength": 1,
|
|
8869
|
-
"description": "To header value. May include multiple addresses or display names."
|
|
8870
|
-
},
|
|
8871
|
-
"date": {
|
|
8872
|
-
"type": ["string", "null"],
|
|
8873
|
-
"description": "Date header value as it appeared in the email. Null if the email had no Date header."
|
|
8874
|
-
}
|
|
8875
|
-
},
|
|
8876
|
-
"required": [
|
|
8877
|
-
"message_id",
|
|
8878
|
-
"subject",
|
|
8879
|
-
"from",
|
|
8880
|
-
"to",
|
|
8881
|
-
"date"
|
|
8882
|
-
],
|
|
8883
|
-
"description": "Parsed email headers. These are extracted from the email content, not the SMTP envelope."
|
|
8884
|
-
},
|
|
8885
|
-
"content": {
|
|
8886
|
-
"type": "object",
|
|
8887
|
-
"properties": {
|
|
8888
|
-
"raw": {
|
|
8889
|
-
"$ref": "#/definitions/RawContent",
|
|
8890
|
-
"description": "Raw email in RFC 5322 format. May be inline (base64) or download-only depending on size."
|
|
8891
|
-
},
|
|
8892
|
-
"download": {
|
|
8893
|
-
"type": "object",
|
|
8894
|
-
"properties": {
|
|
8895
|
-
"url": {
|
|
8896
|
-
"type": "string",
|
|
8897
|
-
"format": "uri",
|
|
8898
|
-
"pattern": "^https?://",
|
|
8899
|
-
"description": "URL to download the raw email as-is in RFC 5322 format. Managed Primitive always issues HTTPS. Self-host deployments may issue HTTP URLs that resolve inside the operator's network (e.g. `http://localhost:4001/...`). Receivers that want to refuse plaintext downloads should check the scheme explicitly."
|
|
8900
|
-
},
|
|
8901
|
-
"expires_at": {
|
|
8902
|
-
"type": "string",
|
|
8903
|
-
"format": "date-time",
|
|
8904
|
-
"description": "ISO 8601 timestamp (UTC) when this URL expires. Download before this time or the URL will return 403."
|
|
8905
|
-
}
|
|
8906
|
-
},
|
|
8907
|
-
"required": ["url", "expires_at"],
|
|
8908
|
-
"description": "Download information for the raw email. Always present, even if raw content is inline."
|
|
8909
|
-
}
|
|
8910
|
-
},
|
|
8911
|
-
"required": ["raw", "download"],
|
|
8912
|
-
"description": "Raw email content and download information."
|
|
8913
|
-
},
|
|
8914
|
-
"parsed": {
|
|
8915
|
-
"$ref": "#/definitions/ParsedData",
|
|
8916
|
-
"description": "Parsed email content (body text, HTML, attachments). Check `status` to determine if parsing succeeded."
|
|
8917
|
-
},
|
|
8918
|
-
"analysis": {
|
|
8919
|
-
"$ref": "#/definitions/EmailAnalysis",
|
|
8920
|
-
"description": "Email analysis and classification results."
|
|
8921
|
-
},
|
|
8922
|
-
"auth": {
|
|
8923
|
-
"$ref": "#/definitions/EmailAuth",
|
|
8924
|
-
"description": "Email authentication results (SPF, DKIM, DMARC)."
|
|
8925
|
-
}
|
|
8926
|
-
},
|
|
8927
|
-
"required": [
|
|
8928
|
-
"id",
|
|
8929
|
-
"received_at",
|
|
8930
|
-
"smtp",
|
|
8931
|
-
"headers",
|
|
8932
|
-
"content",
|
|
8933
|
-
"parsed",
|
|
8934
|
-
"analysis",
|
|
8935
|
-
"auth"
|
|
8936
|
-
],
|
|
8937
|
-
"description": "The email that triggered this event."
|
|
8938
|
-
}
|
|
8939
|
-
},
|
|
8940
|
-
"required": [
|
|
8941
|
-
"id",
|
|
8942
|
-
"event",
|
|
8943
|
-
"version",
|
|
8944
|
-
"delivery",
|
|
8945
|
-
"email"
|
|
8946
|
-
],
|
|
8947
|
-
"description": "Webhook payload for the `email.received` event.\n\nThis is delivered to your webhook endpoint when Primitive receives an email matching your domain configuration."
|
|
8948
|
-
},
|
|
8949
|
-
"RoutingDecision": {
|
|
8950
|
-
"type": "object",
|
|
8951
|
-
"description": "The recipient-routing decision for an inbound email: which endpoint it resolved to and why. Present on the event only when recipient routing ran for the organization.",
|
|
8952
|
-
"properties": {
|
|
8953
|
-
"version": {
|
|
8954
|
-
"type": "integer",
|
|
8955
|
-
"description": "Decision schema version, so consumers can detect shape changes."
|
|
8956
|
-
},
|
|
8957
|
-
"outcome": {
|
|
8958
|
-
"type": "string",
|
|
8959
|
-
"enum": [
|
|
8960
|
-
"matched",
|
|
8961
|
-
"defaulted",
|
|
8962
|
-
"none"
|
|
8963
|
-
],
|
|
8964
|
-
"description": "How the destination was chosen: `matched` a recipient route, fell back to a `defaulted` destination (domain or org), or `none` (no destination resolved)."
|
|
8965
|
-
},
|
|
8966
|
-
"endpoint_id": {
|
|
8967
|
-
"type": ["string", "null"],
|
|
8968
|
-
"description": "The endpoint the email was delivered to, or null when no destination resolved."
|
|
8969
|
-
},
|
|
8970
|
-
"matched_route_id": {
|
|
8971
|
-
"type": ["string", "null"],
|
|
8972
|
-
"description": "The recipient route that matched, or null when the outcome was not `matched`."
|
|
8973
|
-
},
|
|
8974
|
-
"matched_tier": {
|
|
8975
|
-
"type": ["string", "null"],
|
|
8976
|
-
"enum": [
|
|
8977
|
-
"exact",
|
|
8978
|
-
"wildcard",
|
|
8979
|
-
"regex",
|
|
8980
|
-
null
|
|
8981
|
-
],
|
|
8982
|
-
"description": "The match tier of the route that matched, or null when the outcome was not `matched`."
|
|
8983
|
-
},
|
|
8984
|
-
"default_scope": {
|
|
8985
|
-
"type": ["string", "null"],
|
|
8986
|
-
"enum": [
|
|
8987
|
-
"domain",
|
|
8988
|
-
"org",
|
|
8989
|
-
null
|
|
8990
|
-
],
|
|
8991
|
-
"description": "When the outcome was `defaulted`, whether the default came from the domain or the organization; null otherwise."
|
|
8992
|
-
}
|
|
8993
|
-
},
|
|
8994
|
-
"required": ["outcome"]
|
|
8995
|
-
},
|
|
8996
|
-
"WebhookVersion": {
|
|
8997
|
-
"type": "string",
|
|
8998
|
-
"pattern": "^(?:(?:\\d{4}-(?:(?:01|03|05|07|08|10|12)-(?:0[1-9]|[12]\\d|3[01])|(?:04|06|09|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8])))|(?:(?:[02468][048]00|[13579][26]00|\\d{2}(?:0[48]|[2468][048]|[13579][26]))-02-29))$",
|
|
8999
|
-
"description": "Valid webhook version format (YYYY-MM-DD date string). The SDK accepts any valid date-formatted version, not just the current one, for forward and backward compatibility."
|
|
9000
|
-
},
|
|
9001
|
-
"RawContent": {
|
|
9002
|
-
"anyOf": [{ "$ref": "#/definitions/RawContentInline" }, { "$ref": "#/definitions/RawContentDownloadOnly" }],
|
|
9003
|
-
"description": "Raw email content - a discriminated union on `included`."
|
|
9004
|
-
},
|
|
9005
|
-
"RawContentInline": {
|
|
9006
|
-
"type": "object",
|
|
9007
|
-
"properties": {
|
|
9008
|
-
"included": {
|
|
9009
|
-
"type": "boolean",
|
|
9010
|
-
"const": true,
|
|
9011
|
-
"description": "Discriminant indicating raw content is included inline."
|
|
9012
|
-
},
|
|
9013
|
-
"encoding": {
|
|
9014
|
-
"type": "string",
|
|
9015
|
-
"const": "base64",
|
|
9016
|
-
"description": "Encoding used for the data field. Always \"base64\"."
|
|
9017
|
-
},
|
|
9018
|
-
"max_inline_bytes": {
|
|
9019
|
-
"type": "integer",
|
|
9020
|
-
"minimum": 1,
|
|
9021
|
-
"description": "Maximum size in bytes for inline inclusion. Emails larger than this threshold require download."
|
|
9022
|
-
},
|
|
9023
|
-
"size_bytes": {
|
|
9024
|
-
"type": "integer",
|
|
9025
|
-
"minimum": 0,
|
|
9026
|
-
"description": "Actual size of the raw email in bytes."
|
|
9027
|
-
},
|
|
9028
|
-
"sha256": {
|
|
9029
|
-
"type": "string",
|
|
9030
|
-
"pattern": "^[a-fA-F0-9]{64}$",
|
|
9031
|
-
"description": "SHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after base64 decoding."
|
|
9032
|
-
},
|
|
9033
|
-
"data": {
|
|
9034
|
-
"type": "string",
|
|
9035
|
-
"description": "Base64-encoded raw email (RFC 5322 format). Decode with `Buffer.from(data, 'base64')` in Node.js."
|
|
9036
|
-
}
|
|
9037
|
-
},
|
|
9038
|
-
"required": [
|
|
9039
|
-
"included",
|
|
9040
|
-
"encoding",
|
|
9041
|
-
"max_inline_bytes",
|
|
9042
|
-
"size_bytes",
|
|
9043
|
-
"sha256",
|
|
9044
|
-
"data"
|
|
9045
|
-
],
|
|
9046
|
-
"description": "Raw email content included inline (base64 encoded).\n\nWhen the raw email is small enough (under {@link max_inline_bytes } ), it's included directly in the webhook payload for convenience."
|
|
9047
|
-
},
|
|
9048
|
-
"RawContentDownloadOnly": {
|
|
9049
|
-
"type": "object",
|
|
9050
|
-
"properties": {
|
|
9051
|
-
"included": {
|
|
9052
|
-
"type": "boolean",
|
|
9053
|
-
"const": false,
|
|
9054
|
-
"description": "Discriminant indicating raw content must be downloaded."
|
|
9055
|
-
},
|
|
9056
|
-
"reason_code": {
|
|
9057
|
-
"type": "string",
|
|
9058
|
-
"const": "size_exceeded",
|
|
9059
|
-
"description": "Reason the content wasn't included inline."
|
|
9060
|
-
},
|
|
9061
|
-
"max_inline_bytes": {
|
|
9062
|
-
"type": "integer",
|
|
9063
|
-
"minimum": 1,
|
|
9064
|
-
"description": "Maximum size in bytes for inline inclusion. The email exceeded this threshold."
|
|
9065
|
-
},
|
|
9066
|
-
"size_bytes": {
|
|
9067
|
-
"type": "integer",
|
|
9068
|
-
"minimum": 0,
|
|
9069
|
-
"description": "Actual size of the raw email in bytes."
|
|
9070
|
-
},
|
|
9071
|
-
"sha256": {
|
|
9072
|
-
"type": "string",
|
|
9073
|
-
"pattern": "^[a-fA-F0-9]{64}$",
|
|
9074
|
-
"description": "SHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after download."
|
|
9075
|
-
}
|
|
9076
|
-
},
|
|
9077
|
-
"required": [
|
|
9078
|
-
"included",
|
|
9079
|
-
"reason_code",
|
|
9080
|
-
"max_inline_bytes",
|
|
9081
|
-
"size_bytes",
|
|
9082
|
-
"sha256"
|
|
9083
|
-
],
|
|
9084
|
-
"description": "Raw email content not included (must be downloaded).\n\nWhen the raw email exceeds {@link max_inline_bytes } , it's not included in the webhook payload. Use the download URL from {@link EmailReceivedEvent.email.content.download } to fetch it."
|
|
9085
|
-
},
|
|
9086
|
-
"ParsedData": {
|
|
9087
|
-
"anyOf": [{ "$ref": "#/definitions/ParsedDataComplete" }, { "$ref": "#/definitions/ParsedDataFailed" }],
|
|
9088
|
-
"description": "Parsed email content - a discriminated union on `status`."
|
|
9089
|
-
},
|
|
9090
|
-
"ParsedDataComplete": {
|
|
9091
|
-
"type": "object",
|
|
9092
|
-
"properties": {
|
|
9093
|
-
"status": {
|
|
9094
|
-
"type": "string",
|
|
9095
|
-
"const": "complete",
|
|
9096
|
-
"description": "Discriminant indicating successful parsing."
|
|
9097
|
-
},
|
|
9098
|
-
"error": {
|
|
9099
|
-
"type": "null",
|
|
9100
|
-
"description": "Always null when parsing succeeds."
|
|
9101
|
-
},
|
|
9102
|
-
"body_text": {
|
|
9103
|
-
"type": ["string", "null"],
|
|
9104
|
-
"description": "Plain text body of the email. Null if the email had no text/plain part."
|
|
9105
|
-
},
|
|
9106
|
-
"body_html": {
|
|
9107
|
-
"type": ["string", "null"],
|
|
9108
|
-
"description": "HTML body of the email. Null if the email had no text/html part."
|
|
9109
|
-
},
|
|
9110
|
-
"reply_to": {
|
|
9111
|
-
"anyOf": [{
|
|
9112
|
-
"type": "array",
|
|
9113
|
-
"items": { "$ref": "#/definitions/EmailAddress" }
|
|
9114
|
-
}, { "type": "null" }],
|
|
9115
|
-
"description": "Parsed Reply-To header addresses. Null if the email had no Reply-To header."
|
|
9116
|
-
},
|
|
9117
|
-
"cc": {
|
|
9118
|
-
"anyOf": [{
|
|
9119
|
-
"type": "array",
|
|
9120
|
-
"items": { "$ref": "#/definitions/EmailAddress" }
|
|
9121
|
-
}, { "type": "null" }],
|
|
9122
|
-
"description": "Parsed CC header addresses. Null if the email had no CC header."
|
|
9123
|
-
},
|
|
9124
|
-
"bcc": {
|
|
9125
|
-
"anyOf": [{
|
|
9126
|
-
"type": "array",
|
|
9127
|
-
"items": { "$ref": "#/definitions/EmailAddress" }
|
|
9128
|
-
}, { "type": "null" }],
|
|
9129
|
-
"description": "Parsed BCC header addresses. Null if the email had no BCC header. Note: BCC is only available for outgoing emails or when explicitly provided."
|
|
9130
|
-
},
|
|
9131
|
-
"to_addresses": {
|
|
9132
|
-
"anyOf": [{
|
|
9133
|
-
"type": "array",
|
|
9134
|
-
"items": { "$ref": "#/definitions/EmailAddress" }
|
|
9135
|
-
}, { "type": "null" }],
|
|
9136
|
-
"description": "Parsed To header addresses. Null if the email had no To header."
|
|
9137
|
-
},
|
|
9138
|
-
"in_reply_to": {
|
|
9139
|
-
"anyOf": [{
|
|
9140
|
-
"type": "array",
|
|
9141
|
-
"items": { "type": "string" }
|
|
9142
|
-
}, { "type": "null" }],
|
|
9143
|
-
"description": "In-Reply-To header values (Message-IDs of the email(s) being replied to). Null if the email had no In-Reply-To header. Per RFC 5322, this can contain multiple Message-IDs, though typically just one.",
|
|
9144
|
-
"examples": [["<original-message-id@example.com>"]]
|
|
9145
|
-
},
|
|
9146
|
-
"references": {
|
|
9147
|
-
"anyOf": [{
|
|
9148
|
-
"type": "array",
|
|
9149
|
-
"items": { "type": "string" }
|
|
9150
|
-
}, { "type": "null" }],
|
|
9151
|
-
"description": "References header values (Message-IDs of the email thread). Null if the email had no References header.",
|
|
9152
|
-
"examples": [["<msg1@example.com>", "<msg2@example.com>"]]
|
|
9153
|
-
},
|
|
9154
|
-
"attachments": {
|
|
9155
|
-
"type": "array",
|
|
9156
|
-
"items": { "$ref": "#/definitions/WebhookAttachment" },
|
|
9157
|
-
"description": "List of attachments with metadata. Use {@link attachments_download_url } to download the actual files."
|
|
9158
|
-
},
|
|
9159
|
-
"attachments_download_url": {
|
|
9160
|
-
"type": ["string", "null"],
|
|
9161
|
-
"format": "uri",
|
|
9162
|
-
"pattern": "^https?://",
|
|
9163
|
-
"description": "URL to download all attachments as a tar.gz archive. Null if the email had no attachments. Managed Primitive always issues HTTPS. Self-host deployments may issue HTTP URLs that resolve inside the operator's network. URL expires - check the expiration before downloading."
|
|
9164
|
-
}
|
|
9165
|
-
},
|
|
9166
|
-
"required": [
|
|
9167
|
-
"status",
|
|
9168
|
-
"error",
|
|
9169
|
-
"body_text",
|
|
9170
|
-
"body_html",
|
|
9171
|
-
"reply_to",
|
|
9172
|
-
"cc",
|
|
9173
|
-
"bcc",
|
|
9174
|
-
"to_addresses",
|
|
9175
|
-
"in_reply_to",
|
|
9176
|
-
"references",
|
|
9177
|
-
"attachments",
|
|
9178
|
-
"attachments_download_url"
|
|
9179
|
-
],
|
|
9180
|
-
"description": "Parsed email content when parsing succeeded.\n\nUse the discriminant `status: \"complete\"` to narrow from {@link ParsedData } ."
|
|
9181
|
-
},
|
|
9182
|
-
"EmailAddress": {
|
|
9183
|
-
"type": "object",
|
|
9184
|
-
"properties": {
|
|
9185
|
-
"address": {
|
|
9186
|
-
"type": "string",
|
|
9187
|
-
"description": "The email address portion (e.g., \"john@example.com\").\n\nThis is the raw value from the email header with no validation applied. May contain unusual but valid formats like quoted local parts."
|
|
9188
|
-
},
|
|
9189
|
-
"name": {
|
|
9190
|
-
"type": ["string", "null"],
|
|
9191
|
-
"description": "The display name portion, if present. Null if the address had no display name.\n\nMay contain any characters including unicode, emoji, or special characters as they appeared in the original email header."
|
|
9192
|
-
}
|
|
9193
|
-
},
|
|
9194
|
-
"required": ["address", "name"],
|
|
9195
|
-
"description": "A parsed email address with optional display name.\n\nThis structure is used in the `parsed` section of the webhook payload (e.g., `reply_to`, `cc`, `bcc`). For unparsed header strings, see the `headers` section (e.g., `event.email.headers.from`)."
|
|
9196
|
-
},
|
|
9197
|
-
"WebhookAttachment": {
|
|
9198
|
-
"type": "object",
|
|
9199
|
-
"properties": {
|
|
9200
|
-
"filename": {
|
|
9201
|
-
"type": ["string", "null"],
|
|
9202
|
-
"description": "Original filename from the email. May be null if the attachment had no filename specified."
|
|
9203
|
-
},
|
|
9204
|
-
"content_type": {
|
|
9205
|
-
"type": "string",
|
|
9206
|
-
"description": "MIME content type (e.g., \"application/pdf\", \"image/png\")."
|
|
9207
|
-
},
|
|
9208
|
-
"size_bytes": {
|
|
9209
|
-
"type": "integer",
|
|
9210
|
-
"minimum": 0,
|
|
9211
|
-
"description": "Size of the attachment in bytes."
|
|
9212
|
-
},
|
|
9213
|
-
"sha256": {
|
|
9214
|
-
"type": "string",
|
|
9215
|
-
"pattern": "^[a-fA-F0-9]{64}$",
|
|
9216
|
-
"description": "SHA-256 hash of the attachment content (hex-encoded). Use this to verify attachment integrity after download."
|
|
9217
|
-
},
|
|
9218
|
-
"part_index": {
|
|
9219
|
-
"type": "integer",
|
|
9220
|
-
"minimum": 0,
|
|
9221
|
-
"description": "Zero-based index of this part in the MIME structure."
|
|
9222
|
-
},
|
|
9223
|
-
"tar_path": {
|
|
9224
|
-
"type": "string",
|
|
9225
|
-
"description": "Path to this attachment within the downloaded tar.gz archive."
|
|
9226
|
-
}
|
|
9227
|
-
},
|
|
9228
|
-
"required": [
|
|
9229
|
-
"filename",
|
|
9230
|
-
"content_type",
|
|
9231
|
-
"size_bytes",
|
|
9232
|
-
"sha256",
|
|
9233
|
-
"part_index",
|
|
9234
|
-
"tar_path"
|
|
9235
|
-
],
|
|
9236
|
-
"description": "Metadata for an email attachment.\n\nAttachment content is not included directly in the webhook payload. Use the `attachments_download_url` from {@link ParsedDataComplete } to download all attachments as a tar.gz archive."
|
|
9237
|
-
},
|
|
9238
|
-
"ParsedDataFailed": {
|
|
9239
|
-
"type": "object",
|
|
9240
|
-
"properties": {
|
|
9241
|
-
"status": {
|
|
9242
|
-
"type": "string",
|
|
9243
|
-
"const": "failed",
|
|
9244
|
-
"description": "Discriminant indicating parsing failed."
|
|
9245
|
-
},
|
|
9246
|
-
"error": {
|
|
9247
|
-
"$ref": "#/definitions/ParsedError",
|
|
9248
|
-
"description": "Details about why parsing failed."
|
|
9249
|
-
},
|
|
9250
|
-
"body_text": {
|
|
9251
|
-
"type": "null",
|
|
9252
|
-
"description": "Always null when parsing fails."
|
|
9253
|
-
},
|
|
9254
|
-
"body_html": {
|
|
9255
|
-
"type": "null",
|
|
9256
|
-
"description": "Always null when parsing fails."
|
|
9257
|
-
},
|
|
9258
|
-
"reply_to": {
|
|
9259
|
-
"type": "null",
|
|
9260
|
-
"description": "Always null when parsing fails."
|
|
9261
|
-
},
|
|
9262
|
-
"cc": {
|
|
9263
|
-
"type": "null",
|
|
9264
|
-
"description": "Always null when parsing fails."
|
|
9265
|
-
},
|
|
9266
|
-
"bcc": {
|
|
9267
|
-
"type": "null",
|
|
9268
|
-
"description": "Always null when parsing fails."
|
|
9269
|
-
},
|
|
9270
|
-
"to_addresses": {
|
|
9271
|
-
"type": "null",
|
|
9272
|
-
"description": "Always null when parsing fails."
|
|
9273
|
-
},
|
|
9274
|
-
"in_reply_to": {
|
|
9275
|
-
"type": "null",
|
|
9276
|
-
"description": "Always null when parsing fails."
|
|
9277
|
-
},
|
|
9278
|
-
"references": {
|
|
9279
|
-
"type": "null",
|
|
9280
|
-
"description": "Always null when parsing fails."
|
|
9281
|
-
},
|
|
9282
|
-
"attachments": {
|
|
9283
|
-
"type": "array",
|
|
9284
|
-
"items": { "$ref": "#/definitions/WebhookAttachment" },
|
|
9285
|
-
"description": "May contain partial attachment metadata even when parsing failed. Useful for debugging or recovering partial data."
|
|
9286
|
-
},
|
|
9287
|
-
"attachments_download_url": {
|
|
9288
|
-
"type": "null",
|
|
9289
|
-
"description": "Always null when parsing fails."
|
|
9290
|
-
}
|
|
9291
|
-
},
|
|
9292
|
-
"required": [
|
|
9293
|
-
"status",
|
|
9294
|
-
"error",
|
|
9295
|
-
"body_text",
|
|
9296
|
-
"body_html",
|
|
9297
|
-
"reply_to",
|
|
9298
|
-
"cc",
|
|
9299
|
-
"bcc",
|
|
9300
|
-
"to_addresses",
|
|
9301
|
-
"in_reply_to",
|
|
9302
|
-
"references",
|
|
9303
|
-
"attachments",
|
|
9304
|
-
"attachments_download_url"
|
|
9305
|
-
],
|
|
9306
|
-
"description": "Parsed email content when parsing failed.\n\nUse the discriminant `status: \"failed\"` to narrow from {@link ParsedData } ."
|
|
9307
|
-
},
|
|
9308
|
-
"ParsedError": {
|
|
9309
|
-
"type": "object",
|
|
9310
|
-
"properties": {
|
|
9311
|
-
"code": {
|
|
9312
|
-
"type": "string",
|
|
9313
|
-
"enum": ["PARSE_FAILED", "ATTACHMENT_EXTRACTION_FAILED"],
|
|
9314
|
-
"description": "Error code indicating the type of failure.\n- `PARSE_FAILED`: The email could not be parsed (e.g., malformed MIME)\n- `ATTACHMENT_EXTRACTION_FAILED`: Email parsed but attachments couldn't be extracted"
|
|
9315
|
-
},
|
|
9316
|
-
"message": {
|
|
9317
|
-
"type": "string",
|
|
9318
|
-
"description": "Human-readable error message describing what went wrong."
|
|
9319
|
-
},
|
|
9320
|
-
"retryable": {
|
|
9321
|
-
"type": "boolean",
|
|
9322
|
-
"description": "Whether retrying might succeed. If true, the error was transient (e.g., timeout). If false, the email itself is problematic."
|
|
9323
|
-
}
|
|
9324
|
-
},
|
|
9325
|
-
"required": [
|
|
9326
|
-
"code",
|
|
9327
|
-
"message",
|
|
9328
|
-
"retryable"
|
|
9329
|
-
],
|
|
9330
|
-
"description": "Error details when email parsing fails."
|
|
9331
|
-
},
|
|
9332
|
-
"EmailAnalysis": {
|
|
9333
|
-
"type": "object",
|
|
9334
|
-
"properties": {
|
|
9335
|
-
"spamassassin": {
|
|
9336
|
-
"type": "object",
|
|
9337
|
-
"properties": { "score": {
|
|
9338
|
-
"type": "number",
|
|
9339
|
-
"description": "Overall spam score (sum of all rule scores). Higher scores indicate higher likelihood of spam. Unbounded - can be negative (ham) or very high (spam)."
|
|
9340
|
-
} },
|
|
9341
|
-
"required": ["score"],
|
|
9342
|
-
"description": "SpamAssassin analysis results.\n\nOptional. Present when the email was processed by a SpamAssassin-equipped pipeline (always present in Primitive's managed service). When absent, spam scoring was not performed on this email."
|
|
9343
|
-
},
|
|
9344
|
-
"forward": {
|
|
9345
|
-
"$ref": "#/definitions/ForwardAnalysis",
|
|
9346
|
-
"description": "Forward detection and analysis results.\n\nOptional. In Primitive's managed service this object is included only when forwarded content is detected; when absent, no forward was detected or forward detection was not performed."
|
|
9347
|
-
},
|
|
9348
|
-
"bounce": {
|
|
9349
|
-
"$ref": "#/definitions/BounceAnalysis",
|
|
9350
|
-
"description": "Bounce (delivery status notification) analysis.\n\nPresent on `email.bounced` events: the parsed DSN reporting that a message you sent could not be delivered. Absent on all other event types."
|
|
9351
|
-
},
|
|
9352
|
-
"tls_report": {
|
|
9353
|
-
"$ref": "#/definitions/TlsReportAnalysis",
|
|
9354
|
-
"description": "SMTP TLS report analysis (RFC 8460).\n\nPresent on `email.tls_report` events: a remote MTA's report of TLS negotiation results for mail sent to your domain. Absent on all other event types."
|
|
9355
|
-
},
|
|
9356
|
-
"dmarc_report": {
|
|
9357
|
-
"$ref": "#/definitions/DmarcReportAnalysis",
|
|
9358
|
-
"description": "DMARC aggregate report analysis (RFC 7489).\n\nPresent on `email.dmarc_report` events: a receiver's periodic aggregate report of DMARC authentication results for your domain. Absent on all other event types."
|
|
9359
|
-
}
|
|
9360
|
-
},
|
|
9361
|
-
"description": "Email analysis and classification results.\n\nAll properties in this object are optional. Which fields are present depends on the analysis pipeline processing the email. Primitive's managed service populates all fields. Self-hosted or third-party deployments may include some, all, or none of these fields depending on their pipeline configuration.\n\nWhen a field is absent, it means that particular analysis was not performed, not that the analysis produced no results. For example, a missing `spamassassin` field means SpamAssassin was not run, not that the email scored 0.\n\nThese fields may be omitted from the payload entirely but must not be set to null."
|
|
9362
|
-
},
|
|
9363
|
-
"BounceAnalysis": {
|
|
9364
|
-
"type": "object",
|
|
9365
|
-
"properties": {
|
|
9366
|
-
"is_bounce": {
|
|
9367
|
-
"type": "boolean",
|
|
9368
|
-
"const": true,
|
|
9369
|
-
"description": "Always `true` on a parsed bounce."
|
|
9370
|
-
},
|
|
9371
|
-
"kind": {
|
|
9372
|
-
"type": "string",
|
|
9373
|
-
"const": "dsn",
|
|
9374
|
-
"description": "The machine-mail kind. Always `dsn` for a bounce."
|
|
9375
|
-
},
|
|
9376
|
-
"type": {
|
|
9377
|
-
"type": "string",
|
|
9378
|
-
"enum": [
|
|
9379
|
-
"permanent",
|
|
9380
|
-
"transient",
|
|
9381
|
-
"undetermined"
|
|
9382
|
-
],
|
|
9383
|
-
"description": "Whether the failure is permanent (hard bounce), transient (soft bounce, may be retried), or undetermined."
|
|
9384
|
-
},
|
|
9385
|
-
"category": {
|
|
9386
|
-
"type": "string",
|
|
9387
|
-
"enum": [
|
|
9388
|
-
"mailbox_does_not_exist",
|
|
9389
|
-
"domain_does_not_exist",
|
|
9390
|
-
"domain_not_accepting_mail",
|
|
9391
|
-
"mailbox_full",
|
|
9392
|
-
"mailbox_inactive",
|
|
9393
|
-
"message_too_large",
|
|
9394
|
-
"content_rejected",
|
|
9395
|
-
"policy_blocked",
|
|
9396
|
-
"auth_failure",
|
|
9397
|
-
"relay_denied",
|
|
9398
|
-
"rate_limited",
|
|
9399
|
-
"network_error",
|
|
9400
|
-
"recipient_moved",
|
|
9401
|
-
"expired",
|
|
9402
|
-
"undetermined"
|
|
9403
|
-
],
|
|
9404
|
-
"description": "Best-effort reason category for the failure."
|
|
9405
|
-
},
|
|
9406
|
-
"classified_by": {
|
|
9407
|
-
"type": "string",
|
|
9408
|
-
"enum": [
|
|
9409
|
-
"status_code",
|
|
9410
|
-
"smtp_code",
|
|
9411
|
-
"pattern",
|
|
9412
|
-
"provider",
|
|
9413
|
-
"none"
|
|
9414
|
-
],
|
|
9415
|
-
"description": "Which signal produced `category` (for transparency and debugging)."
|
|
9416
|
-
},
|
|
9417
|
-
"failed_recipient": {
|
|
9418
|
-
"type": ["string", "null"],
|
|
9419
|
-
"description": "The recipient address that failed, if the report identifies one."
|
|
9420
|
-
},
|
|
9421
|
-
"smtp_code": {
|
|
9422
|
-
"type": ["integer", "null"],
|
|
9423
|
-
"description": "SMTP reply code (e.g. `550`), if reported."
|
|
9424
|
-
},
|
|
9425
|
-
"status_code": {
|
|
9426
|
-
"type": ["string", "null"],
|
|
9427
|
-
"description": "Enhanced mail system status code (RFC 3463, e.g. `5.1.1`), if reported."
|
|
9428
|
-
},
|
|
9429
|
-
"diagnostic_code": {
|
|
9430
|
-
"type": ["string", "null"],
|
|
9431
|
-
"description": "Raw diagnostic text from the reporting MTA, if present."
|
|
9432
|
-
},
|
|
9433
|
-
"reported_by_mta": {
|
|
9434
|
-
"type": ["string", "null"],
|
|
9435
|
-
"description": "The MTA that generated the report, if identifiable."
|
|
9436
|
-
},
|
|
9437
|
-
"original_message_id": {
|
|
9438
|
-
"type": ["string", "null"],
|
|
9439
|
-
"description": "Message-ID of the original message that bounced, if recoverable. Match this against the `message_id` of a message you sent to correlate the bounce."
|
|
9440
|
-
},
|
|
9441
|
-
"reasons": {
|
|
9442
|
-
"type": "array",
|
|
9443
|
-
"items": { "type": "string" },
|
|
9444
|
-
"description": "Human-readable reason strings extracted from the report."
|
|
9445
|
-
}
|
|
9446
|
-
},
|
|
9447
|
-
"required": [
|
|
9448
|
-
"is_bounce",
|
|
9449
|
-
"kind",
|
|
9450
|
-
"type",
|
|
9451
|
-
"category",
|
|
9452
|
-
"classified_by",
|
|
9453
|
-
"failed_recipient",
|
|
9454
|
-
"smtp_code",
|
|
9455
|
-
"status_code",
|
|
9456
|
-
"diagnostic_code",
|
|
9457
|
-
"reported_by_mta",
|
|
9458
|
-
"original_message_id",
|
|
9459
|
-
"reasons"
|
|
9460
|
-
],
|
|
9461
|
-
"description": "Parsed delivery status notification (bounce). Present as `email.analysis.bounce` on `email.bounced` events."
|
|
9462
|
-
},
|
|
9463
|
-
"TlsReportFailure": {
|
|
9464
|
-
"type": "object",
|
|
9465
|
-
"properties": {
|
|
9466
|
-
"result_type": {
|
|
9467
|
-
"type": ["string", "null"],
|
|
9468
|
-
"description": "Failure result type (e.g. `certificate-expired`, `starttls-not-supported`)."
|
|
9469
|
-
},
|
|
9470
|
-
"count": {
|
|
9471
|
-
"type": "integer",
|
|
9472
|
-
"description": "Number of sessions that hit this failure."
|
|
9473
|
-
},
|
|
9474
|
-
"sending_mta_ip": { "type": ["string", "null"] },
|
|
9475
|
-
"receiving_mx_hostname": { "type": ["string", "null"] }
|
|
9476
|
-
},
|
|
9477
|
-
"required": [
|
|
9478
|
-
"result_type",
|
|
9479
|
-
"count",
|
|
9480
|
-
"sending_mta_ip",
|
|
9481
|
-
"receiving_mx_hostname"
|
|
9482
|
-
]
|
|
9483
|
-
},
|
|
9484
|
-
"TlsReportPolicy": {
|
|
9485
|
-
"type": "object",
|
|
9486
|
-
"properties": {
|
|
9487
|
-
"policy_domain": { "type": ["string", "null"] },
|
|
9488
|
-
"policy_type": {
|
|
9489
|
-
"type": ["string", "null"],
|
|
9490
|
-
"description": "Policy type the sessions were evaluated against (e.g. `sts`, `tlsa`, `no-policy-found`)."
|
|
9491
|
-
},
|
|
9492
|
-
"successful_sessions": { "type": "integer" },
|
|
9493
|
-
"failed_sessions": { "type": "integer" },
|
|
9494
|
-
"failures": {
|
|
9495
|
-
"type": "array",
|
|
9496
|
-
"items": { "$ref": "#/definitions/TlsReportFailure" }
|
|
9497
|
-
}
|
|
9498
|
-
},
|
|
9499
|
-
"required": [
|
|
9500
|
-
"policy_domain",
|
|
9501
|
-
"policy_type",
|
|
9502
|
-
"successful_sessions",
|
|
9503
|
-
"failed_sessions",
|
|
9504
|
-
"failures"
|
|
9505
|
-
]
|
|
9506
|
-
},
|
|
9507
|
-
"TlsReportAnalysis": {
|
|
9508
|
-
"type": "object",
|
|
9509
|
-
"properties": {
|
|
9510
|
-
"kind": {
|
|
9511
|
-
"type": "string",
|
|
9512
|
-
"const": "tls_report"
|
|
9513
|
-
},
|
|
9514
|
-
"organization": {
|
|
9515
|
-
"type": ["string", "null"],
|
|
9516
|
-
"description": "Reporting organization name."
|
|
9517
|
-
},
|
|
9518
|
-
"report_id": { "type": ["string", "null"] },
|
|
9519
|
-
"contact": {
|
|
9520
|
-
"type": ["string", "null"],
|
|
9521
|
-
"description": "Reporter contact, if provided."
|
|
9522
|
-
},
|
|
9523
|
-
"date_range": {
|
|
9524
|
-
"type": "object",
|
|
9525
|
-
"properties": {
|
|
9526
|
-
"start": {
|
|
9527
|
-
"type": ["string", "null"],
|
|
9528
|
-
"description": "ISO 8601 start of the reporting window."
|
|
9529
|
-
},
|
|
9530
|
-
"end": {
|
|
9531
|
-
"type": ["string", "null"],
|
|
9532
|
-
"description": "ISO 8601 end of the reporting window."
|
|
9533
|
-
}
|
|
9534
|
-
},
|
|
9535
|
-
"required": ["start", "end"]
|
|
9536
|
-
},
|
|
9537
|
-
"total_successful_sessions": { "type": "integer" },
|
|
9538
|
-
"total_failed_sessions": { "type": "integer" },
|
|
9539
|
-
"policies": {
|
|
9540
|
-
"type": "array",
|
|
9541
|
-
"items": { "$ref": "#/definitions/TlsReportPolicy" }
|
|
9542
|
-
}
|
|
9543
|
-
},
|
|
9544
|
-
"required": [
|
|
9545
|
-
"kind",
|
|
9546
|
-
"organization",
|
|
9547
|
-
"report_id",
|
|
9548
|
-
"contact",
|
|
9549
|
-
"date_range",
|
|
9550
|
-
"total_successful_sessions",
|
|
9551
|
-
"total_failed_sessions",
|
|
9552
|
-
"policies"
|
|
9553
|
-
],
|
|
9554
|
-
"description": "Parsed SMTP TLS report (RFC 8460). Present as `email.analysis.tls_report` on `email.tls_report` events."
|
|
9555
|
-
},
|
|
9556
|
-
"DmarcRecord": {
|
|
9557
|
-
"type": "object",
|
|
9558
|
-
"properties": {
|
|
9559
|
-
"source_ip": { "type": ["string", "null"] },
|
|
9560
|
-
"count": { "type": "integer" },
|
|
9561
|
-
"disposition": {
|
|
9562
|
-
"type": ["string", "null"],
|
|
9563
|
-
"description": "Disposition applied by the receiver: `none`, `quarantine`, or `reject`."
|
|
9564
|
-
},
|
|
9565
|
-
"dkim": {
|
|
9566
|
-
"type": ["string", "null"],
|
|
9567
|
-
"description": "DKIM alignment result: `pass` or `fail`."
|
|
9568
|
-
},
|
|
9569
|
-
"spf": {
|
|
9570
|
-
"type": ["string", "null"],
|
|
9571
|
-
"description": "SPF alignment result: `pass` or `fail`."
|
|
9572
|
-
},
|
|
9573
|
-
"header_from": { "type": ["string", "null"] }
|
|
9574
|
-
},
|
|
9575
|
-
"required": [
|
|
9576
|
-
"source_ip",
|
|
9577
|
-
"count",
|
|
9578
|
-
"disposition",
|
|
9579
|
-
"dkim",
|
|
9580
|
-
"spf",
|
|
9581
|
-
"header_from"
|
|
9582
|
-
]
|
|
9583
|
-
},
|
|
9584
|
-
"DmarcReportAnalysis": {
|
|
9585
|
-
"type": "object",
|
|
9586
|
-
"properties": {
|
|
9587
|
-
"kind": {
|
|
9588
|
-
"type": "string",
|
|
9589
|
-
"const": "dmarc_report"
|
|
9590
|
-
},
|
|
9591
|
-
"organization": { "type": ["string", "null"] },
|
|
9592
|
-
"report_id": { "type": ["string", "null"] },
|
|
9593
|
-
"date_range": {
|
|
9594
|
-
"type": "object",
|
|
9595
|
-
"properties": {
|
|
9596
|
-
"start": {
|
|
9597
|
-
"type": ["string", "null"],
|
|
9598
|
-
"description": "ISO 8601 start of the reporting window."
|
|
9599
|
-
},
|
|
9600
|
-
"end": {
|
|
9601
|
-
"type": ["string", "null"],
|
|
9602
|
-
"description": "ISO 8601 end of the reporting window."
|
|
9603
|
-
}
|
|
9604
|
-
},
|
|
9605
|
-
"required": ["start", "end"]
|
|
9606
|
-
},
|
|
9607
|
-
"policy_published": {
|
|
9608
|
-
"type": "object",
|
|
9609
|
-
"properties": {
|
|
9610
|
-
"domain": { "type": ["string", "null"] },
|
|
9611
|
-
"p": {
|
|
9612
|
-
"type": ["string", "null"],
|
|
9613
|
-
"description": "Published domain policy: `none`, `quarantine`, or `reject`."
|
|
9614
|
-
},
|
|
9615
|
-
"sp": {
|
|
9616
|
-
"type": ["string", "null"],
|
|
9617
|
-
"description": "Published subdomain policy."
|
|
9618
|
-
},
|
|
9619
|
-
"pct": {
|
|
9620
|
-
"type": ["integer", "null"],
|
|
9621
|
-
"description": "Percentage of messages the policy is applied to."
|
|
9622
|
-
},
|
|
9623
|
-
"adkim": {
|
|
9624
|
-
"type": ["string", "null"],
|
|
9625
|
-
"description": "DKIM alignment mode: `r` (relaxed) or `s` (strict)."
|
|
9626
|
-
},
|
|
9627
|
-
"aspf": {
|
|
9628
|
-
"type": ["string", "null"],
|
|
9629
|
-
"description": "SPF alignment mode: `r` (relaxed) or `s` (strict)."
|
|
9630
|
-
}
|
|
9631
|
-
},
|
|
9632
|
-
"required": [
|
|
9633
|
-
"domain",
|
|
9634
|
-
"p",
|
|
9635
|
-
"sp",
|
|
9636
|
-
"pct",
|
|
9637
|
-
"adkim",
|
|
9638
|
-
"aspf"
|
|
9639
|
-
]
|
|
9640
|
-
},
|
|
9641
|
-
"total_count": {
|
|
9642
|
-
"type": "integer",
|
|
9643
|
-
"description": "Total messages covered by the report."
|
|
9644
|
-
},
|
|
9645
|
-
"dkim_pass_count": { "type": "integer" },
|
|
9646
|
-
"spf_pass_count": { "type": "integer" },
|
|
9647
|
-
"records": {
|
|
9648
|
-
"type": "array",
|
|
9649
|
-
"items": { "$ref": "#/definitions/DmarcRecord" }
|
|
9650
|
-
}
|
|
9651
|
-
},
|
|
9652
|
-
"required": [
|
|
9653
|
-
"kind",
|
|
9654
|
-
"organization",
|
|
9655
|
-
"report_id",
|
|
9656
|
-
"date_range",
|
|
9657
|
-
"policy_published",
|
|
9658
|
-
"total_count",
|
|
9659
|
-
"dkim_pass_count",
|
|
9660
|
-
"spf_pass_count",
|
|
9661
|
-
"records"
|
|
9662
|
-
],
|
|
9663
|
-
"description": "Parsed DMARC aggregate report (RFC 7489). Present as `email.analysis.dmarc_report` on `email.dmarc_report` events."
|
|
9664
|
-
},
|
|
9665
|
-
"ForwardAnalysis": {
|
|
9666
|
-
"type": "object",
|
|
9667
|
-
"properties": {
|
|
9668
|
-
"detected": {
|
|
9669
|
-
"type": "boolean",
|
|
9670
|
-
"description": "Whether any forwards were detected in the email."
|
|
9671
|
-
},
|
|
9672
|
-
"results": {
|
|
9673
|
-
"type": "array",
|
|
9674
|
-
"items": { "$ref": "#/definitions/ForwardResult" },
|
|
9675
|
-
"description": "Analysis results for each detected forward."
|
|
9676
|
-
},
|
|
9677
|
-
"attachments_found": {
|
|
9678
|
-
"type": "integer",
|
|
9679
|
-
"minimum": 0,
|
|
9680
|
-
"description": "Total number of .eml attachments found."
|
|
9681
|
-
},
|
|
9682
|
-
"attachments_analyzed": {
|
|
9683
|
-
"type": "integer",
|
|
9684
|
-
"minimum": 0,
|
|
9685
|
-
"description": "Number of .eml attachments that were analyzed."
|
|
9686
|
-
},
|
|
9687
|
-
"attachments_limit": {
|
|
9688
|
-
"type": ["integer", "null"],
|
|
9689
|
-
"minimum": 1,
|
|
9690
|
-
"description": "Maximum number of attachments that will be analyzed, or null if unlimited."
|
|
9691
|
-
}
|
|
9692
|
-
},
|
|
9693
|
-
"required": [
|
|
9694
|
-
"detected",
|
|
9695
|
-
"results",
|
|
9696
|
-
"attachments_found",
|
|
9697
|
-
"attachments_analyzed",
|
|
9698
|
-
"attachments_limit"
|
|
9699
|
-
],
|
|
9700
|
-
"description": "Forward detection and analysis results."
|
|
9701
|
-
},
|
|
9702
|
-
"ForwardResult": {
|
|
9703
|
-
"anyOf": [
|
|
9704
|
-
{ "$ref": "#/definitions/ForwardResultInline" },
|
|
9705
|
-
{ "$ref": "#/definitions/ForwardResultAttachmentAnalyzed" },
|
|
9706
|
-
{ "$ref": "#/definitions/ForwardResultAttachmentSkipped" }
|
|
9707
|
-
],
|
|
9708
|
-
"description": "Result for a single forwarded email detected in the message.\n\nUse the `type` and `analyzed` fields to narrow the type:\n- `type: 'inline'` - Inline forward, always analyzed\n- `type: 'attachment'` + `analyzed: true` - Analyzed attachment\n- `type: 'attachment'` + `analyzed: false` - Skipped attachment"
|
|
9709
|
-
},
|
|
9710
|
-
"ForwardResultInline": {
|
|
9711
|
-
"type": "object",
|
|
9712
|
-
"properties": {
|
|
9713
|
-
"type": {
|
|
9714
|
-
"type": "string",
|
|
9715
|
-
"const": "inline"
|
|
9716
|
-
},
|
|
9717
|
-
"original_sender": {
|
|
9718
|
-
"anyOf": [{ "$ref": "#/definitions/ForwardOriginalSender" }, { "type": "null" }],
|
|
9719
|
-
"description": "Original sender of the forwarded email, if extractable."
|
|
9720
|
-
},
|
|
9721
|
-
"verification": {
|
|
9722
|
-
"$ref": "#/definitions/ForwardVerification",
|
|
9723
|
-
"description": "Verification result for the forwarded email."
|
|
9724
|
-
},
|
|
9725
|
-
"summary": {
|
|
9726
|
-
"type": "string",
|
|
9727
|
-
"description": "Human-readable summary of the forward analysis."
|
|
9728
|
-
}
|
|
9729
|
-
},
|
|
9730
|
-
"required": [
|
|
9731
|
-
"type",
|
|
9732
|
-
"original_sender",
|
|
9733
|
-
"verification",
|
|
9734
|
-
"summary"
|
|
9735
|
-
],
|
|
9736
|
-
"description": "Result for an inline forward that was detected and analyzed. Inline forwards are always analyzed when forward detection is enabled."
|
|
9737
|
-
},
|
|
9738
|
-
"ForwardOriginalSender": {
|
|
9739
|
-
"type": "object",
|
|
9740
|
-
"properties": {
|
|
9741
|
-
"email": {
|
|
9742
|
-
"type": "string",
|
|
9743
|
-
"description": "Email address of the original sender."
|
|
9744
|
-
},
|
|
9745
|
-
"domain": {
|
|
9746
|
-
"type": "string",
|
|
9747
|
-
"description": "Domain of the original sender."
|
|
9748
|
-
}
|
|
9749
|
-
},
|
|
9750
|
-
"required": ["email", "domain"],
|
|
9751
|
-
"description": "Original sender information extracted from the forwarded email."
|
|
9752
|
-
},
|
|
9753
|
-
"ForwardVerification": {
|
|
9754
|
-
"type": "object",
|
|
9755
|
-
"properties": {
|
|
9756
|
-
"verdict": {
|
|
9757
|
-
"$ref": "#/definitions/ForwardVerdict",
|
|
9758
|
-
"description": "Overall verdict on whether the forward is authentic."
|
|
9759
|
-
},
|
|
9760
|
-
"confidence": {
|
|
9761
|
-
"$ref": "#/definitions/AuthConfidence",
|
|
9762
|
-
"description": "Confidence level for this verdict."
|
|
9763
|
-
},
|
|
9764
|
-
"dkim_verified": {
|
|
9765
|
-
"type": "boolean",
|
|
9766
|
-
"description": "Whether a valid DKIM signature was found that verifies the original sender."
|
|
9767
|
-
},
|
|
9768
|
-
"dkim_domain": {
|
|
9769
|
-
"type": ["string", "null"],
|
|
9770
|
-
"description": "Domain of the DKIM signature that verified the forward, if any."
|
|
9771
|
-
},
|
|
9772
|
-
"dmarc_policy": {
|
|
9773
|
-
"$ref": "#/definitions/DmarcPolicy",
|
|
9774
|
-
"description": "DMARC policy of the original sender's domain."
|
|
9775
|
-
}
|
|
9776
|
-
},
|
|
9777
|
-
"required": [
|
|
9778
|
-
"verdict",
|
|
9779
|
-
"confidence",
|
|
9780
|
-
"dkim_verified",
|
|
9781
|
-
"dkim_domain",
|
|
9782
|
-
"dmarc_policy"
|
|
9783
|
-
],
|
|
9784
|
-
"description": "Verification result for a forwarded email."
|
|
9785
|
-
},
|
|
9786
|
-
"ForwardVerdict": {
|
|
9787
|
-
"type": "string",
|
|
9788
|
-
"enum": ["legit", "unknown"],
|
|
9789
|
-
"description": "Verdict for forwarded email verification.\n\n- `legit`: DKIM signature verified the original sender\n- `unknown`: Could not verify the forwarded email's authenticity"
|
|
9790
|
-
},
|
|
9791
|
-
"AuthConfidence": {
|
|
9792
|
-
"type": "string",
|
|
9793
|
-
"enum": [
|
|
9794
|
-
"high",
|
|
9795
|
-
"medium",
|
|
9796
|
-
"low"
|
|
9797
|
-
],
|
|
9798
|
-
"description": "Confidence level for the authentication verdict.\n\n- `high`: Strong cryptographic evidence (DKIM aligned + DMARC pass)\n- `medium`: Good evidence but with caveats (SPF-only alignment)\n- `low`: Weak evidence (missing authentication or unclear results)"
|
|
9799
|
-
},
|
|
9800
|
-
"DmarcPolicy": {
|
|
9801
|
-
"type": ["string", "null"],
|
|
9802
|
-
"enum": [
|
|
9803
|
-
"reject",
|
|
9804
|
-
"quarantine",
|
|
9805
|
-
"none",
|
|
9806
|
-
null
|
|
9807
|
-
],
|
|
9808
|
-
"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"
|
|
9809
|
-
},
|
|
9810
|
-
"ForwardResultAttachmentAnalyzed": {
|
|
9811
|
-
"type": "object",
|
|
9812
|
-
"properties": {
|
|
9813
|
-
"type": {
|
|
9814
|
-
"type": "string",
|
|
9815
|
-
"const": "attachment"
|
|
9816
|
-
},
|
|
9817
|
-
"attachment_tar_path": {
|
|
9818
|
-
"type": "string",
|
|
9819
|
-
"description": "Path to the attachment in the attachments tar archive."
|
|
9820
|
-
},
|
|
9821
|
-
"attachment_filename": {
|
|
9822
|
-
"type": ["string", "null"],
|
|
9823
|
-
"description": "Original filename of the attachment, if available."
|
|
9824
|
-
},
|
|
9825
|
-
"analyzed": {
|
|
9826
|
-
"type": "boolean",
|
|
9827
|
-
"const": true,
|
|
9828
|
-
"description": "Whether this attachment was analyzed."
|
|
9829
|
-
},
|
|
9830
|
-
"original_sender": {
|
|
9831
|
-
"anyOf": [{ "$ref": "#/definitions/ForwardOriginalSender" }, { "type": "null" }],
|
|
9832
|
-
"description": "Original sender of the forwarded email, if extractable."
|
|
9833
|
-
},
|
|
9834
|
-
"verification": {
|
|
9835
|
-
"$ref": "#/definitions/ForwardVerification",
|
|
9836
|
-
"description": "Verification result for the forwarded email."
|
|
9837
|
-
},
|
|
9838
|
-
"summary": {
|
|
9839
|
-
"type": "string",
|
|
9840
|
-
"description": "Human-readable summary of the forward analysis."
|
|
9841
|
-
}
|
|
9842
|
-
},
|
|
9843
|
-
"required": [
|
|
9844
|
-
"type",
|
|
9845
|
-
"attachment_tar_path",
|
|
9846
|
-
"attachment_filename",
|
|
9847
|
-
"analyzed",
|
|
9848
|
-
"original_sender",
|
|
9849
|
-
"verification",
|
|
9850
|
-
"summary"
|
|
9851
|
-
],
|
|
9852
|
-
"description": "Result for an attachment forward that was analyzed."
|
|
9853
|
-
},
|
|
9854
|
-
"ForwardResultAttachmentSkipped": {
|
|
9855
|
-
"type": "object",
|
|
9856
|
-
"properties": {
|
|
9857
|
-
"type": {
|
|
9858
|
-
"type": "string",
|
|
9859
|
-
"const": "attachment"
|
|
9860
|
-
},
|
|
9861
|
-
"attachment_tar_path": {
|
|
9862
|
-
"type": "string",
|
|
9863
|
-
"description": "Path to the attachment in the attachments tar archive."
|
|
9864
|
-
},
|
|
9865
|
-
"attachment_filename": {
|
|
9866
|
-
"type": ["string", "null"],
|
|
9867
|
-
"description": "Original filename of the attachment, if available."
|
|
9868
|
-
},
|
|
9869
|
-
"analyzed": {
|
|
9870
|
-
"type": "boolean",
|
|
9871
|
-
"const": false,
|
|
9872
|
-
"description": "Whether this attachment was analyzed."
|
|
9873
|
-
},
|
|
9874
|
-
"original_sender": {
|
|
9875
|
-
"type": "null",
|
|
9876
|
-
"description": "Always null when not analyzed."
|
|
9877
|
-
},
|
|
9878
|
-
"verification": {
|
|
9879
|
-
"type": "null",
|
|
9880
|
-
"description": "Always null when not analyzed."
|
|
9881
|
-
},
|
|
9882
|
-
"summary": {
|
|
9883
|
-
"type": "string",
|
|
9884
|
-
"description": "Human-readable summary explaining why analysis was skipped."
|
|
9885
|
-
}
|
|
9886
|
-
},
|
|
9887
|
-
"required": [
|
|
9888
|
-
"type",
|
|
9889
|
-
"attachment_tar_path",
|
|
9890
|
-
"attachment_filename",
|
|
9891
|
-
"analyzed",
|
|
9892
|
-
"original_sender",
|
|
9893
|
-
"verification",
|
|
9894
|
-
"summary"
|
|
9895
|
-
],
|
|
9896
|
-
"description": "Result for an attachment forward that was detected but not analyzed. This occurs when attachment analysis is disabled or the limit was reached."
|
|
9897
|
-
},
|
|
9898
|
-
"EmailAuth": {
|
|
9899
|
-
"type": "object",
|
|
9900
|
-
"properties": {
|
|
9901
|
-
"spf": {
|
|
9902
|
-
"$ref": "#/definitions/SpfResult",
|
|
9903
|
-
"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."
|
|
9904
|
-
},
|
|
9905
|
-
"dmarc": {
|
|
9906
|
-
"$ref": "#/definitions/DmarcResult",
|
|
9907
|
-
"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."
|
|
9908
|
-
},
|
|
9909
|
-
"dmarcPolicy": {
|
|
9910
|
-
"$ref": "#/definitions/DmarcPolicy",
|
|
9911
|
-
"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"
|
|
9912
|
-
},
|
|
9913
|
-
"dmarcFromDomain": {
|
|
9914
|
-
"type": ["string", "null"],
|
|
9915
|
-
"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."
|
|
9916
|
-
},
|
|
9917
|
-
"dmarcSpfAligned": {
|
|
9918
|
-
"type": "boolean",
|
|
9919
|
-
"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)."
|
|
9920
|
-
},
|
|
9921
|
-
"dmarcDkimAligned": {
|
|
9922
|
-
"type": "boolean",
|
|
9923
|
-
"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."
|
|
9924
|
-
},
|
|
9925
|
-
"dmarcSpfStrict": {
|
|
9926
|
-
"type": ["boolean", "null"],
|
|
9927
|
-
"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"
|
|
9928
|
-
},
|
|
9929
|
-
"dmarcDkimStrict": {
|
|
9930
|
-
"type": ["boolean", "null"],
|
|
9931
|
-
"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"
|
|
9932
|
-
},
|
|
9933
|
-
"dkimSignatures": {
|
|
9934
|
-
"type": "array",
|
|
9935
|
-
"items": { "$ref": "#/definitions/DkimSignature" },
|
|
9936
|
-
"description": "All DKIM signatures found in the email with their verification results.\n\nMay be empty if no DKIM signatures were present."
|
|
9937
|
-
}
|
|
9938
|
-
},
|
|
9939
|
-
"required": [
|
|
9940
|
-
"spf",
|
|
9941
|
-
"dmarc",
|
|
9942
|
-
"dmarcPolicy",
|
|
9943
|
-
"dmarcFromDomain",
|
|
9944
|
-
"dmarcSpfAligned",
|
|
9945
|
-
"dmarcDkimAligned",
|
|
9946
|
-
"dmarcSpfStrict",
|
|
9947
|
-
"dmarcDkimStrict",
|
|
9948
|
-
"dkimSignatures"
|
|
9949
|
-
],
|
|
9950
|
-
"description": "Email authentication results for SPF, DKIM, and DMARC.\n\nUse `validateEmailAuth()` to compute a verdict based on these results."
|
|
9951
|
-
},
|
|
9952
|
-
"SpfResult": {
|
|
9953
|
-
"type": "string",
|
|
9954
|
-
"enum": [
|
|
9955
|
-
"pass",
|
|
9956
|
-
"fail",
|
|
9957
|
-
"softfail",
|
|
9958
|
-
"neutral",
|
|
9959
|
-
"none",
|
|
9960
|
-
"temperror",
|
|
9961
|
-
"permerror"
|
|
9962
|
-
],
|
|
9963
|
-
"description": "SPF verification result."
|
|
9964
|
-
},
|
|
9965
|
-
"DmarcResult": {
|
|
9966
|
-
"type": "string",
|
|
9967
|
-
"enum": [
|
|
9968
|
-
"pass",
|
|
9969
|
-
"fail",
|
|
9970
|
-
"none",
|
|
9971
|
-
"temperror",
|
|
9972
|
-
"permerror"
|
|
9973
|
-
],
|
|
9974
|
-
"description": "DMARC verification result."
|
|
9975
|
-
},
|
|
9976
|
-
"DkimSignature": {
|
|
9977
|
-
"type": "object",
|
|
9978
|
-
"properties": {
|
|
9979
|
-
"domain": {
|
|
9980
|
-
"type": "string",
|
|
9981
|
-
"description": "The domain that signed this DKIM signature (d= tag). This may differ from the From: domain (that's what alignment checks)."
|
|
9982
|
-
},
|
|
9983
|
-
"selector": {
|
|
9984
|
-
"type": ["string", "null"],
|
|
9985
|
-
"description": "The DKIM selector used to locate the public key (s= tag). Combined with the domain to form the DNS lookup: `selector._domainkey.domain`\n\nOptional in self-hosted environments where the milter may not provide selector info."
|
|
9986
|
-
},
|
|
9987
|
-
"result": {
|
|
9988
|
-
"$ref": "#/definitions/DkimResult",
|
|
9989
|
-
"description": "Verification result for this specific signature."
|
|
9990
|
-
},
|
|
9991
|
-
"aligned": {
|
|
9992
|
-
"type": "boolean",
|
|
9993
|
-
"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)"
|
|
9994
|
-
},
|
|
9995
|
-
"keyBits": {
|
|
9996
|
-
"type": ["integer", "null"],
|
|
9997
|
-
"minimum": 1,
|
|
9998
|
-
"maximum": 16384,
|
|
9999
|
-
"description": "Key size in bits (e.g., 1024, 2048). Null if the key size couldn't be determined.\n\nOptional in self-hosted environments."
|
|
10000
|
-
},
|
|
10001
|
-
"algo": {
|
|
10002
|
-
"type": ["string", "null"],
|
|
10003
|
-
"description": "Signing algorithm (e.g., \"rsa-sha256\", \"ed25519-sha256\").\n\nOptional in self-hosted environments."
|
|
10004
|
-
}
|
|
10005
|
-
},
|
|
10006
|
-
"required": [
|
|
10007
|
-
"domain",
|
|
10008
|
-
"selector",
|
|
10009
|
-
"result",
|
|
10010
|
-
"aligned",
|
|
10011
|
-
"keyBits",
|
|
10012
|
-
"algo"
|
|
10013
|
-
],
|
|
10014
|
-
"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."
|
|
10015
|
-
},
|
|
10016
|
-
"DkimResult": {
|
|
10017
|
-
"type": "string",
|
|
10018
|
-
"enum": [
|
|
10019
|
-
"pass",
|
|
10020
|
-
"fail",
|
|
10021
|
-
"temperror",
|
|
10022
|
-
"permerror"
|
|
10023
|
-
],
|
|
10024
|
-
"description": "DKIM signature verification result for a single signature."
|
|
10025
|
-
}
|
|
10026
|
-
}
|
|
10027
|
-
};
|
|
10028
|
-
//#endregion
|
|
10029
|
-
//#region src/webhook/version.ts
|
|
10030
|
-
/**
|
|
10031
|
-
* Webhook API Version
|
|
10032
|
-
*
|
|
10033
|
-
* Single source of truth for the webhook version.
|
|
10034
|
-
* Update this file when bumping the API version.
|
|
10035
|
-
*/
|
|
10036
|
-
/**
|
|
10037
|
-
* The current webhook API version this SDK is built for.
|
|
10038
|
-
* Webhooks may be sent with different versions - the SDK accepts any valid
|
|
10039
|
-
* YYYY-MM-DD formatted version string. Compare against this constant if you
|
|
10040
|
-
* need to handle version-specific behavior.
|
|
10041
|
-
*/
|
|
10042
|
-
const WEBHOOK_VERSION = "2025-12-14";
|
|
10043
|
-
//#endregion
|
|
10044
|
-
//#region src/webhook/parsing.ts
|
|
10045
|
-
/**
|
|
10046
|
-
* JSON parsing utilities with helpful error messages.
|
|
10047
|
-
* @internal
|
|
10048
|
-
*/
|
|
10049
|
-
/**
|
|
10050
|
-
* Parse a raw body string/Buffer into JSON with helpful error messages.
|
|
10051
|
-
*
|
|
10052
|
-
* Handles:
|
|
10053
|
-
* - Empty/whitespace bodies
|
|
10054
|
-
* - BOM (byte order mark) prefix stripping
|
|
10055
|
-
* - Detailed JSON syntax error messages with position
|
|
10056
|
-
*
|
|
10057
|
-
* @param rawBody - The raw request body (string or Buffer)
|
|
10058
|
-
* @returns The parsed JSON value
|
|
10059
|
-
* @throws WebhookPayloadError with helpful message on failure
|
|
10060
|
-
* @internal
|
|
10061
|
-
*/
|
|
10062
|
-
function parseJsonBody(rawBody) {
|
|
10063
|
-
const bodyStr = typeof rawBody === "string" ? rawBody : bufferToString(rawBody, "request body");
|
|
10064
|
-
if (!bodyStr || bodyStr.trim() === "") throw new WebhookPayloadError("PAYLOAD_EMPTY_BODY", "Received empty request body", "The request body is empty. Check your web framework is correctly passing the request body.");
|
|
10065
|
-
try {
|
|
10066
|
-
const cleanBody = bodyStr.charCodeAt(0) === 65279 ? bodyStr.slice(1) : bodyStr;
|
|
10067
|
-
return JSON.parse(cleanBody);
|
|
10068
|
-
} catch (e) {
|
|
10069
|
-
const jsonError = e;
|
|
10070
|
-
const position = jsonError.message.match(/position\s*(\d+)/i)?.[1];
|
|
10071
|
-
throw new WebhookPayloadError("JSON_PARSE_FAILED", "Failed to parse webhook body as JSON", position ? `Invalid JSON at position ${position}. Check your web framework isn't truncating the request body.` : `Invalid JSON: ${jsonError.message}. Check the raw request body is valid JSON.`, jsonError);
|
|
10072
|
-
}
|
|
10073
|
-
}
|
|
10074
|
-
//#endregion
|
|
10075
|
-
//#region src/webhook/index.ts
|
|
10076
|
-
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
10077
|
-
/**
|
|
10078
|
-
* Parse a webhook payload, returning typed events for known types
|
|
10079
|
-
* and UnknownEvent for future event types.
|
|
8846
|
+
* A `legit` verdict means the email authenticated as its own From
|
|
8847
|
+
* domain, not as any particular domain you trust. For authorization
|
|
8848
|
+
* decisions, pair the verdict with a domain anchor via
|
|
8849
|
+
* {@link isTrustedSender} instead of checking the verdict alone.
|
|
10080
8850
|
*
|
|
10081
|
-
*
|
|
10082
|
-
*
|
|
10083
|
-
* handle or ignore.
|
|
10084
|
-
*
|
|
10085
|
-
* Known event types are validated against the canonical schema. Unknown
|
|
10086
|
-
* event types are returned as-is for forward compatibility.
|
|
10087
|
-
*
|
|
10088
|
-
* For most use cases, prefer `handleWebhook()` which also verifies the
|
|
10089
|
-
* signature before parsing the payload.
|
|
10090
|
-
*
|
|
10091
|
-
* @param input - The parsed JSON payload
|
|
10092
|
-
* @returns Typed event for known types, UnknownEvent for unknown types
|
|
10093
|
-
* @throws WebhookPayloadError if the input is not a valid webhook structure
|
|
10094
|
-
* @throws WebhookValidationError if a known event fails schema validation
|
|
8851
|
+
* @param auth - Email authentication results from the webhook
|
|
8852
|
+
* @returns Verdict, confidence level, and explanatory reasons
|
|
10095
8853
|
*
|
|
10096
8854
|
* @example
|
|
10097
8855
|
* ```typescript
|
|
10098
|
-
*
|
|
10099
|
-
*
|
|
10100
|
-
*
|
|
8856
|
+
* const result = validateEmailAuth({
|
|
8857
|
+
* spf: 'pass',
|
|
8858
|
+
* dmarc: 'pass',
|
|
8859
|
+
* dmarcPolicy: 'reject',
|
|
8860
|
+
* dmarcFromDomain: 'example.com',
|
|
8861
|
+
* dmarcSpfAligned: true,
|
|
8862
|
+
* dmarcDkimAligned: true,
|
|
8863
|
+
* dmarcSpfStrict: false,
|
|
8864
|
+
* dmarcDkimStrict: false,
|
|
8865
|
+
* dkimSignatures: [{
|
|
8866
|
+
* domain: 'example.com',
|
|
8867
|
+
* selector: 'default',
|
|
8868
|
+
* result: 'pass',
|
|
8869
|
+
* aligned: true,
|
|
8870
|
+
* keyBits: 2048,
|
|
8871
|
+
* algo: 'rsa-sha256',
|
|
8872
|
+
* }],
|
|
8873
|
+
* });
|
|
10101
8874
|
*
|
|
10102
|
-
*
|
|
10103
|
-
*
|
|
10104
|
-
*
|
|
10105
|
-
* } else {
|
|
10106
|
-
* // Handle or log unknown event types
|
|
10107
|
-
* console.log("Unknown event:", event.event);
|
|
10108
|
-
* }
|
|
8875
|
+
* // result.verdict === 'legit'
|
|
8876
|
+
* // result.confidence === 'high'
|
|
8877
|
+
* // result.reasons === ['DMARC passed with DKIM alignment']
|
|
10109
8878
|
* ```
|
|
10110
8879
|
*/
|
|
10111
|
-
function
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
if (
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
|
|
10121
|
-
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
|
|
10127
|
-
|
|
10128
|
-
|
|
10129
|
-
|
|
8880
|
+
function validateEmailAuth(auth) {
|
|
8881
|
+
const reasons = [];
|
|
8882
|
+
let verdict;
|
|
8883
|
+
let confidence;
|
|
8884
|
+
if (auth.dmarc === "temperror" || auth.dmarc === "permerror") return {
|
|
8885
|
+
verdict: "unknown",
|
|
8886
|
+
confidence: "low",
|
|
8887
|
+
reasons: [`DMARC verification error (${auth.dmarc})`, "Cannot determine email authenticity due to DNS or policy errors"]
|
|
8888
|
+
};
|
|
8889
|
+
if (auth.spf === "temperror" || auth.spf === "permerror") reasons.push(`SPF verification error (${auth.spf})`);
|
|
8890
|
+
const weakKeySignatures = auth.dkimSignatures.filter((sig) => sig.keyBits != null && sig.keyBits < MIN_SECURE_KEY_BITS);
|
|
8891
|
+
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`);
|
|
8892
|
+
if (auth.dmarc === "pass") {
|
|
8893
|
+
const alignedSigs = auth.dkimSignatures.filter((sig) => sig.result === "pass" && sig.aligned);
|
|
8894
|
+
if (auth.dmarcDkimAligned && alignedSigs.length > 0) {
|
|
8895
|
+
const domains = alignedSigs.map((sig) => sig.domain).join(", ");
|
|
8896
|
+
reasons.unshift(`DMARC passed with DKIM alignment (${domains})`);
|
|
8897
|
+
verdict = "legit";
|
|
8898
|
+
confidence = weakKeySignatures.length > 0 ? "medium" : "high";
|
|
8899
|
+
return {
|
|
8900
|
+
verdict,
|
|
8901
|
+
confidence,
|
|
8902
|
+
reasons
|
|
10130
8903
|
};
|
|
8904
|
+
}
|
|
8905
|
+
if (auth.dmarcSpfAligned && auth.spf === "pass") {
|
|
8906
|
+
reasons.unshift("DMARC passed with SPF alignment");
|
|
8907
|
+
reasons.push("No aligned DKIM signature (SPF can break through forwarding)");
|
|
10131
8908
|
return {
|
|
10132
|
-
|
|
10133
|
-
|
|
8909
|
+
verdict: "legit",
|
|
8910
|
+
confidence: "medium",
|
|
8911
|
+
reasons
|
|
10134
8912
|
};
|
|
10135
|
-
}
|
|
10136
|
-
}
|
|
10137
|
-
/**
|
|
10138
|
-
* The header that names the webhook event for ALL event families
|
|
10139
|
-
* (`email.*`, `payment.*`, `interaction.*`). It is the primary discriminator
|
|
10140
|
-
* the parser keys on, because the stored body is sent verbatim with no envelope.
|
|
10141
|
-
*/
|
|
10142
|
-
const WEBHOOK_EVENT_HEADER = "X-Webhook-Event";
|
|
10143
|
-
const SIGNATURE_HEADER_NAMES = ["primitive-signature", "mymx-signature"];
|
|
10144
|
-
/**
|
|
10145
|
-
* Read the `X-Webhook-Event` header value (case-insensitive). Returns null when
|
|
10146
|
-
* the header is absent.
|
|
10147
|
-
*/
|
|
10148
|
-
function getEventHeader(headers) {
|
|
10149
|
-
if (headers instanceof Headers) return headers.get("x-webhook-event");
|
|
10150
|
-
const obj = headers;
|
|
10151
|
-
const key = Object.keys(obj).find((k) => k.toLowerCase() === "x-webhook-event");
|
|
10152
|
-
if (!key) return null;
|
|
10153
|
-
const value = obj[key];
|
|
10154
|
-
if (Array.isArray(value)) return value[0] ?? null;
|
|
10155
|
-
return value ?? null;
|
|
10156
|
-
}
|
|
10157
|
-
const STANDARD_WEBHOOKS_HEADER_NAMES = [
|
|
10158
|
-
"webhook-signature",
|
|
10159
|
-
"webhook-id",
|
|
10160
|
-
"webhook-timestamp"
|
|
10161
|
-
];
|
|
10162
|
-
/**
|
|
10163
|
-
* Extract signature header from various header formats.
|
|
10164
|
-
* Checks for Primitive-Signature first, then falls back to the legacy
|
|
10165
|
-
* MyMX-Signature for backward compatibility with older servers.
|
|
10166
|
-
*/
|
|
10167
|
-
function getSignatureHeader(headers) {
|
|
10168
|
-
if (headers instanceof Headers) {
|
|
10169
|
-
for (const name of SIGNATURE_HEADER_NAMES) {
|
|
10170
|
-
const value = headers.get(name);
|
|
10171
|
-
if (value) return value;
|
|
10172
8913
|
}
|
|
10173
|
-
|
|
8914
|
+
reasons.unshift("DMARC passed");
|
|
8915
|
+
return {
|
|
8916
|
+
verdict: "legit",
|
|
8917
|
+
confidence: "medium",
|
|
8918
|
+
reasons
|
|
8919
|
+
};
|
|
10174
8920
|
}
|
|
10175
|
-
|
|
10176
|
-
|
|
10177
|
-
|
|
10178
|
-
|
|
10179
|
-
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
8921
|
+
if (auth.dmarc === "fail") {
|
|
8922
|
+
if (auth.dmarcPolicy === "reject") {
|
|
8923
|
+
reasons.unshift("DMARC failed and domain has reject policy");
|
|
8924
|
+
reasons.push("The sender's domain explicitly rejects emails that fail authentication");
|
|
8925
|
+
return {
|
|
8926
|
+
verdict: "suspicious",
|
|
8927
|
+
confidence: "high",
|
|
8928
|
+
reasons
|
|
8929
|
+
};
|
|
8930
|
+
}
|
|
8931
|
+
if (auth.dmarcPolicy === "quarantine") {
|
|
8932
|
+
reasons.unshift("DMARC failed and domain has quarantine policy");
|
|
8933
|
+
reasons.push("The sender's domain marks failing emails as suspicious");
|
|
8934
|
+
return {
|
|
8935
|
+
verdict: "suspicious",
|
|
8936
|
+
confidence: "high",
|
|
8937
|
+
reasons
|
|
8938
|
+
};
|
|
8939
|
+
}
|
|
8940
|
+
reasons.unshift("DMARC failed (domain is in monitoring mode)");
|
|
8941
|
+
if (auth.spf === "fail") {
|
|
8942
|
+
reasons.push("SPF failed - sending IP not authorized");
|
|
8943
|
+
return {
|
|
8944
|
+
verdict: "suspicious",
|
|
8945
|
+
confidence: "medium",
|
|
8946
|
+
reasons
|
|
8947
|
+
};
|
|
8948
|
+
}
|
|
8949
|
+
return {
|
|
8950
|
+
verdict: "suspicious",
|
|
8951
|
+
confidence: "low",
|
|
8952
|
+
reasons
|
|
8953
|
+
};
|
|
10183
8954
|
}
|
|
10184
|
-
|
|
10185
|
-
|
|
10186
|
-
|
|
10187
|
-
|
|
10188
|
-
|
|
10189
|
-
|
|
10190
|
-
|
|
10191
|
-
|
|
10192
|
-
|
|
10193
|
-
|
|
10194
|
-
|
|
10195
|
-
|
|
10196
|
-
|
|
10197
|
-
|
|
10198
|
-
|
|
10199
|
-
|
|
10200
|
-
|
|
10201
|
-
|
|
10202
|
-
|
|
10203
|
-
|
|
10204
|
-
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
|
|
10213
|
-
|
|
10214
|
-
|
|
10215
|
-
|
|
10216
|
-
|
|
10217
|
-
|
|
10218
|
-
|
|
10219
|
-
|
|
10220
|
-
|
|
8955
|
+
if (auth.dmarc === "none") {
|
|
8956
|
+
if (auth.spf === "fail") {
|
|
8957
|
+
reasons.push("No DMARC record for sender domain");
|
|
8958
|
+
reasons.push("SPF failed - sending IP not authorized");
|
|
8959
|
+
return {
|
|
8960
|
+
verdict: "suspicious",
|
|
8961
|
+
confidence: "medium",
|
|
8962
|
+
reasons
|
|
8963
|
+
};
|
|
8964
|
+
}
|
|
8965
|
+
const passingDkim = auth.dkimSignatures.filter((sig) => sig.result === "pass");
|
|
8966
|
+
if (passingDkim.length > 0) {
|
|
8967
|
+
const domains = passingDkim.map((sig) => sig.domain).join(", ");
|
|
8968
|
+
reasons.push("No DMARC record for sender domain");
|
|
8969
|
+
reasons.push(`DKIM verified for: ${domains}`);
|
|
8970
|
+
if (auth.spf === "pass") reasons.push("SPF passed");
|
|
8971
|
+
return {
|
|
8972
|
+
verdict: "unknown",
|
|
8973
|
+
confidence: "low",
|
|
8974
|
+
reasons
|
|
8975
|
+
};
|
|
8976
|
+
}
|
|
8977
|
+
if (auth.spf === "pass") {
|
|
8978
|
+
reasons.push("No DMARC record for sender domain");
|
|
8979
|
+
reasons.push("No DKIM signatures present");
|
|
8980
|
+
reasons.push("SPF passed (but SPF alone is weak authentication)");
|
|
8981
|
+
return {
|
|
8982
|
+
verdict: "unknown",
|
|
8983
|
+
confidence: "low",
|
|
8984
|
+
reasons
|
|
8985
|
+
};
|
|
8986
|
+
}
|
|
8987
|
+
reasons.push("No DMARC record for sender domain");
|
|
8988
|
+
reasons.push("No valid authentication found");
|
|
8989
|
+
return {
|
|
8990
|
+
verdict: "unknown",
|
|
8991
|
+
confidence: "low",
|
|
8992
|
+
reasons
|
|
8993
|
+
};
|
|
10221
8994
|
}
|
|
10222
|
-
if (!signature) throw new WebhookVerificationError("INVALID_SIGNATURE_HEADER", "Empty webhook-signature header. Expected: \"v1,<base64>\"");
|
|
10223
|
-
if (!msgId || !timestamp) throw new WebhookVerificationError("INVALID_SIGNATURE_HEADER", `Found webhook-signature header but missing ${!msgId ? "webhook-id" : "webhook-timestamp"} header. Standard Webhooks requires all three headers: webhook-id, webhook-timestamp, webhook-signature.`);
|
|
10224
8995
|
return {
|
|
10225
|
-
|
|
10226
|
-
|
|
10227
|
-
|
|
8996
|
+
verdict: "unknown",
|
|
8997
|
+
confidence: "low",
|
|
8998
|
+
reasons: ["Unable to determine email authenticity"]
|
|
10228
8999
|
};
|
|
10229
9000
|
}
|
|
9001
|
+
//#endregion
|
|
9002
|
+
//#region src/webhook/trust.ts
|
|
10230
9003
|
/**
|
|
10231
|
-
*
|
|
9004
|
+
* Domain-Anchored Sender Trust
|
|
10232
9005
|
*
|
|
10233
|
-
*
|
|
10234
|
-
*
|
|
10235
|
-
*
|
|
10236
|
-
*
|
|
10237
|
-
*
|
|
10238
|
-
*
|
|
10239
|
-
* @param options - The webhook data and secret
|
|
10240
|
-
* @returns A validated EmailReceivedEvent
|
|
10241
|
-
* @throws {WebhookVerificationError} If signature verification fails
|
|
10242
|
-
* @throws {WebhookPayloadError} If JSON parsing fails
|
|
10243
|
-
* @throws {WebhookValidationError} If schema validation fails
|
|
9006
|
+
* `validateEmailAuth()` answers "was this email authenticated?" but not
|
|
9007
|
+
* "authenticated AS WHOM?". A fully authenticated email from any domain
|
|
9008
|
+
* returns a `legit` verdict, so a verdict check alone cannot gate actions
|
|
9009
|
+
* on "this really came from our domain". `isTrustedSender()` closes that
|
|
9010
|
+
* gap by anchoring the verdict to an expected From domain (and optionally
|
|
9011
|
+
* an exact sender address).
|
|
10244
9012
|
*
|
|
10245
9013
|
* @example
|
|
10246
9014
|
* ```typescript
|
|
10247
|
-
* import {
|
|
10248
|
-
*
|
|
10249
|
-
* app.post('/webhooks/email', express.raw({ type: 'application/json' }), (req, res) => {
|
|
10250
|
-
* try {
|
|
10251
|
-
* const event = handleWebhook({
|
|
10252
|
-
* body: req.body,
|
|
10253
|
-
* headers: req.headers,
|
|
10254
|
-
* secret: process.env.PRIMITIVE_WEBHOOK_SECRET,
|
|
10255
|
-
* });
|
|
10256
|
-
*
|
|
10257
|
-
* console.log('Email from:', event.email.headers.from);
|
|
10258
|
-
* res.json({ received: true });
|
|
10259
|
-
* } catch (err) {
|
|
10260
|
-
* if (err instanceof PrimitiveWebhookError) {
|
|
10261
|
-
* console.error(`[${err.code}] ${err.message}`);
|
|
10262
|
-
* return res.status(400).json({ error: err.code });
|
|
10263
|
-
* }
|
|
10264
|
-
* throw err;
|
|
10265
|
-
* }
|
|
10266
|
-
* });
|
|
10267
|
-
* ```
|
|
10268
|
-
*/
|
|
10269
|
-
function verifyWebhookRequest(options) {
|
|
10270
|
-
const { body, headers, secret, toleranceSeconds } = options;
|
|
10271
|
-
const swHeaders = getStandardWebhooksHeaders(headers);
|
|
10272
|
-
if (swHeaders) verifyStandardWebhooksSignature({
|
|
10273
|
-
rawBody: body,
|
|
10274
|
-
msgId: swHeaders.msgId,
|
|
10275
|
-
timestamp: swHeaders.timestamp,
|
|
10276
|
-
signatureHeader: swHeaders.signature,
|
|
10277
|
-
secret,
|
|
10278
|
-
toleranceSeconds
|
|
10279
|
-
});
|
|
10280
|
-
else verifyWebhookSignature({
|
|
10281
|
-
rawBody: body,
|
|
10282
|
-
signatureHeader: getSignatureHeader(headers),
|
|
10283
|
-
secret,
|
|
10284
|
-
toleranceSeconds
|
|
10285
|
-
});
|
|
10286
|
-
}
|
|
10287
|
-
/**
|
|
10288
|
-
* Verify, then parse any webhook event into a typed value.
|
|
9015
|
+
* import { isTrustedSender } from '@primitivedotdev/sdk/api';
|
|
10289
9016
|
*
|
|
10290
|
-
*
|
|
10291
|
-
*
|
|
10292
|
-
*
|
|
10293
|
-
*
|
|
10294
|
-
*
|
|
10295
|
-
*
|
|
10296
|
-
*
|
|
10297
|
-
*
|
|
10298
|
-
*
|
|
10299
|
-
* @example
|
|
10300
|
-
* ```typescript
|
|
10301
|
-
* const event = handleWebhookEvent({ body, headers, secret });
|
|
10302
|
-
* if (isPaymentSettledEvent(event)) {
|
|
10303
|
-
* // typed PaymentSettledEvent
|
|
10304
|
-
* } else if (isInteractionX402Event(event)) {
|
|
10305
|
-
* // typed interaction.x402.* event
|
|
9017
|
+
* const trust = isTrustedSender(event, { domain: 'example.com' });
|
|
9018
|
+
* if (trust.trusted) {
|
|
9019
|
+
* // Authenticated mail whose From domain is example.com
|
|
9020
|
+
* } else if (trust.retryable) {
|
|
9021
|
+
* // Transient DNS failure during DMARC evaluation; return a 5xx so
|
|
9022
|
+
* // webhook redelivery retries with fresh DNS.
|
|
9023
|
+
* } else {
|
|
9024
|
+
* console.warn('Untrusted email:', trust.reason, trust.auth.reasons);
|
|
10306
9025
|
* }
|
|
10307
9026
|
* ```
|
|
10308
|
-
*/
|
|
10309
|
-
function handleWebhookEvent(options) {
|
|
10310
|
-
verifyWebhookRequest(options);
|
|
10311
|
-
return parseWebhookEvent(parseJsonBody(options.body), getEventHeader(options.headers));
|
|
10312
|
-
}
|
|
10313
|
-
function handleWebhook(options) {
|
|
10314
|
-
verifyWebhookRequest(options);
|
|
10315
|
-
return validateEmailReceivedEvent(parseJsonBody(options.body));
|
|
10316
|
-
}
|
|
10317
|
-
function receive(input, options) {
|
|
10318
|
-
if (input instanceof Request) return receiveFromRequest(input, options);
|
|
10319
|
-
return normalizeReceivedEmail(handleWebhook(input));
|
|
10320
|
-
}
|
|
10321
|
-
async function receiveFromRequest(request, options) {
|
|
10322
|
-
if (!options?.secret) throw new WebhookVerificationError("MISSING_SECRET", "Webhook secret is required but was empty or not provided");
|
|
10323
|
-
return normalizeReceivedEmail(handleWebhook({
|
|
10324
|
-
body: Buffer.from(await request.arrayBuffer()),
|
|
10325
|
-
headers: request.headers,
|
|
10326
|
-
secret: options.secret,
|
|
10327
|
-
toleranceSeconds: options.toleranceSeconds
|
|
10328
|
-
}));
|
|
10329
|
-
}
|
|
10330
|
-
/**
|
|
10331
|
-
* Returns headers for the optional "content discard" feature.
|
|
10332
|
-
*
|
|
10333
|
-
* If you have the "content discard" setting enabled in your Primitive dashboard,
|
|
10334
|
-
* returning this header tells Primitive to permanently delete the email content
|
|
10335
|
-
* after successful delivery. Requires BOTH the dashboard setting AND this header.
|
|
10336
|
-
*
|
|
10337
|
-
* **Warning:** Only use this if you can durably guarantee you've processed the email.
|
|
10338
|
-
* Once discarded, the email content is gone forever.
|
|
10339
|
-
*
|
|
10340
|
-
* @returns Headers object to spread into your response
|
|
10341
|
-
*
|
|
10342
|
-
* @example Express (only if using content discard)
|
|
10343
|
-
* ```typescript
|
|
10344
|
-
* app.post('/webhook', async (req, res) => {
|
|
10345
|
-
* const event = handleWebhook({ ... });
|
|
10346
|
-
* // Durably save the email first!
|
|
10347
|
-
* await db.saveEmail(event);
|
|
10348
|
-
* res.set(confirmedHeaders()).json({ received: true });
|
|
10349
|
-
* });
|
|
10350
|
-
* ```
|
|
10351
9027
|
*
|
|
10352
|
-
* @
|
|
10353
|
-
* ```typescript
|
|
10354
|
-
* return new Response(JSON.stringify({ received: true }), {
|
|
10355
|
-
* status: 200,
|
|
10356
|
-
* headers: {
|
|
10357
|
-
* 'Content-Type': 'application/json',
|
|
10358
|
-
* ...confirmedHeaders(),
|
|
10359
|
-
* },
|
|
10360
|
-
* });
|
|
10361
|
-
* ```
|
|
9028
|
+
* @packageDocumentation
|
|
10362
9029
|
*/
|
|
10363
|
-
|
|
9030
|
+
const SENDER_OPTION_EMAIL_OPTIONS = {
|
|
9031
|
+
allow_ip_domain: true,
|
|
9032
|
+
require_tld: true,
|
|
9033
|
+
allow_display_name: false,
|
|
9034
|
+
allow_utf8_local_part: true
|
|
9035
|
+
};
|
|
9036
|
+
function untrusted(reason, auth, retryable = false) {
|
|
10364
9037
|
return {
|
|
10365
|
-
|
|
10366
|
-
|
|
9038
|
+
trusted: false,
|
|
9039
|
+
retryable,
|
|
9040
|
+
reason,
|
|
9041
|
+
auth
|
|
10367
9042
|
};
|
|
10368
9043
|
}
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
*
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
* ```typescript
|
|
10378
|
-
* if (isDownloadExpired(event)) {
|
|
10379
|
-
* console.log("Download URL has expired, cannot fetch raw email");
|
|
10380
|
-
* } else {
|
|
10381
|
-
* const response = await fetch(event.email.content.download.url);
|
|
10382
|
-
* }
|
|
10383
|
-
* ```
|
|
10384
|
-
*/
|
|
10385
|
-
function isDownloadExpired(event, now = Date.now()) {
|
|
10386
|
-
return now >= new Date(event.email.content.download.expires_at).getTime();
|
|
10387
|
-
}
|
|
10388
|
-
/**
|
|
10389
|
-
* Get the time remaining (in milliseconds) before the download URL expires.
|
|
10390
|
-
* Returns 0 if already expired.
|
|
10391
|
-
*
|
|
10392
|
-
* @param event - The webhook event
|
|
10393
|
-
* @param now - Optional current time for testing (defaults to Date.now())
|
|
10394
|
-
* @returns Milliseconds until expiration, or 0 if expired
|
|
10395
|
-
*
|
|
10396
|
-
* @example
|
|
10397
|
-
* ```typescript
|
|
10398
|
-
* const remaining = getDownloadTimeRemaining(event);
|
|
10399
|
-
* if (remaining > 60000) {
|
|
10400
|
-
* // More than 1 minute left, safe to download
|
|
10401
|
-
* }
|
|
10402
|
-
* ```
|
|
10403
|
-
*/
|
|
10404
|
-
function getDownloadTimeRemaining(event, now = Date.now()) {
|
|
10405
|
-
const expiresAt = new Date(event.email.content.download.expires_at).getTime();
|
|
10406
|
-
return Math.max(0, expiresAt - now);
|
|
9044
|
+
function hasSubdomainIdentity(auth, domain, dmarcDomain) {
|
|
9045
|
+
if (auth.dmarc !== "pass" || auth.dmarcDkimAligned !== true || !domain.endsWith(`.${dmarcDomain}`)) return false;
|
|
9046
|
+
const managedInbox = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.primitive\.email$/.test(domain);
|
|
9047
|
+
return auth.dkimSignatures.some((signature) => {
|
|
9048
|
+
const signer = typeof signature.domain === "string" ? signature.domain.trim().toLowerCase() : "";
|
|
9049
|
+
const strongKey = signature.algo === "ed25519-sha256" || signature.algo === "rsa-sha256" && typeof signature.keyBits === "number" && signature.keyBits >= 1024;
|
|
9050
|
+
return signature.result === "pass" && signature.aligned === true && strongKey && (signer === domain || managedInbox && dmarcDomain === "primitive.email" && signer === "primitive.email");
|
|
9051
|
+
});
|
|
10407
9052
|
}
|
|
10408
9053
|
/**
|
|
10409
|
-
* Check
|
|
10410
|
-
*
|
|
10411
|
-
* Use this to check before calling `decodeRawEmail()` to avoid try/catch.
|
|
9054
|
+
* Check whether an inbound email is authenticated as an expected domain
|
|
9055
|
+
* (and optionally an exact sender address).
|
|
10412
9056
|
*
|
|
10413
|
-
*
|
|
10414
|
-
* @returns true if raw content is included inline, false if download required
|
|
10415
|
-
*
|
|
10416
|
-
* @example
|
|
10417
|
-
* ```typescript
|
|
10418
|
-
* if (isRawIncluded(event)) {
|
|
10419
|
-
* const rawEmail = decodeRawEmail(event);
|
|
10420
|
-
* } else {
|
|
10421
|
-
* const response = await fetch(event.email.content.download.url);
|
|
10422
|
-
* }
|
|
10423
|
-
* ```
|
|
10424
|
-
*/
|
|
10425
|
-
function isRawIncluded(event) {
|
|
10426
|
-
return event.email.content.raw.included;
|
|
10427
|
-
}
|
|
10428
|
-
/**
|
|
10429
|
-
* Decode the raw email content from an EmailReceivedEvent.
|
|
9057
|
+
* `trusted` is true only when ALL of the following hold:
|
|
10430
9058
|
*
|
|
10431
|
-
*
|
|
10432
|
-
*
|
|
9059
|
+
* 1. `validateEmailAuth(event.email.auth)` returns a `legit` verdict.
|
|
9060
|
+
* 2. The reported DMARC domain equals `options.domain`, or is its parent
|
|
9061
|
+
* and passing, aligned DKIM uses the exact expected domain.
|
|
9062
|
+
* A direct child of primitive.email may instead use primitive.email
|
|
9063
|
+
* as its signer, trusting Primitive to authorize the sending identity.
|
|
9064
|
+
* 3. The From header strict-parses to exactly one valid address whose
|
|
9065
|
+
* domain equals `options.domain`.
|
|
9066
|
+
* 4. When `options.sender` is given, the parsed From address equals it
|
|
9067
|
+
* exactly (case-insensitive).
|
|
10433
9068
|
*
|
|
10434
|
-
*
|
|
10435
|
-
* Passing a manually constructed event with missing fields (e.g., `raw.data`
|
|
10436
|
-
* undefined when `raw.included` is true) will result in undefined behavior.
|
|
9069
|
+
* ## Why the extra checks beyond the verdict
|
|
10437
9070
|
*
|
|
10438
|
-
*
|
|
10439
|
-
*
|
|
10440
|
-
*
|
|
10441
|
-
*
|
|
9071
|
+
* The verdict alone says an email was authenticated, not which domain
|
|
9072
|
+
* it was authenticated as: a fully authenticated email from an
|
|
9073
|
+
* attacker-controlled domain is `legit`. Anchoring `dmarcFromDomain`
|
|
9074
|
+
* and requiring DKIM proof for subdomain exceptions closes that. A shared
|
|
9075
|
+
* organizational domain or SPF-only pass never suffices for the exception.
|
|
9076
|
+
* The qualifying signature must use RSA-SHA256 with at least 1024 reported
|
|
9077
|
+
* key bits, or Ed25519-SHA256. Unknown algorithms or RSA sizes fail closed.
|
|
9078
|
+
* The strict From parse defends the remaining gaps:
|
|
10442
9079
|
*
|
|
10443
|
-
*
|
|
10444
|
-
*
|
|
10445
|
-
*
|
|
9080
|
+
* - Naively regexing the raw From header is unsafe. A header like
|
|
9081
|
+
* `From: "trusted@example.com" <x@evil.com>` plants an allowlisted
|
|
9082
|
+
* address in the display name while DMARC evaluates (and passes for)
|
|
9083
|
+
* `evil.com`. The strict parser extracts only the real addr-spec and
|
|
9084
|
+
* rejects multi-address and group forms outright.
|
|
9085
|
+
* - `normalizeReceivedEmail().sender` is NOT a safe anchor for
|
|
9086
|
+
* authorization: it uses a lenient parser and falls back to the SMTP
|
|
9087
|
+
* envelope sender (`smtp.mail_from`), which the sender fully
|
|
9088
|
+
* controls. The same goes for Reply-To (`replyTarget`). This function
|
|
9089
|
+
* never consults either.
|
|
9090
|
+
* - An `unknown` verdict is not one thing: a DMARC temperror is
|
|
9091
|
+
* transient (surfaced as `retryable: true`, respond 5xx and let
|
|
9092
|
+
* webhook redelivery retry), while "no DMARC record" is permanent
|
|
9093
|
+
* for the email and surfaced as non-retryable.
|
|
10446
9094
|
*
|
|
10447
|
-
*
|
|
9095
|
+
* Never throws for malformed event content; malformed input yields an
|
|
9096
|
+
* untrusted result with a reason. Throws `TypeError` only for invalid
|
|
9097
|
+
* `options` (programmer error).
|
|
10448
9098
|
*
|
|
10449
|
-
*
|
|
10450
|
-
*
|
|
10451
|
-
*
|
|
10452
|
-
*
|
|
10453
|
-
* // Must download from event.email.content.download.url
|
|
10454
|
-
* }
|
|
10455
|
-
* ```
|
|
9099
|
+
* @param event - The verified `email.received` webhook event
|
|
9100
|
+
* @param options - Expected domain and optional exact sender
|
|
9101
|
+
* @returns Trust decision with a stable reason code and the underlying
|
|
9102
|
+
* auth result
|
|
10456
9103
|
*/
|
|
10457
|
-
function
|
|
10458
|
-
const {
|
|
10459
|
-
const
|
|
10460
|
-
if (
|
|
10461
|
-
|
|
10462
|
-
|
|
10463
|
-
|
|
10464
|
-
|
|
10465
|
-
|
|
9104
|
+
function isTrustedSender(event, options) {
|
|
9105
|
+
const { domain, sender } = normalizeOptions(options);
|
|
9106
|
+
const auth = event?.email?.auth;
|
|
9107
|
+
if (auth === null || typeof auth !== "object" || !Array.isArray(auth.dkimSignatures)) return untrusted("auth-missing", {
|
|
9108
|
+
verdict: "unknown",
|
|
9109
|
+
confidence: "low",
|
|
9110
|
+
reasons: ["Missing or malformed email.auth on event"]
|
|
9111
|
+
});
|
|
9112
|
+
const authResult = validateEmailAuth(auth);
|
|
9113
|
+
if (authResult.verdict === "suspicious") return untrusted("auth-suspicious", authResult);
|
|
9114
|
+
if (authResult.verdict === "unknown") {
|
|
9115
|
+
if (auth.dmarc === "temperror") return untrusted("dmarc-temperror", authResult, true);
|
|
9116
|
+
return untrusted("auth-unknown", authResult);
|
|
10466
9117
|
}
|
|
10467
|
-
|
|
9118
|
+
const dmarcFromDomain = typeof auth.dmarcFromDomain === "string" ? auth.dmarcFromDomain.trim().toLowerCase() : "";
|
|
9119
|
+
if (dmarcFromDomain === "" || dmarcFromDomain !== domain && !hasSubdomainIdentity(auth, domain, dmarcFromDomain)) return untrusted("dmarc-domain-mismatch", authResult);
|
|
9120
|
+
const parsed = parseFromHeader(event.email?.headers?.from);
|
|
9121
|
+
if (!parsed.ok) return untrusted(parsed.reason === "multiple_addresses" ? "from-header-multiple-addresses" : "from-header-invalid", authResult);
|
|
9122
|
+
const fromAddress = parsed.value.address;
|
|
9123
|
+
if (fromAddress.slice(fromAddress.lastIndexOf("@") + 1) !== domain) return untrusted("from-domain-mismatch", authResult);
|
|
9124
|
+
if (sender !== void 0 && fromAddress !== sender) return untrusted("sender-mismatch", authResult);
|
|
9125
|
+
return {
|
|
9126
|
+
trusted: true,
|
|
9127
|
+
retryable: false,
|
|
9128
|
+
reason: "trusted",
|
|
9129
|
+
auth: authResult
|
|
9130
|
+
};
|
|
10468
9131
|
}
|
|
10469
|
-
|
|
10470
|
-
|
|
10471
|
-
|
|
10472
|
-
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
* import { handleWebhook, verifyRawEmailDownload, isRawIncluded } from '@primitivedotdev/sdk';
|
|
10483
|
-
*
|
|
10484
|
-
* const event = handleWebhook({ body, headers, secret });
|
|
10485
|
-
*
|
|
10486
|
-
* if (!isRawIncluded(event)) {
|
|
10487
|
-
* const response = await fetch(event.email.content.download.url);
|
|
10488
|
-
* const arrayBuffer = await response.arrayBuffer();
|
|
10489
|
-
* const verified = verifyRawEmailDownload(arrayBuffer, event);
|
|
10490
|
-
* // verified is a Buffer containing the RFC 5322 email
|
|
10491
|
-
* }
|
|
10492
|
-
* ```
|
|
10493
|
-
*/
|
|
10494
|
-
function verifyRawEmailDownload(downloaded, event) {
|
|
10495
|
-
const buffer = Buffer.isBuffer(downloaded) ? downloaded : Buffer.from(downloaded);
|
|
10496
|
-
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
10497
|
-
const expected = event.email.content.raw.sha256;
|
|
10498
|
-
if (hash !== expected.toLowerCase()) throw new RawEmailDecodeError("HASH_MISMATCH", `SHA-256 hash mismatch. Expected: ${expected}, got: ${hash}. The downloaded content may be corrupted.`);
|
|
10499
|
-
return buffer;
|
|
9132
|
+
function normalizeOptions(options) {
|
|
9133
|
+
if (typeof options?.domain !== "string") throw new TypeError("options.domain is required");
|
|
9134
|
+
const domain = options.domain.trim().toLowerCase();
|
|
9135
|
+
if (domain.length === 0) throw new TypeError("options.domain must be a non-empty domain name");
|
|
9136
|
+
if (domain.includes("@") || /\s/.test(domain)) throw new TypeError("options.domain must be a bare domain name without @ or whitespace");
|
|
9137
|
+
if (options.sender === void 0) return { domain };
|
|
9138
|
+
if (typeof options.sender !== "string") throw new TypeError("options.sender must be a string when provided");
|
|
9139
|
+
const sender = options.sender.trim().toLowerCase();
|
|
9140
|
+
if (!isEmail(sender, SENDER_OPTION_EMAIL_OPTIONS)) throw new TypeError("options.sender must be a single bare email address (user@example.com)");
|
|
9141
|
+
return {
|
|
9142
|
+
domain,
|
|
9143
|
+
sender
|
|
9144
|
+
};
|
|
10500
9145
|
}
|
|
10501
9146
|
//#endregion
|
|
10502
|
-
export {
|
|
9147
|
+
export { PAYLOAD_ERRORS as A, isInteractionX402Event as C, isPaymentSettledEvent as D, isPaymentFailedEvent as E, WebhookPayloadError as F, WebhookValidationError as I, WebhookVerificationError as L, RAW_EMAIL_ERRORS as M, RawEmailDecodeError as N, safeValidateEmailReceivedEvent as O, VERIFICATION_ERRORS as P, isEmailReceivedEvent as S, isPaymentEvent as T, parseWebhookEvent as _, DkimResult as a, PAYMENT_EVENT_TYPES as b, EventType as c, SpfResult as d, buildForwardSubject as f, parseHeaderAddress as g, normalizeReceivedEmail as h, AuthVerdict as i, PrimitiveWebhookError as j, validateEmailReceivedEvent as k, ForwardVerdict as l, formatAddress as m, validateEmailAuth as n, DmarcPolicy as o, buildReplySubject as p, AuthConfidence as r, DmarcResult as s, isTrustedSender as t, ParsedStatus as u, EMAIL_EVENT_TYPES as v, isKnownWebhookEventType as w, WEBHOOK_EVENT_TYPES as x, INTERACTION_EVENT_TYPES as y };
|