@primitivedotdev/sdk 0.2.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 +227 -0
- package/dist/contract/index.d.ts +150 -0
- package/dist/contract/index.js +199 -0
- package/dist/index-C9pON-wY.d.ts +1474 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/parser/index.d.ts +185 -0
- package/dist/parser/index.js +438 -0
- package/dist/types-C6M6oCRS.d.ts +817 -0
- package/dist/webhook/index.d.ts +3 -0
- package/dist/webhook/index.js +3 -0
- package/dist/webhook-h24dbQEE.js +7661 -0
- package/package.json +86 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { AuthConfidence$1 as AuthConfidence, AuthVerdict$1 as AuthVerdict, DkimResult$1 as DkimResult, DkimSignature, DmarcPolicy$1 as DmarcPolicy, DmarcResult$1 as DmarcResult, EmailAddress, EmailAnalysis, EmailAuth, EmailReceivedEvent, EventType$1 as EventType, ForwardAnalysis, ForwardOriginalSender, ForwardResult, ForwardResultAttachmentAnalyzed, ForwardResultAttachmentSkipped, ForwardResultInline, ForwardVerdict$1 as ForwardVerdict, ForwardVerification, KnownWebhookEvent, ParsedData, ParsedDataComplete, ParsedDataFailed, ParsedError, ParsedStatus$1 as ParsedStatus, RawContent, RawContentDownloadOnly, RawContentInline, SpfResult$1 as SpfResult, UnknownEvent, ValidateEmailAuthResult, WebhookAttachment, WebhookEvent } from "./types-C6M6oCRS.js";
|
|
2
|
+
import { DecodeRawEmailOptions, HandleWebhookOptions, PAYLOAD_ERRORS$1 as PAYLOAD_ERRORS, PRIMITIVE_CONFIRMED_HEADER$1 as PRIMITIVE_CONFIRMED_HEADER, PRIMITIVE_SIGNATURE_HEADER$1 as PRIMITIVE_SIGNATURE_HEADER, PrimitiveWebhookError$1 as PrimitiveWebhookError, RAW_EMAIL_ERRORS$1 as RAW_EMAIL_ERRORS, RawEmailDecodeError$1 as RawEmailDecodeError, RawEmailDecodeErrorCode, SignResult, VERIFICATION_ERRORS$1 as VERIFICATION_ERRORS, VerifyOptions, WEBHOOK_VERSION$1 as WEBHOOK_VERSION, WebhookErrorCode, WebhookHeaders, WebhookPayloadError$1 as WebhookPayloadError, WebhookPayloadErrorCode, WebhookValidationError$1 as WebhookValidationError, WebhookValidationErrorCode, WebhookVerificationError$1 as WebhookVerificationError, WebhookVerificationErrorCode, confirmedHeaders$1 as confirmedHeaders, decodeRawEmail$1 as decodeRawEmail, emailReceivedEventJsonSchema$1 as emailReceivedEventJsonSchema, getDownloadTimeRemaining$1 as getDownloadTimeRemaining, handleWebhook$1 as handleWebhook, isDownloadExpired$1 as isDownloadExpired, isEmailReceivedEvent$1 as isEmailReceivedEvent, isRawIncluded$1 as isRawIncluded, parseWebhookEvent$1 as parseWebhookEvent, safeValidateEmailReceivedEvent$1 as safeValidateEmailReceivedEvent, signWebhookPayload$1 as signWebhookPayload, validateEmailAuth$1 as validateEmailAuth, validateEmailReceivedEvent$1 as validateEmailReceivedEvent, verifyRawEmailDownload$1 as verifyRawEmailDownload, verifyWebhookSignature$1 as verifyWebhookSignature } from "./index-C9pON-wY.js";
|
|
3
|
+
export { AuthConfidence, AuthVerdict, DecodeRawEmailOptions, DkimResult, DkimSignature, DmarcPolicy, DmarcResult, EmailAddress, EmailAnalysis, EmailAuth, EmailReceivedEvent, EventType, ForwardAnalysis, ForwardOriginalSender, ForwardResult, ForwardResultAttachmentAnalyzed, ForwardResultAttachmentSkipped, ForwardResultInline, ForwardVerdict, ForwardVerification, HandleWebhookOptions, KnownWebhookEvent, PAYLOAD_ERRORS, PRIMITIVE_CONFIRMED_HEADER, PRIMITIVE_SIGNATURE_HEADER, ParsedData, ParsedDataComplete, ParsedDataFailed, ParsedError, ParsedStatus, PrimitiveWebhookError, RAW_EMAIL_ERRORS, RawContent, RawContentDownloadOnly, RawContentInline, RawEmailDecodeError, RawEmailDecodeErrorCode, SignResult, SpfResult, UnknownEvent, VERIFICATION_ERRORS, ValidateEmailAuthResult, VerifyOptions, WEBHOOK_VERSION, WebhookAttachment, WebhookErrorCode, WebhookEvent, WebhookHeaders, WebhookPayloadError, WebhookPayloadErrorCode, WebhookValidationError, WebhookValidationErrorCode, WebhookVerificationError, WebhookVerificationErrorCode, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, safeValidateEmailReceivedEvent, signWebhookPayload, validateEmailAuth, validateEmailReceivedEvent, verifyRawEmailDownload, verifyWebhookSignature };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { AuthConfidence, AuthVerdict, DkimResult, DmarcPolicy, DmarcResult, EventType, ForwardVerdict, PAYLOAD_ERRORS, PRIMITIVE_CONFIRMED_HEADER, PRIMITIVE_SIGNATURE_HEADER, ParsedStatus, PrimitiveWebhookError, RAW_EMAIL_ERRORS, RawEmailDecodeError, SpfResult, VERIFICATION_ERRORS, WEBHOOK_VERSION, WebhookPayloadError, WebhookValidationError, WebhookVerificationError, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, safeValidateEmailReceivedEvent, signWebhookPayload, validateEmailAuth, validateEmailReceivedEvent, verifyRawEmailDownload, verifyWebhookSignature } from "./webhook-h24dbQEE.js";
|
|
2
|
+
|
|
3
|
+
export { AuthConfidence, AuthVerdict, DkimResult, DmarcPolicy, DmarcResult, EventType, ForwardVerdict, PAYLOAD_ERRORS, PRIMITIVE_CONFIRMED_HEADER, PRIMITIVE_SIGNATURE_HEADER, ParsedStatus, PrimitiveWebhookError, RAW_EMAIL_ERRORS, RawEmailDecodeError, SpfResult, VERIFICATION_ERRORS, WEBHOOK_VERSION, WebhookPayloadError, WebhookValidationError, WebhookVerificationError, confirmedHeaders, decodeRawEmail, emailReceivedEventJsonSchema, getDownloadTimeRemaining, handleWebhook, isDownloadExpired, isEmailReceivedEvent, isRawIncluded, parseWebhookEvent, safeValidateEmailReceivedEvent, signWebhookPayload, validateEmailAuth, validateEmailReceivedEvent, verifyRawEmailDownload, verifyWebhookSignature };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { EmailAddress, ParsedDataComplete, WebhookAttachment } from "../types-C6M6oCRS.js";
|
|
2
|
+
|
|
3
|
+
//#region src/parser/attachment-parser.d.ts
|
|
4
|
+
interface ParsedAttachment {
|
|
5
|
+
id: string;
|
|
6
|
+
partIndex: number;
|
|
7
|
+
filename: string | null;
|
|
8
|
+
contentType: string | null;
|
|
9
|
+
contentTypeNorm: string;
|
|
10
|
+
contentDispositionRaw: string | null;
|
|
11
|
+
disposition: "attachment" | "inline" | null;
|
|
12
|
+
contentTransferEncoding: string | null;
|
|
13
|
+
contentIdRaw: string | null;
|
|
14
|
+
contentIdNorm: string | null;
|
|
15
|
+
downloadName: string;
|
|
16
|
+
tarPath: string;
|
|
17
|
+
sizeBytes: number;
|
|
18
|
+
sha256: string;
|
|
19
|
+
isInline: boolean;
|
|
20
|
+
isDownloadable: boolean;
|
|
21
|
+
isSafeForInlineServing: boolean;
|
|
22
|
+
content: Buffer;
|
|
23
|
+
}
|
|
24
|
+
interface ParsedEmailWithAttachments {
|
|
25
|
+
bodyText: string | null;
|
|
26
|
+
bodyHtml: string | null;
|
|
27
|
+
attachments: ParsedAttachment[];
|
|
28
|
+
subject: string | null;
|
|
29
|
+
messageId: string | null;
|
|
30
|
+
date: Date | null;
|
|
31
|
+
dateHeader: string | null;
|
|
32
|
+
from: string | null;
|
|
33
|
+
to: string | null;
|
|
34
|
+
replyTo: EmailAddress[] | null;
|
|
35
|
+
cc: EmailAddress[] | null;
|
|
36
|
+
bcc: EmailAddress[] | null;
|
|
37
|
+
inReplyTo: string[] | null;
|
|
38
|
+
references: string[] | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse a raw email buffer and extract body + attachments.
|
|
42
|
+
* Uses mailparser for the heavy lifting.
|
|
43
|
+
*
|
|
44
|
+
* - Mailparser's default converts CID refs to data: URLs in body_html
|
|
45
|
+
* - Inline images (related=true) are embedded in body_html, NOT in attachments list
|
|
46
|
+
* - Only "real" attachments are returned for tar.gz bundling
|
|
47
|
+
*/
|
|
48
|
+
declare function parseEmailWithAttachments(emlBuffer: Buffer, options?: {
|
|
49
|
+
generateAttachmentId?: () => string;
|
|
50
|
+
}): Promise<ParsedEmailWithAttachments>;
|
|
51
|
+
/**
|
|
52
|
+
* Normalize a Content-Type to lowercase media type without parameters.
|
|
53
|
+
*/
|
|
54
|
+
declare function normalizeContentType(contentType: string | undefined | null): string;
|
|
55
|
+
/**
|
|
56
|
+
* Compute SHA-256 hash of a buffer as hex string.
|
|
57
|
+
*/
|
|
58
|
+
declare function sha256Hex(buffer: Buffer): string;
|
|
59
|
+
/**
|
|
60
|
+
* Sanitize a filename for safe use in Content-Disposition headers.
|
|
61
|
+
* Prevents path traversal, removes control characters, enforces length limits.
|
|
62
|
+
*/
|
|
63
|
+
declare function sanitizeFilename(filename: string | null, partIndex: number): string; //#endregion
|
|
64
|
+
//#region src/parser/attachment-bundler.d.ts
|
|
65
|
+
/**
|
|
66
|
+
* Result of bundling attachments into a tar.gz archive
|
|
67
|
+
*/
|
|
68
|
+
interface BundleResult {
|
|
69
|
+
/** The tar.gz archive as a Buffer */
|
|
70
|
+
tarGzBuffer: Buffer;
|
|
71
|
+
/** SHA-256 hash of the tar.gz archive */
|
|
72
|
+
sha256: string;
|
|
73
|
+
/** Number of attachments included */
|
|
74
|
+
attachmentCount: number;
|
|
75
|
+
/** Total size of all attachment bytes (before compression) */
|
|
76
|
+
totalAttachmentBytes: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Metadata for attachments included in the bundle (for DB storage)
|
|
80
|
+
*/
|
|
81
|
+
interface AttachmentMetadata {
|
|
82
|
+
filename: string | null;
|
|
83
|
+
content_type: string;
|
|
84
|
+
size_bytes: number;
|
|
85
|
+
sha256: string;
|
|
86
|
+
part_index: number;
|
|
87
|
+
tar_path: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Bundle downloadable attachments into a tar.gz archive.
|
|
91
|
+
*
|
|
92
|
+
* - Only includes attachments where isDownloadable === true
|
|
93
|
+
* - Inline images (related=true) are excluded - they're in body_html as data: URLs
|
|
94
|
+
* - Files are stored at paths: {partIndex}_{sanitized_filename}
|
|
95
|
+
*
|
|
96
|
+
* @param attachments - Array of parsed attachments from parseEmailWithAttachments()
|
|
97
|
+
* @returns BundleResult with the tar.gz buffer and metadata, or null if no downloadable attachments
|
|
98
|
+
*/
|
|
99
|
+
declare function bundleAttachments(attachments: ParsedAttachment[]): Promise<BundleResult | null>;
|
|
100
|
+
/**
|
|
101
|
+
* Extract metadata for DB storage from parsed attachments.
|
|
102
|
+
* Only includes downloadable attachments (matches what's in the tar.gz).
|
|
103
|
+
*
|
|
104
|
+
* @param attachments - Array of parsed attachments
|
|
105
|
+
* @returns Array of attachment metadata for DB storage
|
|
106
|
+
*/
|
|
107
|
+
declare function extractAttachmentMetadata(attachments: ParsedAttachment[]): AttachmentMetadata[];
|
|
108
|
+
/**
|
|
109
|
+
* Generate the storage key for an attachments tar.gz file.
|
|
110
|
+
*
|
|
111
|
+
* @param emailId - The email UUID
|
|
112
|
+
* @param sha256 - SHA-256 hash of the tar.gz file
|
|
113
|
+
* @returns Storage key in format: attachments/{email_id}_{hash8}.tar.gz
|
|
114
|
+
*/
|
|
115
|
+
declare function getAttachmentsStorageKey(emailId: string, sha256: string): string;
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/parser/email-parser.d.ts
|
|
119
|
+
interface ParsedEmail {
|
|
120
|
+
from: string;
|
|
121
|
+
to: string;
|
|
122
|
+
subject: string | undefined;
|
|
123
|
+
text: string | undefined;
|
|
124
|
+
html: string | undefined;
|
|
125
|
+
headers: Record<string, string | string[]>;
|
|
126
|
+
messageId: string | undefined;
|
|
127
|
+
date: Date | undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Parse a raw .eml file into structured data
|
|
131
|
+
* Uses mailparser library for robust email parsing
|
|
132
|
+
*/
|
|
133
|
+
declare function parseEmail(emlRaw: string): Promise<ParsedEmail>;
|
|
134
|
+
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/parser/mapping.d.ts
|
|
137
|
+
/**
|
|
138
|
+
* Convert parser output to canonical ParsedDataComplete (snake_case).
|
|
139
|
+
*
|
|
140
|
+
* Maps `bodyHtml` → `body_html` without mutating the HTML content.
|
|
141
|
+
* Preserves the original Date header value when available.
|
|
142
|
+
* Coerces nullable `from`/`to` to non-nullable strings.
|
|
143
|
+
*
|
|
144
|
+
* @param parsed - Output from parseEmailWithAttachments()
|
|
145
|
+
* @param attachmentsDownloadUrl - URL or local path for attachment download (null if no attachments)
|
|
146
|
+
* @returns Canonical ParsedDataComplete ready for JSON serialization
|
|
147
|
+
*/
|
|
148
|
+
declare function toParsedDataComplete(parsed: ParsedEmailWithAttachments, attachmentsDownloadUrl: string | null): ParsedDataComplete;
|
|
149
|
+
/**
|
|
150
|
+
* Convert parser attachments to canonical WebhookAttachment[] (JSON-safe subset).
|
|
151
|
+
*
|
|
152
|
+
* Filters to only downloadable attachments and strips internal fields
|
|
153
|
+
* (content Buffer, contentId, disposition, etc.).
|
|
154
|
+
*
|
|
155
|
+
* @param attachments - ParsedAttachment[] from the parser
|
|
156
|
+
* @returns WebhookAttachment[] with only public metadata
|
|
157
|
+
*/
|
|
158
|
+
declare function toWebhookAttachments(attachments: ParsedAttachment[]): WebhookAttachment[];
|
|
159
|
+
/**
|
|
160
|
+
* Convert AttachmentMetadata[] (from extractAttachmentMetadata) to WebhookAttachment[].
|
|
161
|
+
*
|
|
162
|
+
* AttachmentMetadata is already in snake_case and JSON-safe, so this is
|
|
163
|
+
* essentially a type assertion. Useful when you have metadata from the bundler
|
|
164
|
+
* rather than raw ParsedAttachment[].
|
|
165
|
+
*/
|
|
166
|
+
declare function attachmentMetadataToWebhookAttachments(metadata: AttachmentMetadata[]): WebhookAttachment[];
|
|
167
|
+
/**
|
|
168
|
+
* Extract canonical email headers from parser output.
|
|
169
|
+
*
|
|
170
|
+
* Converts camelCase parser fields to the shape expected by
|
|
171
|
+
* EmailReceivedEvent.email.headers.
|
|
172
|
+
*
|
|
173
|
+
* @param parsed - Output from parseEmailWithAttachments()
|
|
174
|
+
* @returns Headers object matching the canonical schema
|
|
175
|
+
*/
|
|
176
|
+
declare function toCanonicalHeaders(parsed: ParsedEmailWithAttachments): {
|
|
177
|
+
message_id: string | null;
|
|
178
|
+
subject: string | null;
|
|
179
|
+
from: string;
|
|
180
|
+
to: string;
|
|
181
|
+
date: string | null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
//#endregion
|
|
185
|
+
export { AttachmentMetadata, BundleResult, ParsedAttachment, ParsedEmail, ParsedEmailWithAttachments, attachmentMetadataToWebhookAttachments, bundleAttachments, extractAttachmentMetadata, getAttachmentsStorageKey, normalizeContentType, parseEmail, parseEmailWithAttachments, sanitizeFilename, sha256Hex, toCanonicalHeaders, toParsedDataComplete, toWebhookAttachments };
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
|
|
4
|
+
//#region src/parser/attachment-bundler.ts
|
|
5
|
+
async function loadArchiver() {
|
|
6
|
+
const module = await import("archiver");
|
|
7
|
+
return module.default;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Bundle downloadable attachments into a tar.gz archive.
|
|
11
|
+
*
|
|
12
|
+
* - Only includes attachments where isDownloadable === true
|
|
13
|
+
* - Inline images (related=true) are excluded - they're in body_html as data: URLs
|
|
14
|
+
* - Files are stored at paths: {partIndex}_{sanitized_filename}
|
|
15
|
+
*
|
|
16
|
+
* @param attachments - Array of parsed attachments from parseEmailWithAttachments()
|
|
17
|
+
* @returns BundleResult with the tar.gz buffer and metadata, or null if no downloadable attachments
|
|
18
|
+
*/
|
|
19
|
+
async function bundleAttachments(attachments) {
|
|
20
|
+
const downloadable = attachments.filter((att) => att.isDownloadable);
|
|
21
|
+
if (downloadable.length === 0) return null;
|
|
22
|
+
const archiver = await loadArchiver();
|
|
23
|
+
const archive = archiver("tar", {
|
|
24
|
+
gzip: true,
|
|
25
|
+
gzipOptions: { level: 6 }
|
|
26
|
+
});
|
|
27
|
+
const chunks = [];
|
|
28
|
+
const passThrough = new PassThrough();
|
|
29
|
+
let totalAttachmentBytes = 0;
|
|
30
|
+
const tarGzBuffer = await new Promise((resolve, reject) => {
|
|
31
|
+
let settled = false;
|
|
32
|
+
const cleanup = () => {
|
|
33
|
+
archive.off("error", rejectArchive);
|
|
34
|
+
archive.off("warning", rejectArchive);
|
|
35
|
+
passThrough.off("data", handleData);
|
|
36
|
+
passThrough.off("end", handleEnd);
|
|
37
|
+
passThrough.off("error", rejectArchive);
|
|
38
|
+
};
|
|
39
|
+
const rejectArchive = (error) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
cleanup();
|
|
43
|
+
reject(error);
|
|
44
|
+
};
|
|
45
|
+
const handleData = (chunk) => {
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
};
|
|
48
|
+
const handleEnd = () => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
cleanup();
|
|
52
|
+
resolve(Buffer.concat(chunks));
|
|
53
|
+
};
|
|
54
|
+
archive.on("error", rejectArchive);
|
|
55
|
+
archive.on("warning", rejectArchive);
|
|
56
|
+
passThrough.on("data", handleData);
|
|
57
|
+
passThrough.on("end", handleEnd);
|
|
58
|
+
passThrough.on("error", rejectArchive);
|
|
59
|
+
archive.pipe(passThrough);
|
|
60
|
+
try {
|
|
61
|
+
for (const att of downloadable) {
|
|
62
|
+
archive.append(att.content, { name: att.tarPath });
|
|
63
|
+
totalAttachmentBytes += att.sizeBytes;
|
|
64
|
+
}
|
|
65
|
+
archive.finalize().catch(rejectArchive);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
rejectArchive(error instanceof Error ? error : new Error(String(error)));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const sha256 = createHash("sha256").update(tarGzBuffer).digest("hex");
|
|
71
|
+
return {
|
|
72
|
+
tarGzBuffer,
|
|
73
|
+
sha256,
|
|
74
|
+
attachmentCount: downloadable.length,
|
|
75
|
+
totalAttachmentBytes
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Extract metadata for DB storage from parsed attachments.
|
|
80
|
+
* Only includes downloadable attachments (matches what's in the tar.gz).
|
|
81
|
+
*
|
|
82
|
+
* @param attachments - Array of parsed attachments
|
|
83
|
+
* @returns Array of attachment metadata for DB storage
|
|
84
|
+
*/
|
|
85
|
+
function extractAttachmentMetadata(attachments) {
|
|
86
|
+
return attachments.filter((att) => att.isDownloadable).map((att) => ({
|
|
87
|
+
filename: att.filename,
|
|
88
|
+
content_type: att.contentTypeNorm,
|
|
89
|
+
size_bytes: att.sizeBytes,
|
|
90
|
+
sha256: att.sha256,
|
|
91
|
+
part_index: att.partIndex,
|
|
92
|
+
tar_path: att.tarPath
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Generate the storage key for an attachments tar.gz file.
|
|
97
|
+
*
|
|
98
|
+
* @param emailId - The email UUID
|
|
99
|
+
* @param sha256 - SHA-256 hash of the tar.gz file
|
|
100
|
+
* @returns Storage key in format: attachments/{email_id}_{hash8}.tar.gz
|
|
101
|
+
*/
|
|
102
|
+
function getAttachmentsStorageKey(emailId, sha256) {
|
|
103
|
+
const hash8 = sha256.substring(0, 8);
|
|
104
|
+
return `attachments/${emailId}_${hash8}.tar.gz`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/parser/attachment-parser.ts
|
|
109
|
+
async function loadMailparser$1() {
|
|
110
|
+
return import("mailparser");
|
|
111
|
+
}
|
|
112
|
+
const SIGNATURE_ARTIFACTS = new Set([
|
|
113
|
+
"application/pkcs7-signature",
|
|
114
|
+
"application/x-pkcs7-signature",
|
|
115
|
+
"application/pgp-signature",
|
|
116
|
+
"application/pgp-keys",
|
|
117
|
+
"application/pgp-encrypted",
|
|
118
|
+
"application/ms-tnef"
|
|
119
|
+
]);
|
|
120
|
+
const SAFE_INLINE_TYPES = new Set([
|
|
121
|
+
"image/png",
|
|
122
|
+
"image/jpeg",
|
|
123
|
+
"image/gif",
|
|
124
|
+
"image/webp",
|
|
125
|
+
"image/avif"
|
|
126
|
+
]);
|
|
127
|
+
/**
|
|
128
|
+
* Parse a raw email buffer and extract body + attachments.
|
|
129
|
+
* Uses mailparser for the heavy lifting.
|
|
130
|
+
*
|
|
131
|
+
* - Mailparser's default converts CID refs to data: URLs in body_html
|
|
132
|
+
* - Inline images (related=true) are embedded in body_html, NOT in attachments list
|
|
133
|
+
* - Only "real" attachments are returned for tar.gz bundling
|
|
134
|
+
*/
|
|
135
|
+
async function parseEmailWithAttachments(emlBuffer, options) {
|
|
136
|
+
const generateId = options?.generateAttachmentId ?? (() => crypto.randomUUID());
|
|
137
|
+
const { simpleParser } = await loadMailparser$1();
|
|
138
|
+
const parsed = await simpleParser(emlBuffer);
|
|
139
|
+
const attachments = [];
|
|
140
|
+
for (let i = 0; i < (parsed.attachments?.length ?? 0); i++) {
|
|
141
|
+
const att = parsed.attachments[i];
|
|
142
|
+
const contentTypeNorm = normalizeContentType(att.contentType);
|
|
143
|
+
if (SIGNATURE_ARTIFACTS.has(contentTypeNorm)) continue;
|
|
144
|
+
const id = generateId();
|
|
145
|
+
const contentIdNorm = att.cid?.toLowerCase() ?? null;
|
|
146
|
+
const isInline = att.related === true;
|
|
147
|
+
const isDownloadable = !isInline && (att.contentDisposition === "attachment" || !!att.filename || contentTypeNorm === "message/rfc822" || contentTypeNorm === "text/calendar");
|
|
148
|
+
const downloadName = sanitizeFilename(att.filename ?? null, i);
|
|
149
|
+
const tarPath = `${i}_${downloadName}`;
|
|
150
|
+
attachments.push({
|
|
151
|
+
id,
|
|
152
|
+
partIndex: i,
|
|
153
|
+
filename: att.filename ?? null,
|
|
154
|
+
contentType: getHeaderString(att.headers.get("content-type")),
|
|
155
|
+
contentTypeNorm,
|
|
156
|
+
contentDispositionRaw: getHeaderString(att.headers.get("content-disposition")),
|
|
157
|
+
disposition: parseDisposition(att.contentDisposition),
|
|
158
|
+
contentTransferEncoding: getHeaderString(att.headers.get("content-transfer-encoding")),
|
|
159
|
+
contentIdRaw: att.contentId ?? null,
|
|
160
|
+
contentIdNorm,
|
|
161
|
+
downloadName,
|
|
162
|
+
tarPath,
|
|
163
|
+
sizeBytes: att.content.length,
|
|
164
|
+
sha256: sha256Hex(att.content),
|
|
165
|
+
isInline,
|
|
166
|
+
isDownloadable,
|
|
167
|
+
isSafeForInlineServing: isInline && SAFE_INLINE_TYPES.has(contentTypeNorm),
|
|
168
|
+
content: att.content
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
let bodyHtml = null;
|
|
172
|
+
if (parsed.html && typeof parsed.html === "string") bodyHtml = parsed.html;
|
|
173
|
+
return {
|
|
174
|
+
bodyText: parsed.text ?? null,
|
|
175
|
+
bodyHtml,
|
|
176
|
+
attachments,
|
|
177
|
+
subject: parsed.subject ?? null,
|
|
178
|
+
messageId: parsed.messageId ?? null,
|
|
179
|
+
date: parsed.date ?? null,
|
|
180
|
+
dateHeader: getOriginalHeaderValue(parsed, "date"),
|
|
181
|
+
from: parsed.from?.text ?? null,
|
|
182
|
+
to: Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(", ") : parsed.to?.text ?? null,
|
|
183
|
+
replyTo: extractAddresses(parsed.replyTo),
|
|
184
|
+
cc: extractAddresses(parsed.cc),
|
|
185
|
+
bcc: extractAddresses(parsed.bcc),
|
|
186
|
+
inReplyTo: normalizeReferences(parsed.inReplyTo),
|
|
187
|
+
references: normalizeReferences(parsed.references)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Extract email addresses from mailparser's AddressObject format.
|
|
192
|
+
* Handles both single AddressObject and arrays.
|
|
193
|
+
*/
|
|
194
|
+
function extractAddresses(addressObj) {
|
|
195
|
+
if (!addressObj) return null;
|
|
196
|
+
const objects = Array.isArray(addressObj) ? addressObj : [addressObj];
|
|
197
|
+
const addresses = [];
|
|
198
|
+
for (const obj of objects) if (obj && "value" in obj && obj.value) {
|
|
199
|
+
for (const addr of obj.value) if (addr.address) addresses.push({
|
|
200
|
+
address: addr.address,
|
|
201
|
+
name: addr.name || null
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return addresses.length > 0 ? addresses : null;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Normalize References header to array of message IDs.
|
|
208
|
+
* Can be a string (single ID or space-separated IDs) or array.
|
|
209
|
+
*/
|
|
210
|
+
function normalizeReferences(refs) {
|
|
211
|
+
if (!refs) return null;
|
|
212
|
+
if (Array.isArray(refs)) return refs.length > 0 ? refs : null;
|
|
213
|
+
const parts = refs.split(/\s+/).filter(Boolean);
|
|
214
|
+
return parts.length > 0 ? parts : null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Normalize a Content-Type to lowercase media type without parameters.
|
|
218
|
+
*/
|
|
219
|
+
function normalizeContentType(contentType) {
|
|
220
|
+
if (!contentType?.trim()) return "application/octet-stream";
|
|
221
|
+
const mediaType = contentType.split(";")[0].trim().toLowerCase();
|
|
222
|
+
return mediaType || "application/octet-stream";
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Parse disposition string to typed value.
|
|
226
|
+
*/
|
|
227
|
+
function parseDisposition(disposition) {
|
|
228
|
+
if (!disposition) return null;
|
|
229
|
+
const lower = disposition.toLowerCase();
|
|
230
|
+
if (lower === "attachment") return "attachment";
|
|
231
|
+
if (lower === "inline") return "inline";
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Get string representation of a header value.
|
|
236
|
+
*/
|
|
237
|
+
function getHeaderString(value) {
|
|
238
|
+
if (!value) return null;
|
|
239
|
+
if (typeof value === "string") return value;
|
|
240
|
+
if (typeof value === "object" && "value" in value) {
|
|
241
|
+
const v = value;
|
|
242
|
+
if (v.params && Object.keys(v.params).length > 0) {
|
|
243
|
+
const params = Object.entries(v.params).map(([k, val]) => `${k}="${val}"`).join("; ");
|
|
244
|
+
return `${v.value}; ${params}`;
|
|
245
|
+
}
|
|
246
|
+
return v.value;
|
|
247
|
+
}
|
|
248
|
+
return String(value);
|
|
249
|
+
}
|
|
250
|
+
function getOriginalHeaderValue(parsed, key) {
|
|
251
|
+
const headerLines = parsed.headerLines;
|
|
252
|
+
const original = headerLines?.find((header) => header.key?.toLowerCase() === key.toLowerCase())?.line;
|
|
253
|
+
if (!original) return null;
|
|
254
|
+
const separator = original.indexOf(":");
|
|
255
|
+
return separator === -1 ? original : original.slice(separator + 1).trimStart();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Compute SHA-256 hash of a buffer as hex string.
|
|
259
|
+
*/
|
|
260
|
+
function sha256Hex(buffer) {
|
|
261
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Sanitize a filename for safe use in Content-Disposition headers.
|
|
265
|
+
* Prevents path traversal, removes control characters, enforces length limits.
|
|
266
|
+
*/
|
|
267
|
+
function sanitizeFilename(filename, partIndex) {
|
|
268
|
+
if (!filename) return `attachment_${partIndex}`;
|
|
269
|
+
const trimmed = filename.trim();
|
|
270
|
+
if (!trimmed || trimmed === "." || trimmed === "..") return `attachment_${partIndex}`;
|
|
271
|
+
let safe = filename.replace(/[/\\]/g, "_").replace(/:/g, "-").replace(/\.\./g, "_").replace(/[\x00-\x1f\x7f]/g, "").replace(/[^\x20-\x7E]/g, "_").replace(/\s+/g, " ").trim();
|
|
272
|
+
if (safe.length > 200) {
|
|
273
|
+
const lastDot = safe.lastIndexOf(".");
|
|
274
|
+
if (lastDot > 0 && safe.length - lastDot <= 10) {
|
|
275
|
+
const ext = safe.substring(lastDot);
|
|
276
|
+
safe = safe.substring(0, 200 - ext.length) + ext;
|
|
277
|
+
} else safe = safe.substring(0, 200);
|
|
278
|
+
}
|
|
279
|
+
if (!safe) return `attachment_${partIndex}`;
|
|
280
|
+
return safe;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/parser/email-parser.ts
|
|
285
|
+
async function loadMailparser() {
|
|
286
|
+
return import("mailparser");
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Parse a raw .eml file into structured data
|
|
290
|
+
* Uses mailparser library for robust email parsing
|
|
291
|
+
*/
|
|
292
|
+
async function parseEmail(emlRaw) {
|
|
293
|
+
const { simpleParser } = await loadMailparser();
|
|
294
|
+
const parsed = await simpleParser(emlRaw);
|
|
295
|
+
const headers = {};
|
|
296
|
+
parsed.headers.forEach((value, key) => {
|
|
297
|
+
headers[key] = headerValueToString(value);
|
|
298
|
+
});
|
|
299
|
+
const getAddressText = (addr) => {
|
|
300
|
+
if (!addr) return "";
|
|
301
|
+
if (Array.isArray(addr)) return addr.map((entry) => entry.text || "").filter(Boolean).join(", ");
|
|
302
|
+
return addr.text || "";
|
|
303
|
+
};
|
|
304
|
+
return {
|
|
305
|
+
from: getAddressText(parsed.from),
|
|
306
|
+
to: getAddressText(parsed.to),
|
|
307
|
+
subject: parsed.subject,
|
|
308
|
+
text: parsed.text,
|
|
309
|
+
html: parsed.html ? String(parsed.html) : void 0,
|
|
310
|
+
headers,
|
|
311
|
+
messageId: parsed.messageId,
|
|
312
|
+
date: parsed.date
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function headerValueToString(value) {
|
|
316
|
+
if (typeof value === "string") return value;
|
|
317
|
+
if (Array.isArray(value)) {
|
|
318
|
+
if (value.every((entry) => typeof entry === "string")) return value;
|
|
319
|
+
return value.map((entry) => structuredHeaderToString(entry)).filter((entry) => entry.length > 0).join(", ");
|
|
320
|
+
}
|
|
321
|
+
if (value instanceof Date) return value.toISOString();
|
|
322
|
+
if (value && typeof value === "object") return structuredHeaderToString(value);
|
|
323
|
+
return String(value);
|
|
324
|
+
}
|
|
325
|
+
function structuredHeaderToString(value) {
|
|
326
|
+
if (!value || typeof value !== "object") return String(value ?? "");
|
|
327
|
+
if ("text" in value && typeof value.text === "string") return value.text;
|
|
328
|
+
if ("address" in value || "name" in value) {
|
|
329
|
+
const address = value.address;
|
|
330
|
+
return typeof address === "string" ? address : "";
|
|
331
|
+
}
|
|
332
|
+
if ("value" in value) {
|
|
333
|
+
const nested = value.value;
|
|
334
|
+
if (Array.isArray(nested)) return nested.map((entry) => {
|
|
335
|
+
if (typeof entry === "string") return entry;
|
|
336
|
+
if (entry && typeof entry === "object" && "address" in entry) {
|
|
337
|
+
const address = entry.address;
|
|
338
|
+
if (typeof address === "string") return address;
|
|
339
|
+
}
|
|
340
|
+
return structuredHeaderToString(entry);
|
|
341
|
+
}).filter((entry) => entry.length > 0).join(", ");
|
|
342
|
+
if (typeof nested === "string") return nested;
|
|
343
|
+
}
|
|
344
|
+
return JSON.stringify(value);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/parser/mapping.ts
|
|
349
|
+
/**
|
|
350
|
+
* Convert parser output to canonical ParsedDataComplete (snake_case).
|
|
351
|
+
*
|
|
352
|
+
* Maps `bodyHtml` → `body_html` without mutating the HTML content.
|
|
353
|
+
* Preserves the original Date header value when available.
|
|
354
|
+
* Coerces nullable `from`/`to` to non-nullable strings.
|
|
355
|
+
*
|
|
356
|
+
* @param parsed - Output from parseEmailWithAttachments()
|
|
357
|
+
* @param attachmentsDownloadUrl - URL or local path for attachment download (null if no attachments)
|
|
358
|
+
* @returns Canonical ParsedDataComplete ready for JSON serialization
|
|
359
|
+
*/
|
|
360
|
+
function toParsedDataComplete(parsed, attachmentsDownloadUrl) {
|
|
361
|
+
const attachments = toWebhookAttachments(parsed.attachments);
|
|
362
|
+
return {
|
|
363
|
+
status: "complete",
|
|
364
|
+
error: null,
|
|
365
|
+
body_text: parsed.bodyText,
|
|
366
|
+
body_html: parsed.bodyHtml,
|
|
367
|
+
reply_to: parsed.replyTo,
|
|
368
|
+
cc: parsed.cc,
|
|
369
|
+
bcc: parsed.bcc,
|
|
370
|
+
in_reply_to: parsed.inReplyTo,
|
|
371
|
+
references: parsed.references,
|
|
372
|
+
attachments,
|
|
373
|
+
attachments_download_url: attachments.length === 0 ? null : attachmentsDownloadUrl
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Convert parser attachments to canonical WebhookAttachment[] (JSON-safe subset).
|
|
378
|
+
*
|
|
379
|
+
* Filters to only downloadable attachments and strips internal fields
|
|
380
|
+
* (content Buffer, contentId, disposition, etc.).
|
|
381
|
+
*
|
|
382
|
+
* @param attachments - ParsedAttachment[] from the parser
|
|
383
|
+
* @returns WebhookAttachment[] with only public metadata
|
|
384
|
+
*/
|
|
385
|
+
function toWebhookAttachments(attachments) {
|
|
386
|
+
return attachments.filter((att) => att.isDownloadable).map((att) => ({
|
|
387
|
+
filename: att.filename,
|
|
388
|
+
content_type: att.contentTypeNorm,
|
|
389
|
+
size_bytes: att.sizeBytes,
|
|
390
|
+
sha256: att.sha256,
|
|
391
|
+
part_index: att.partIndex,
|
|
392
|
+
tar_path: att.tarPath
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Convert AttachmentMetadata[] (from extractAttachmentMetadata) to WebhookAttachment[].
|
|
397
|
+
*
|
|
398
|
+
* AttachmentMetadata is already in snake_case and JSON-safe, so this is
|
|
399
|
+
* essentially a type assertion. Useful when you have metadata from the bundler
|
|
400
|
+
* rather than raw ParsedAttachment[].
|
|
401
|
+
*/
|
|
402
|
+
function attachmentMetadataToWebhookAttachments(metadata) {
|
|
403
|
+
return metadata.map((m) => ({
|
|
404
|
+
filename: m.filename,
|
|
405
|
+
content_type: m.content_type,
|
|
406
|
+
size_bytes: m.size_bytes,
|
|
407
|
+
sha256: m.sha256,
|
|
408
|
+
part_index: m.part_index,
|
|
409
|
+
tar_path: m.tar_path
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Extract canonical email headers from parser output.
|
|
414
|
+
*
|
|
415
|
+
* Converts camelCase parser fields to the shape expected by
|
|
416
|
+
* EmailReceivedEvent.email.headers.
|
|
417
|
+
*
|
|
418
|
+
* @param parsed - Output from parseEmailWithAttachments()
|
|
419
|
+
* @returns Headers object matching the canonical schema
|
|
420
|
+
*/
|
|
421
|
+
function toCanonicalHeaders(parsed) {
|
|
422
|
+
const from = requireNonEmptyHeader(parsed.from, "From");
|
|
423
|
+
const to = requireNonEmptyHeader(parsed.to, "To");
|
|
424
|
+
return {
|
|
425
|
+
message_id: parsed.messageId,
|
|
426
|
+
subject: parsed.subject,
|
|
427
|
+
from,
|
|
428
|
+
to,
|
|
429
|
+
date: parsed.dateHeader ?? null
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function requireNonEmptyHeader(value, headerName) {
|
|
433
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`Parsed email is missing a usable ${headerName} header value`);
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
//#endregion
|
|
438
|
+
export { attachmentMetadataToWebhookAttachments, bundleAttachments, extractAttachmentMetadata, getAttachmentsStorageKey, normalizeContentType, parseEmail, parseEmailWithAttachments, sanitizeFilename, sha256Hex, toCanonicalHeaders, toParsedDataComplete, toWebhookAttachments };
|