├── .npmignore ├── .gitignore ├── test ├── canvas.png ├── testOut.msg ├── testOut_attach.msg ├── test_internal.msg ├── msgKitOut_attach.msg ├── testOut_noattach.msg ├── msgKitOut_noattach.msg ├── utils.ts ├── tsconfig.json └── index.ts ├── lib ├── index.ts ├── address │ ├── address.ts │ ├── receiving.ts │ ├── representing.ts │ ├── receiving_representing.ts │ ├── one_off_entry_id.spec.ts │ ├── sender.ts │ ├── recipients.ts │ └── one_off_entry_id.ts ├── streams │ ├── recipient_properties.ts │ ├── guid_stream.ts │ ├── top_level_properties.ts │ ├── named_properties.ts │ ├── string_stream.ts │ └── entry_stream.ts ├── attachments.ts ├── attachments.spec.ts ├── utils │ ├── mapi.spec.ts │ ├── time.spec.ts │ ├── time.ts │ ├── utils.spec.ts │ ├── lcid.ts │ ├── mapi.ts │ └── utils.ts ├── helpers │ ├── strings.spec.ts │ ├── rtf_compressor.spec.ts │ ├── strings.ts │ ├── crc32.ts │ ├── rtf_compressor.ts │ └── crc32.spec.ts ├── mime │ ├── decode │ │ ├── size_parser.ts │ │ └── rfc2231decoder.ts │ └── header │ │ ├── received.ts │ │ ├── rfc_mail_address.ts │ │ ├── header_field_parser.ts │ │ ├── rfc2047.ts │ │ └── rfc2047.spec.ts ├── cfb_storage.ts ├── message.ts ├── attachment.ts ├── property.ts ├── structures │ └── report_tag.ts └── properties.ts ├── .flowconfig ├── examples ├── browser.html ├── node.js └── comparer.js ├── types └── rfc2822.d.ts ├── rollup.config.js ├── tsconfig.json ├── rollup_test.config.js ├── package.json ├── lcid.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | .idea/ 4 | dist/test 5 | dist/ 6 | -------------------------------------------------------------------------------- /test/canvas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/canvas.png -------------------------------------------------------------------------------- /test/testOut.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/testOut.msg -------------------------------------------------------------------------------- /test/testOut_attach.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/testOut_attach.msg -------------------------------------------------------------------------------- /test/test_internal.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/test_internal.msg -------------------------------------------------------------------------------- /test/msgKitOut_attach.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/msgKitOut_attach.msg -------------------------------------------------------------------------------- /test/testOut_noattach.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/testOut_noattach.msg -------------------------------------------------------------------------------- /test/msgKitOut_noattach.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tutao/oxmsg/HEAD/test/msgKitOut_noattach.msg -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | export function uint8ToBase16(u8Arr: Uint8Array | Array): string { 2 | return Buffer.from(u8Arr).toString('hex') 3 | } -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export {utf16LeArrayToString, utf8ArrayToString, bigInt64FromParts} from "./utils/utils.js" 2 | export {fileTimeToDate, dateToFileTime} from "./utils/time.js" 3 | export {Email} from "./email.js" 4 | export {AttachmentType, MessageEditorFormat} from "./enums.js" 5 | export {Attachment} from "./attachment.js" 6 | export * as CFB from "cfb" -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/build/.* 3 | .*/resources/.* 4 | .*/buildSrc/.* 5 | .app-android/.* 6 | .app-ios/.* 7 | .fdroid-metadata-workaround/.* 8 | 9 | [libs] 10 | flow 11 | 12 | [options] 13 | module.ignore_non_literal_requires=true 14 | include_warnings=true 15 | 16 | [lints] 17 | untyped-import=error 18 | untyped-type-import=error 19 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../build/test", 5 | "declaration": false, 6 | "declarationDir": null, 7 | "noImplicitAny": false 8 | }, 9 | "include": [ 10 | ".", 11 | "../types" 12 | ], 13 | "exclude": [ 14 | "../node_modules" 15 | ] 16 | } -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | 3 | import "../lib/mime/header/rfc2047.spec.js" 4 | import "../lib/helpers/crc32.spec.js" 5 | import "../lib/utils/mapi.spec.js" 6 | import "../lib/address/one_off_entry_id.spec.js" 7 | import "../lib/helpers/rtf_compressor.spec.js" 8 | import "../lib/utils/time.spec.js" 9 | import "../lib/utils/utils.spec.js" 10 | import "../lib/attachments.spec.js" 11 | 12 | o.run() -------------------------------------------------------------------------------- /lib/address/address.ts: -------------------------------------------------------------------------------- 1 | import type {AddressType} from "../enums" 2 | import {isNullOrWhiteSpace} from "../utils/utils" 3 | 4 | export class Address { 5 | readonly addressType: AddressType 6 | readonly email: string 7 | readonly displayName: string 8 | 9 | constructor(email: string, displayName: string, addressType: AddressType = "SMTP") { 10 | this.email = email 11 | this.displayName = isNullOrWhiteSpace(displayName) ? email : displayName 12 | this.addressType = addressType 13 | } 14 | } -------------------------------------------------------------------------------- /examples/browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Use library directly with <script> tag 6 | 7 | 8 |

(1-4)Use library directly with <script> tag

9 | 10 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/address/receiving.ts: -------------------------------------------------------------------------------- 1 | import type {AddressType} from "../enums" 2 | import {Address} from "./address" 3 | import {TopLevelProperties} from "../streams/top_level_properties" 4 | import {PropertyTags} from "../property_tags" 5 | 6 | export class Receiving extends Address { 7 | constructor(email: string, displayName: string, addressType: AddressType = "SMTP") { 8 | super(email, displayName, addressType) 9 | } 10 | 11 | writeProperties(stream: TopLevelProperties) { 12 | stream.addProperty(PropertyTags.PR_RECEIVED_BY_EMAIL_ADDRESS_W, this.email) 13 | stream.addProperty(PropertyTags.PR_RECEIVED_BY_NAME_W, this.displayName) 14 | stream.addProperty(PropertyTags.PR_RECEIVED_BY_ADDRTYPE_W, this.addressType) 15 | } 16 | } -------------------------------------------------------------------------------- /lib/address/representing.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "./address" 2 | import type {AddressType} from "../enums" 3 | import {TopLevelProperties} from "../streams/top_level_properties" 4 | import {PropertyTags} from "../property_tags" 5 | 6 | export class Representing extends Address { 7 | constructor(email: string, displayName: string, addressType: AddressType = "SMTP") { 8 | super(email, displayName, addressType) 9 | } 10 | 11 | writeProperties(stream: TopLevelProperties) { 12 | stream.addProperty(PropertyTags.PR_SENT_REPRESENTING_EMAIL_ADDRESS_W, this.email) 13 | stream.addProperty(PropertyTags.PR_SENT_REPRESENTING_NAME_W, this.displayName) 14 | stream.addProperty(PropertyTags.PR_SENT_REPRESENTING_ADDRTYPE_W, this.addressType) 15 | } 16 | } -------------------------------------------------------------------------------- /lib/address/receiving_representing.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "./address" 2 | import type {AddressType} from "../enums" 3 | import {TopLevelProperties} from "../streams/top_level_properties" 4 | import {PropertyTags} from "../property_tags" 5 | 6 | export class ReceivingRepresenting extends Address { 7 | constructor(email: string, displayName: string, addressType: AddressType = "SMTP") { 8 | super(email, displayName, addressType) 9 | } 10 | 11 | writeProperties(stream: TopLevelProperties) { 12 | stream.addProperty(PropertyTags.PR_RCVD_REPRESENTING_EMAIL_ADDRESS_W, this.email) 13 | stream.addProperty(PropertyTags.PR_RCVD_REPRESENTING_NAME_W, this.displayName) 14 | stream.addProperty(PropertyTags.PR_RCVD_REPRESENTING_ADDRTYPE_W, this.addressType) 15 | } 16 | } -------------------------------------------------------------------------------- /lib/streams/recipient_properties.ts: -------------------------------------------------------------------------------- 1 | import {Properties} from "../properties.js" 2 | import type {CFBStorage} from "../cfb_storage.js" 3 | import type ByteBuffer from "bytebuffer" 4 | 5 | /** 6 | * The properties stream contained inside an Recipient storage object. 7 | */ 8 | export class RecipientProperties extends Properties { 9 | override writeProperties(storage: CFBStorage, prefix: (arg0: ByteBuffer) => void, messageSize?: number): number { 10 | const recipPropPrefix = (buf: ByteBuffer) => { 11 | prefix(buf) 12 | // Reserved(8 bytes): This field MUST be set to zero when writing a .msg file and MUST be ignored 13 | // when reading a.msg file. 14 | buf.writeUint64(0) 15 | } 16 | 17 | return super.writeProperties(storage, recipPropPrefix, messageSize) 18 | } 19 | } -------------------------------------------------------------------------------- /types/rfc2822.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'address-rfc2822' { 2 | declare class Address { 3 | address: string 4 | phrase: string 5 | comment: string 6 | 7 | name(): string 8 | 9 | format(): string 10 | 11 | user(): string 12 | 13 | host(): string 14 | } 15 | 16 | type StartAt = 17 | | "address" 18 | | "address-list" 19 | | "angle-addr" 20 | | "from" 21 | | "group" 22 | | "mailbox" 23 | | "mailbox-list" 24 | | "reply-to" 25 | | "sender" 26 | 27 | type ParseOpts = { 28 | startAt: StartAt, 29 | allowAtInDisplayName: boolean, 30 | allowCommaInDisplayName: boolean, 31 | } 32 | 33 | declare function parse(line: string, opts: Partial | null = null): Array
34 | } 35 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import alias from '@rollup/plugin-alias' 4 | import path from 'path' 5 | import typescript from "@rollup/plugin-typescript"; 6 | 7 | export default { 8 | input: 'lib/index.ts', 9 | plugins: [ 10 | typescript(), 11 | alias({ 12 | entries: { 13 | "uuid": path.resolve("./node_modules/uuid/dist/esm-node/index.js"), 14 | } 15 | }), 16 | nodeResolve({ 17 | preferBuiltins: true, 18 | }), 19 | commonjs({ 20 | transformMixedEsModules: true, 21 | ignore: [ 22 | 'memcpy' // optional dep of bytebuffer 23 | ], 24 | include: ["node_modules/**"], 25 | exclude: ["lib/**"] 26 | }), 27 | ], 28 | output: [ 29 | { 30 | dir: 'dist', 31 | format: 'esm', 32 | sourcemap: true, 33 | name: 'oxmsg' 34 | } 35 | ], 36 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es2020", 5 | "dom" 6 | ], 7 | "target": "es2020", 8 | "declaration": true, 9 | "declarationDir": "./dist/types", 10 | "outDir": "./dist", 11 | "skipLibCheck": true, 12 | 13 | "noEmitOnError": true, 14 | "noErrorTruncation": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "allowSyntheticDefaultImports": true, 18 | 19 | "strict": true, 20 | "noImplicitOverride": true, 21 | "noImplicitReturns": true, 22 | "exactOptionalPropertyTypes": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noEmit": true 27 | }, 28 | "include": [ 29 | "lib", 30 | "types" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "dist", 35 | "lib/**/*.spec.ts" 36 | ] 37 | } -------------------------------------------------------------------------------- /lib/attachments.ts: -------------------------------------------------------------------------------- 1 | import {Attachment} from "./attachment.js" 2 | import {X8} from "./utils/utils.js" 3 | import {PropertyTagLiterals} from "./property_tags.js" 4 | 5 | export class Attachments extends Array { 6 | /** 7 | * Writes the Attachment objects to the given storage and sets all the needed properties 8 | * @param rootStorage 9 | * @returns {number} the total size of the written attachment objects and their properties 10 | */ 11 | writeToStorage(rootStorage: any): number { 12 | let size = 0 13 | 14 | for (let i = 0; i < this.length; i++) { 15 | const attachment = this[i] 16 | const storage = rootStorage.addStorage(PropertyTagLiterals.AttachmentStoragePrefix + X8(i)) 17 | size += attachment.writeProperties(storage, i) 18 | } 19 | 20 | return size 21 | } 22 | 23 | attach(attachment: Attachment): void { 24 | if (this.length >= 2048) throw new Error("length > 2048 => too many attachments!") 25 | this.push(attachment) 26 | } 27 | } -------------------------------------------------------------------------------- /rollup_test.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import json from '@rollup/plugin-json' 4 | import alias from '@rollup/plugin-alias' 5 | import typescript from '@rollup/plugin-typescript' 6 | import path from 'path' 7 | 8 | export default { 9 | input: 'test/index.ts', 10 | plugins: [ 11 | typescript({tsconfig: "test/tsconfig.json"}), 12 | alias({ 13 | entries: { 14 | "uuid": path.resolve("./node_modules/uuid/dist/esm-node/index.js"), 15 | } 16 | }), 17 | nodeResolve({ 18 | preferBuiltins: true, 19 | }), 20 | commonjs({ 21 | transformMixedEsModules: true, 22 | ignore: [ 23 | 'memcpy' // optional dep of bytebuffer 24 | ] 25 | }), 26 | json(), // for iconv-lite 27 | ], 28 | output: [ 29 | { 30 | dir: 'build/test', 31 | format: 'es', 32 | sourcemap: true, 33 | // dont preserve module structure or we get errors when trying to run 34 | preserveModules: false, 35 | exports: "named" 36 | }, 37 | ], 38 | treeshake: false, 39 | } -------------------------------------------------------------------------------- /lib/address/one_off_entry_id.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | import {OneOffEntryId} from "./one_off_entry_id" 3 | import {uint8ToBase16} from "../../test/utils" 4 | 5 | o.spec("OneOffEntryId", function () { 6 | o("test value serialization", function () { 7 | const ooei = new OneOffEntryId("peterpan@neverland.com", "", "SMTP", 2, false) 8 | const buf = ooei.toByteArray() 9 | const expect = ( 10 | "00000000812b1fa4bea310199d6e00dd" + 11 | "010f5402000001e87000650074006500" + 12 | "7200700061006e0040006e0065007600" + 13 | "650072006c0061006e0064002e006300" + 14 | "6f006d00000053004d00540050000000" + 15 | "70006500740065007200700061006e00" + 16 | "40006e0065007600650072006c006100" + 17 | "6e0064002e0063006f006d000000" 18 | ).match(/.{1,2}/g)! 19 | const actual = uint8ToBase16(buf).match(/.{1,2}/g)! 20 | o(actual.length).equals(expect.length) 21 | expect.map((b, i) => o(actual[i]).equals(b)("at byte " + i)) 22 | }) 23 | }) -------------------------------------------------------------------------------- /lib/attachments.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | import {Attachments} from "./attachments.js" 3 | import CFB from "cfb" 4 | import {uint8ToBase16} from "../test/utils.js" 5 | import {Attachment} from "./attachment.js" 6 | import {Email} from "./email.js" 7 | import {CFBStorage} from "./cfb_storage.js" 8 | 9 | o.spec("Attachments", function () { 10 | o("attach an attachment", function () { 11 | const data = new Uint8Array(Buffer.from("0123456789")) 12 | const email = new Email() 13 | email.attach(new Attachment(data, "data.txt")) 14 | const storage = new CFBStorage() 15 | const attachments = new Attachments() 16 | attachments.attach(new Attachment(data, "data.txt")) 17 | attachments.writeToStorage(storage) 18 | const bytes = storage.toBytes() 19 | const cfb = CFB.read(bytes, { 20 | type: "binary", 21 | }) 22 | const attachmentStream = CFB.find(cfb, "__substg1.0_37010102")?.content 23 | o(uint8ToBase16(data)).equals(uint8ToBase16(attachmentStream || [])) 24 | }) 25 | }) -------------------------------------------------------------------------------- /examples/node.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import {Attachment, Email} from "../dist/index.js"; 4 | 5 | const email = new Email() 6 | const attachment = new Attachment(Uint8Array.from(fs.readFileSync('./test/canvas.png')), 'canvas.png') 7 | 8 | email.subject("This is the subject") 9 | .bodyText("") 10 | .bodyHtml("This is a message") 11 | .to("crocodile@neverland.com") 12 | .attach(attachment) 13 | .sender("peterpan@neverland.com") 14 | email.iconIndex = 0x00000103 15 | const content = email.msg() 16 | 17 | 18 | fs.writeFileSync('./test/testOut.msg', content) 19 | 20 | // const {CFB} = require('../build/oxmsg.develop.js') 21 | // 22 | // const storageSorted = CFB.parse(content).FileIndex 23 | // const testStorage = CFB.parse(fs.readFileSync('./test/test.msg')).FileIndex 24 | // 25 | // console.log(storageSorted, testStorage) 26 | 27 | // const f = CFB.utils.cfb_new() 28 | // 29 | // const entry = CFB.utils.cfb_add(f, "/hello", Uint8Array.of(1, 2, 3, 4, 5, 6)) 30 | // const entry2 = CFB.utils.cfb_add(f, "some/deeper/nesting/going/on", Uint8Array.of(1,3,3,7)) 31 | // const entry3 = CFB.utils.cfb_add(f, "some/deeper/nesting/going/on", Uint8Array.of(1,3,3,8)) 32 | // console.log(entry, entry2, entry3) 33 | // console.log(f) 34 | -------------------------------------------------------------------------------- /lib/utils/mapi.spec.ts: -------------------------------------------------------------------------------- 1 | import {generateEntryId, generateInstanceKey, generateRecordKey, generateSearchKey} from "./mapi.js" 2 | import o from "ospec" 3 | 4 | function uint8ToBase16(u8Arr) { 5 | return Buffer.from(u8Arr).toString("hex") 6 | } 7 | 8 | o.spec("uuid generation", function () { 9 | o("generateEntryId", function () { 10 | const v = generateEntryId() 11 | o(v.byteLength).equals(72) 12 | o(v[16]).equals("-".charCodeAt(0)) 13 | o(v[26]).equals("-".charCodeAt(0)) 14 | o(v[36]).equals("-".charCodeAt(0)) 15 | o(v[46]).equals("-".charCodeAt(0)) 16 | }) 17 | o("generateInstanceKey", function () { 18 | const v = generateInstanceKey() 19 | o(v.byteLength).equals(4) 20 | const v2 = generateInstanceKey() 21 | o(v).equals(v2) 22 | }) 23 | o("generateRecordKey", function () { 24 | const v = generateRecordKey() 25 | o(v.byteLength).equals(16) 26 | o(v instanceof Uint8Array).equals(true) 27 | }) 28 | o("generateSearchKey", function () { 29 | const addressType = "SMTP" 30 | const emailAddress = "crocodile@neverland.com" 31 | const v = generateSearchKey(addressType, emailAddress) 32 | o(v.byteLength).equals(54) 33 | o(uint8ToBase16(v)).equals("53004d0054005000630072006f0063006f00640069006c00650040006e0065007600650072006c0061006e0064002e0063006f006d00") 34 | }) 35 | }) -------------------------------------------------------------------------------- /lib/utils/time.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec/ospec.js" 2 | import * as time from "./time.js" 3 | import {dateToFileTime, fileTimeToDate} from "./time.js" 4 | 5 | o.spec("time", function () { 6 | const dateAndLabel = dateStr => [new Date(Date.parse(dateStr)), dateStr] 7 | 8 | const filetimeDateMap = [ 9 | [0n, ...dateAndLabel("01 Jan 1601 00:00:00 UTC")], 10 | [116444736000000000n, ...dateAndLabel("01 Jan 1970 00:00:00 UTC")], 11 | [132586423320000000n, ...dateAndLabel("24 Feb 2021 12:12:12 UTC")], 12 | [124155180000000000n, ...dateAndLabel("08 Jun 1994 03:00:00 UTC")], 13 | [132533279990000000n, ...dateAndLabel("24 Dec 2020 23:59:59 UTC")], 14 | [125690437230000000n, ...dateAndLabel("20 Apr 1999 01:02:03 UTC")], 15 | ] 16 | o("dateToFileTime", function () { 17 | for (let [fileTime, date, label] of filetimeDateMap) { 18 | const result = dateToFileTime(date) 19 | o(result).equals(fileTime)(`dateToFileTime(${date.toISOString()}) = ${result}, expected: ${fileTime}, difference: ${result - fileTime}`) 20 | } 21 | }) 22 | o("fileTimeToDate", function () { 23 | for (let [fileTime, date, label] of filetimeDateMap) { 24 | const result = fileTimeToDate(fileTime) 25 | o(result.getTime()).equals(date.getTime())(`fileTimeToDate(${fileTime}) = ${result.toISOString()}, expected: ${label}`) 26 | } 27 | }) 28 | }) -------------------------------------------------------------------------------- /lib/helpers/strings.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | import {Strings} from "./strings" 3 | import fs from "fs" 4 | // big list of naughty strings converted with MsgKit 5 | const inStrings = JSON.parse(fs.readFileSync("test/blns.json", {encoding: "utf8"})) 6 | const outStrings = JSON.parse(fs.readFileSync("test/blns.out.json", {encoding: "utf8"})) 7 | const suite = inStrings.map((s, i) => ({ 8 | label: i.toString(), 9 | pre: s, 10 | post: outStrings[i], 11 | })) 12 | const tests = [ 13 | { 14 | label: "ansi", 15 | pre: "Hello World", 16 | post: "{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 {\\*\\htmltag1 Hello World }}", 17 | }, 18 | { 19 | label: "ascii", 20 | pre: "°^â", 21 | post: "{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 {\\*\\htmltag1 \\'b0^\\'e2 }}", 22 | }, 23 | { 24 | label: "escaped rtf chars", 25 | pre: "{}\\", 26 | post: "{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 {\\*\\htmltag1 \\{\\}\\\\ }}", 27 | }, 28 | { 29 | label: "unicode", 30 | // contains unicode vegetable 31 | pre: "TEST 🍆 € ¥ TEST", 32 | post: "{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 {\\*\\htmltag1 TEST \\u55356?\\u57158? \\u8364? \\u65509? TEST }}", 33 | }, 34 | ] 35 | o.spec("Strings", function () { 36 | tests.concat(suite).forEach(t => { 37 | o(t.label, function () { 38 | o(Strings.escapeRtf(t.pre)).equals(t.post) 39 | }) 40 | }) 41 | }) -------------------------------------------------------------------------------- /lib/helpers/rtf_compressor.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | import {stringToUtf8Array} from "../utils/utils" 3 | import {compress} from "./rtf_compressor" 4 | import fs from "fs" 5 | import {uint8ToBase16} from "../../test/utils" 6 | 7 | // big list of naughty strings converted to escaped rtf 8 | const inStrings = JSON.parse(fs.readFileSync("test/blns.out.json", {encoding: "utf8"})) 9 | // blns rtf compressed with MsgKit 10 | const outStrings = JSON.parse(fs.readFileSync("test/blns.out.compressed.json", {encoding: "utf8"})) 11 | const suite = inStrings.map((s, i) => ({ 12 | label: "rtf " + i.toString(), 13 | pre: stringToUtf8Array(s), 14 | post: Uint8Array.from(Buffer.from(outStrings[i], "hex")), 15 | })) 16 | 17 | o.spec("RtfCompressor", function () { 18 | o("empty string", function () { 19 | const input = stringToUtf8Array("") 20 | const output = "0f000000000000004c5a467527d7ca10010cf0" 21 | o(uint8ToBase16(compress(input))).equals(output) 22 | }) 23 | o("example 1 from spec", function () { 24 | const input = stringToUtf8Array("{\\rtf1\\ansi\\ansicpg1252\\pard hello world}\r\n") 25 | const output = "2d0000002b0000004c5a4675f1c5c7a703000a007263706731323542320af32068656c090020627705b06c647d0a800fa0" 26 | o(uint8ToBase16(compress(input))).equals(output) 27 | }) 28 | o("example 2 from spec", function () { 29 | const input = stringToUtf8Array("{\\rtf1 WXYZWXYZWXYZWXYZWXYZ}") 30 | const output = "1a0000001c0000004c5a4675e2d44b51410004205758595a0d6e7d010eb0" 31 | o(uint8ToBase16(compress(input))).equals(output) 32 | }) 33 | suite.forEach(t => { 34 | o(t.label, function () { 35 | o(uint8ToBase16(compress(t.pre))).equals(uint8ToBase16(t.post)) 36 | }) 37 | }) 38 | }) -------------------------------------------------------------------------------- /lib/streams/guid_stream.ts: -------------------------------------------------------------------------------- 1 | import {makeByteBuffer} from "../utils/utils.js" 2 | import {PropertyTagLiterals} from "../property_tags.js" 3 | import {CFBStorage} from "../cfb_storage"; 4 | 5 | /** 6 | * The GUID stream MUST be named "__substg1.0_00020102". It MUST store the property set GUID 7 | * part of the property name of all named properties in the Message object or any of its subobjects, 8 | * except for those named properties that have PS_MAPI or PS_PUBLIC_STRINGS, as described in [MSOXPROPS] 9 | * section 1.3.2, as their property set GUID. 10 | * The GUIDs are stored in the stream consecutively like an array. If there are multiple named properties 11 | * that have the same property set GUID, then the GUID is stored only once and all the named 12 | * properties will refer to it by its index 13 | */ 14 | export class GuidStream extends Array { 15 | /** 16 | * create this object 17 | * @param storage the storage that contains the PropertyTags.GuidStream 18 | */ 19 | constructor(storage?: CFBStorage) { 20 | super() 21 | if (storage == null) return 22 | const stream = storage.getStream(PropertyTagLiterals.GuidStream) 23 | const buf = makeByteBuffer(undefined, stream) 24 | 25 | while (buf.offset < buf.limit) { 26 | const guid = buf.slice(buf.offset, buf.offset + 16).toArrayBuffer(true) 27 | this.push(new Uint8Array(guid)) 28 | } 29 | } 30 | 31 | /** 32 | * writes all the guids as a stream to the storage 33 | * @param storage 34 | */ 35 | write(storage: any): void { 36 | const buf = makeByteBuffer() 37 | this.forEach(g => { 38 | buf.append(g) 39 | storage.addStream(PropertyTagLiterals.GuidStream, buf) 40 | }) 41 | } 42 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tutao/oxmsg", 3 | "version": "0.1.1", 4 | "description": "js library for Microsoft Outlook Item files (.msg)", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rollup --config rollup.config.js", 8 | "exec:nodejs": "node examples/node.js", 9 | "clean": "rm -rf ./dist ./build", 10 | "types": "tsc", 11 | "test": "rollup --config rollup_test.config.js && node 'build/test/index.js'" 12 | }, 13 | "files": [ 14 | "lib/*", 15 | "dist/*" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/tutao/oxmsg.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/tutao/oxmsg/issues" 23 | }, 24 | "author": "tutao GmbH", 25 | "license": "MIT", 26 | "homepage": "https://github.com/tutao/oxmsg#readme", 27 | "dependencies": { 28 | "address-rfc2822": "^2.0.6", 29 | "cfb": "^1.2.0", 30 | "iconv-lite": "^0.6.2", 31 | "uuid": "^8.3.1", 32 | "bytebuffer": "^5.0.1" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.16.5", 36 | "@babel/plugin-proposal-class-properties": "^7.14.5", 37 | "@babel/plugin-transform-classes": "^7.14.5", 38 | "@babel/plugin-transform-typescript": "^7.16.1", 39 | "@rollup/plugin-alias": "^3.1.1", 40 | "@rollup/plugin-babel": "^5.3.0", 41 | "@rollup/plugin-commonjs": "^17.0.0", 42 | "@rollup/plugin-json": "^4.1.0", 43 | "@rollup/plugin-node-resolve": "^11.0.1", 44 | "@rollup/plugin-typescript": "^8.3.0", 45 | "@types/uuid": "^8.3.1", 46 | "@types/bytebuffer": "^5.0.43", 47 | "@types/iconv-lite": "^0.0.1", 48 | "ospec": "^4.1.1", 49 | "rollup": "^2.35.1", 50 | "rollup-plugin-terser": "^7.0.2", 51 | "typescript": "^4.5.4" 52 | }, 53 | "type": "module", 54 | "types": "dist/types" 55 | } 56 | -------------------------------------------------------------------------------- /lib/helpers/strings.ts: -------------------------------------------------------------------------------- 1 | import {x2} from "../utils/utils.js" 2 | 3 | export class Strings { 4 | /** 5 | * returns the str as an escaped RTF string 6 | * @param str {string} string to escape 7 | */ 8 | static escapeRtf(str: string): string { 9 | const rtfEscaped: string[] = [] 10 | const escapedChars = ["{", "}", "\\"] 11 | 12 | for (const glyph of str) { 13 | const charCode = glyph.charCodeAt(0) 14 | if (charCode <= 31) continue // non-printables 15 | 16 | if (charCode <= 127) { 17 | // 7-bit ascii 18 | if (escapedChars.includes(glyph)) rtfEscaped.push("\\") 19 | rtfEscaped.push(glyph) 20 | } else if (charCode <= 255) { 21 | // 8-bit ascii 22 | rtfEscaped.push("\\'" + x2(charCode)) 23 | } else { 24 | // unicode. may consist of multiple code points 25 | for (const codepoint of glyph.split("")) { 26 | // TODO: 27 | // RTF control words generally accept signed 16-bit numbers as arguments. 28 | // For this reason, Unicode values greater than 32767 must be expressed as negative numbers. 29 | // 30 | // we want to escape unicode codepoints "🍆" -> "\\u55356?\\u57158?" as specced for rtf 1.5. 31 | // the ? is the "equivalent character(s) in ANSI representation" mentioned for the \uN control word, 32 | // so non-unicode readers will show a ? 33 | rtfEscaped.push("\\u") 34 | rtfEscaped.push(codepoint.charCodeAt(0).toString()) 35 | rtfEscaped.push("?") 36 | } 37 | } 38 | } 39 | 40 | return "{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 {\\*\\htmltag1 " + rtfEscaped.join("") + " }}" 41 | } 42 | } -------------------------------------------------------------------------------- /lib/mime/decode/size_parser.ts: -------------------------------------------------------------------------------- 1 | const unitsToMultiplicator = { 2 | "": 1, 3 | B: 1, 4 | KB: 1024, 5 | MB: 1024 * 1024, 6 | GB: 1024 * 1024 * 1024, 7 | TB: 1024 * 1024 * 1024 * 1024, 8 | } as const 9 | 10 | /** 11 | * Thanks to http://stackoverflow.com/a/7333402/477854 for inspiration 12 | * This class can convert from strings like "104 kB" (104 kilobytes) to bytes. 13 | * It does not know about differences such as kilobits vs kilobytes. 14 | */ 15 | export class SizeParser { 16 | static parse(value: string): number { 17 | value = value.trim() 18 | const unit = SizeParser.extractUnit(value) 19 | const valueWithoutUnit = value.substring(0, value.length - unit.length).trim() 20 | const multiplicatorForUnit = SizeParser.multiplicatorForUnit(unit) 21 | const size = parseFloat(valueWithoutUnit) 22 | return Math.floor(multiplicatorForUnit * size) 23 | } 24 | 25 | static extractUnit(sizeWithUnit: string): string { 26 | // start right, end at the first digit 27 | const lastChar = sizeWithUnit.length - 1 28 | let unitLength = 0 29 | 30 | while ( 31 | unitLength <= lastChar && 32 | sizeWithUnit[lastChar - unitLength] !== " " && // stop when a space 33 | !SizeParser.isDigit(sizeWithUnit[lastChar - unitLength]) // or digit is found 34 | ) 35 | unitLength++ 36 | 37 | return sizeWithUnit.substring(sizeWithUnit.length - unitLength).toUpperCase() 38 | } 39 | 40 | static isDigit(value: string): boolean { 41 | return value >= "0" && value <= "9" 42 | } 43 | 44 | static multiplicatorForUnit(unit: string): number { 45 | unit = unit.toUpperCase() 46 | if (unit in unitsToMultiplicator) { 47 | return unitsToMultiplicator[unit as keyof typeof unitsToMultiplicator] 48 | } else { 49 | throw new Error("illegal or unknown unit:" + unit) 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /lib/utils/time.ts: -------------------------------------------------------------------------------- 1 | /** https://stackoverflow.com/a/15550284 2 | * Convert a Microsoft OADate to ECMAScript Date 3 | * Treat all values as local. 4 | * OADate = number of days since 30 dec 1899 as a double value 5 | * @param {string|number} oaDate - OADate value 6 | * @returns {Date} 7 | */ 8 | export function oADateToDate(oaDate: number): Date { 9 | // Treat integer part as whole days 10 | const days = Math.floor(oaDate) 11 | // Treat decimal part as part of 24hr day, always +ve 12 | const ms = Math.abs((oaDate - days) * 8.64e7) 13 | // Add days and add ms 14 | return new Date(1899, 11, 30 + days, 0, 0, 0, ms) 15 | } 16 | 17 | /** https://stackoverflow.com/a/15550284 18 | * Convert an ECMAScript Date to a Microsoft OADate 19 | * Treat all dates as local. 20 | * @param {Date} date - Date to convert 21 | * @returns {Date} 22 | */ 23 | export function dateToOADate(date: Date): number { 24 | const temp = new Date(date) 25 | // Set temp to start of day and get whole days between dates, 26 | const days = Math.round((temp.setHours(0, 0, 0, 0) - new Date(1899, 11, 30).getTime()) / 8.64e7) 27 | // Get decimal part of day, OADate always assumes 24 hours in day 28 | const partDay = Math.abs((date.getTime() - temp.getTime()) % 8.64e7) / 8.64e7 //.toFixed(10) 29 | 30 | return days + partDay //.substr(1) 31 | } 32 | export const FT_TICKS_PER_MS = 10000n 33 | const FILE_TIME_ZERO = new Date(Date.parse("01 Jan 1601 00:00:00 UTC")) 34 | // Date: milliseconds since 1. January 1970 (UTC) 35 | // FileTime: unsigned 64 Bit, 100ns units since 1. January 1601 (UTC) 36 | // ms between 01.01.1970 and 01.01.1601: 11644473600 37 | export function fileTimeToDate(fileTime: bigint): Date { 38 | return new Date(FILE_TIME_ZERO.getTime() + Number(fileTime / FT_TICKS_PER_MS)) 39 | } 40 | export function dateToFileTime(date: Date): bigint { 41 | const msSinceFileTimeEpoch = BigInt(date.getTime()) - BigInt(FILE_TIME_ZERO.getTime()) 42 | return msSinceFileTimeEpoch * FT_TICKS_PER_MS 43 | } -------------------------------------------------------------------------------- /lib/address/sender.ts: -------------------------------------------------------------------------------- 1 | import type {AddressType} from "../enums" 2 | import {MessageFormat} from "../enums" 3 | import {Address} from "./address" 4 | import {PropertyTags} from "../property_tags" 5 | import type {TopLevelProperties} from "../streams/top_level_properties" 6 | import {OneOffEntryId} from "./one_off_entry_id" 7 | 8 | export class Sender extends Address { 9 | private readonly _messageFormat: MessageFormat 10 | private readonly _canLookupEmailAddress: boolean 11 | private readonly _senderIsCreator: boolean 12 | 13 | constructor( 14 | email: string, 15 | displayName: string, 16 | addressType: AddressType = "SMTP", 17 | messageFormat: MessageFormat = MessageFormat.TextAndHtml, 18 | canLookupEmailAddress: boolean = false, 19 | senderIsCreator: boolean = true, 20 | ) { 21 | super(email, displayName, addressType) 22 | this._messageFormat = messageFormat 23 | this._canLookupEmailAddress = canLookupEmailAddress 24 | this._senderIsCreator = senderIsCreator 25 | } 26 | 27 | writeProperties(stream: TopLevelProperties) { 28 | if (this._senderIsCreator) { 29 | stream.addProperty(PropertyTags.PR_CreatorEmailAddr_W, this.email) 30 | stream.addProperty(PropertyTags.PR_CreatorSimpleDispName_W, this.displayName) 31 | stream.addProperty(PropertyTags.PR_CreatorAddrType_W, this.addressType) 32 | } 33 | 34 | const senderEntryId = new OneOffEntryId(this.email, this.displayName, this.addressType, this._messageFormat, this._canLookupEmailAddress) 35 | stream.addProperty(PropertyTags.PR_SENDER_ENTRYID, senderEntryId.toByteArray()) 36 | stream.addProperty(PropertyTags.PR_SENDER_EMAIL_ADDRESS_W, this.email) 37 | stream.addProperty(PropertyTags.PR_SENDER_NAME_W, this.displayName) 38 | stream.addProperty(PropertyTags.PR_SENT_REPRESENTING_NAME_W, this.displayName) 39 | stream.addProperty(PropertyTags.PR_SENDER_ADDRTYPE_W, this.addressType) 40 | } 41 | } -------------------------------------------------------------------------------- /lib/streams/top_level_properties.ts: -------------------------------------------------------------------------------- 1 | import {Properties} from "../properties.js" 2 | import type ByteBuffer from "bytebuffer" 3 | import type {CFBStorage} from "../cfb_storage.js" 4 | 5 | /** 6 | * The properties stream contained inside the top level of the .msg file, which represents the Message object itself. 7 | */ 8 | export class TopLevelProperties extends Properties { 9 | nextRecipientId!: number 10 | nextAttachmentId!: number 11 | recipientCount!: number 12 | attachmentCount!: number 13 | 14 | // TODO: add constructor to read in existing CFB stream 15 | 16 | /** 17 | * 18 | * @param storage 19 | * @param prefix 20 | * @param messageSize 21 | */ 22 | override writeProperties(storage: CFBStorage, prefix: (arg0: ByteBuffer) => void, messageSize?: number): number { 23 | // prefix required by the standard: 32 bytes 24 | const topLevelPropPrefix = (buf: ByteBuffer) => { 25 | prefix(buf) 26 | // Reserved(8 bytes): This field MUST be set to zero when writing a .msg file and MUST be ignored 27 | // when reading a.msg file. 28 | buf.writeUint64(0) 29 | // Next Recipient ID(4 bytes): The ID to use for naming the next Recipient object storage if one is 30 | // created inside the .msg file. The naming convention to be used is specified in section 2.2.1.If 31 | // no Recipient object storages are contained in the.msg file, this field MUST be set to 0. 32 | buf.writeUint32(this.nextRecipientId) 33 | // Next Attachment ID (4 bytes): The ID to use for naming the next Attachment object storage if one 34 | // is created inside the .msg file. The naming convention to be used is specified in section 2.2.2. 35 | // If no Attachment object storages are contained in the.msg file, this field MUST be set to 0. 36 | buf.writeUint32(this.nextAttachmentId) 37 | // Recipient Count(4 bytes): The number of Recipient objects. 38 | buf.writeUint32(this.recipientCount) 39 | // Attachment Count (4 bytes): The number of Attachment objects. 40 | buf.writeUint32(this.attachmentCount) 41 | // Reserved(8 bytes): This field MUST be set to 0 when writing a msg file and MUST be ignored when 42 | // reading a msg file. 43 | buf.writeUint64(0) 44 | } 45 | 46 | return super.writeProperties(storage, topLevelPropPrefix, messageSize) 47 | } 48 | } -------------------------------------------------------------------------------- /lib/cfb_storage.ts: -------------------------------------------------------------------------------- 1 | import type {CFB$Container} from "cfb" 2 | import CFB from "cfb" 3 | 4 | /** 5 | * wrapper around SheetJS CFB to produce FAT-like compound file 6 | * terminology: 7 | * 'storage': directory in the cfb 8 | * 'stream' : file in the cfb 9 | * */ 10 | export class CFBStorage { 11 | /** underlying cfb container */ 12 | _cfb: CFB$Container 13 | 14 | /** the current path all new storages and streams will be added to*/ 15 | _path: string 16 | 17 | constructor(cfb?: CFB$Container) { 18 | this._cfb = cfb ?? CFB.utils.cfb_new() 19 | this._path = "" 20 | } 21 | 22 | /** 23 | * add substorage to this (doesn't modify the underlying CFBContainer) 24 | * @param name {string} name of the subdir 25 | * @returns {CFBStorage} a storage that will add storage and streams to the subdir 26 | * */ 27 | addStorage(name: string): CFBStorage { 28 | const child = new CFBStorage(this._cfb) 29 | child._path = this._path + "/" + name 30 | return child 31 | } 32 | 33 | /** 34 | * 35 | */ 36 | getStorage(name: string): CFBStorage { 37 | return this.addStorage(name) 38 | } 39 | 40 | /** 41 | * add a stream (file) to the cfb at the current _path. creates all parent dirs if they don't exist yet 42 | * should the stream already exist, this will replace the contents. 43 | * @param name {string} the name of the new stream 44 | * @param content {Uint8Array} the contents of the stream 45 | * @return {void} 46 | * */ 47 | addStream(name: string, content: Uint8Array): void { 48 | const entryIndex = this._getEntryIndex(name) 49 | 50 | if (entryIndex < 0) { 51 | CFB.utils.cfb_add(this._cfb, this._path + "/" + name, content) 52 | } else { 53 | this._cfb.FileIndex[entryIndex].content = content 54 | } 55 | } 56 | 57 | /** 58 | * get the contents of a stream or an empty array 59 | * @param name {string} the name of the stream 60 | * @return {Uint8Array} the contents of the named stream, empty if it wasn't found 61 | * TODO: should this be absolute? 62 | */ 63 | getStream(name: string): Uint8Array { 64 | const entryIndex = this._getEntryIndex(name) 65 | 66 | return entryIndex < 0 ? Uint8Array.of() : Uint8Array.from(this._cfb.FileIndex[entryIndex].content) 67 | } 68 | 69 | /** write the contents of the cfb container to a byte array */ 70 | toBytes(): Uint8Array { 71 | return Uint8Array.from(CFB.write(this._cfb) as any) 72 | } 73 | 74 | _getEntryIndex(name: string): number { 75 | return this._cfb.FullPaths.findIndex(p => p === this._path + "/" + name) 76 | } 77 | } -------------------------------------------------------------------------------- /examples/comparer.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import {getTag, getTagFromStreamName} from "./propertytags.js"; 4 | import CFB from "cfb"; 5 | import fs from "fs"; 6 | 7 | const inPath = path.join(process.cwd(), process.argv[2]) 8 | const inPath2 = path.join(process.cwd(), process.argv[3]) 9 | const compoundFileA = CFB.parse(fs.readFileSync(inPath)) 10 | const compoundFileB = CFB.parse(fs.readFileSync(inPath2)) 11 | 12 | 13 | const toHex = b => b && b.length > 0 14 | ? b.toString('hex') 15 | .match(/.{1,32}/g) 16 | .map((s, i) => (i * 16).toString(16).padStart(4, "0") + ": " + s.match(/.{1,2}/g).join(" ")) 17 | .join('\n') 18 | : "NULL" 19 | 20 | const toString = b => b ? Array.from(b).map(c => c > 128 ? "?" : String.fromCharCode(c)).join('') : "REALLY NULL" 21 | 22 | const zipIndex = cf => cf.FileIndex.map((f, i) => Object.assign({}, f, { 23 | index: i, 24 | p: cf.FullPaths[i] 25 | })) 26 | 27 | const withIndexA = zipIndex(compoundFileA) 28 | const withIndexB = zipIndex(compoundFileB) 29 | 30 | withIndexA.forEach(fA => { 31 | const fB = withIndexB.find(f => f.p === fA.p) 32 | 33 | const contentA = toHex(fA.content) 34 | 35 | if (!fB) { 36 | console.log() 37 | console.log("MISSING:", fA.size, fA.p, "not in", inPath2) 38 | console.log("tag:", getTagFromStreamName(fA.name)) 39 | console.log(contentA) 40 | console.log(toString(fA.content)) 41 | return 42 | } 43 | 44 | const contentB = toHex(fB.content) 45 | if (contentA !== contentB) { 46 | console.log() 47 | console.log("DIFFERENCE:", fA.size + ":" + fB.size, fA.p, "type", fA.type) 48 | console.log("tag:", getTagFromStreamName(fB.name)) 49 | 50 | console.log(process.argv[2].padEnd(53, " "), '||', process.argv[3]) 51 | const bSplit = contentB.split('\r\n') 52 | contentA.split('\r\n') 53 | .forEach((l, i) => { 54 | if (bSplit[i] === l) return 55 | const ltag = getTag(l.slice(6, 17)) || "no tag" 56 | const rtag = getTag(bSplit[i].slice(6, 17)) || "no tag" 57 | console.log(ltag.padEnd(53, " "), '||', rtag.padEnd(53, " ")) 58 | console.log(l.padEnd(53, " "), "||", bSplit[i].split(" ").slice(1).join(' ')) 59 | }) 60 | console.log(toString(fA.content)) 61 | console.log(toString(fB.content)) 62 | return 63 | } 64 | if (!process.argv.includes('-a')) return 65 | console.log() 66 | console.log('SAME: size:', fA.size, fA.p) 67 | console.log("tag:", getTagFromStreamName(fB.name)) 68 | console.log("content:",) 69 | console.log(contentA) 70 | console.log(toString(fB.content)) 71 | }) 72 | 73 | withIndexB.forEach(fB => { 74 | const fA = withIndexA.find(f => f.p === fB.p) 75 | const contentB = toHex(fB.content) 76 | if (!fA) { 77 | console.log() 78 | console.log("MISS:", fB.size, fB.p, "not in", inPath) 79 | console.log("tag:", getTagFromStreamName(fB.name)) 80 | console.log(contentB) 81 | console.log(toString(fB.content)) 82 | } 83 | }) -------------------------------------------------------------------------------- /lib/message.ts: -------------------------------------------------------------------------------- 1 | import CFB from "cfb" 2 | import {CFBStorage} from "./cfb_storage" 3 | import {MessageClass, MessageIconIndex, PropertyFlag} from "./enums" 4 | import type {PropertyTag} from "./property_tags" 5 | import {PropertyTagLiterals, PropertyTags} from "./property_tags" 6 | import {TopLevelProperties} from "./streams/top_level_properties" 7 | import {NamedProperties} from "./streams/named_properties" 8 | // Setting the RootStorage CLSID to this will cause outlook to parse the msg file and import the full email contents, 9 | // rather than just storing it as a file/inserting it as an attachment 10 | // *I am unclear on whether this behaviour can be relied upon, or if it is a fluke*. Will outlook always use this CLSID? will it always interpret an MSG purely based on the CLSID? 11 | // these are questions we should answer, even though it's working now. 12 | // I've inspected MSG files that were exported from outlook on two different machines, and they both have the same CLSID (this one), so maybe it's a safe bet? 13 | // - John Feb 2021 14 | const OUTLOOK_CLSID = "0b0d020000000000c000000000000046" 15 | 16 | /** 17 | * base class for all MSG files 18 | */ 19 | export class Message { 20 | private _saved: boolean = false 21 | iconIndex: MessageIconIndex | null = null 22 | protected readonly _topLevelProperties: TopLevelProperties 23 | private readonly _namedProperties: NamedProperties 24 | protected readonly _storage: CFBStorage 25 | protected _messageClass: MessageClass = MessageClass.Unknown 26 | protected _messageSize: number = 0 27 | 28 | constructor() { 29 | this._storage = new CFBStorage( 30 | CFB.utils.cfb_new({ 31 | CLSID: OUTLOOK_CLSID, 32 | }), 33 | ) 34 | 35 | // In the preceding figure, the "__nameid_version1.0" named property mapping storage contains the 36 | // three streams used to provide a mapping from property ID to property name 37 | // ("__substg1.0_00020102", "__substg1.0_00030102", and "__substg1.0_00040102") and various other 38 | // streams that provide a mapping from property names to property IDs. 39 | // if (!CompoundFile.RootStorage.TryGetStorage(PropertyTags.NameIdStorage, out var nameIdStorage)) 40 | // nameIdStorage = CompoundFile.RootStorage.AddStorage(PropertyTags.NameIdStorage); 41 | const nameIdStorage = this._storage.addStorage(PropertyTagLiterals.NameIdStorage) 42 | 43 | nameIdStorage.addStream(PropertyTagLiterals.EntryStream, Uint8Array.of()) 44 | nameIdStorage.addStream(PropertyTagLiterals.StringStream, Uint8Array.of()) 45 | nameIdStorage.addStream(PropertyTagLiterals.GuidStream, Uint8Array.of()) 46 | this._topLevelProperties = new TopLevelProperties() 47 | this._namedProperties = new NamedProperties(this._topLevelProperties) 48 | } 49 | 50 | _save() { 51 | this._topLevelProperties.addProperty(PropertyTags.PR_MESSAGE_CLASS_W, this._messageClass) 52 | 53 | this._topLevelProperties.writeProperties(this._storage, () => {}, this._messageSize) 54 | 55 | this._namedProperties.writeProperties(this._storage, 0) 56 | 57 | this._saved = true 58 | this._messageSize = 0 59 | } 60 | 61 | /** 62 | * writes the Message to an underlying CFB 63 | * structure and returns a serialized 64 | * representation 65 | * 66 | */ 67 | saveToBuffer(): Uint8Array { 68 | this._save() 69 | 70 | return this._storage.toBytes() 71 | } 72 | 73 | addProperty(propertyTag: PropertyTag, value: any, flags: number = PropertyFlag.PROPATTR_WRITABLE) { 74 | if (this._saved) throw new Error("Message is already saved!") 75 | 76 | this._topLevelProperties.addOrReplaceProperty(propertyTag, value, flags) 77 | } 78 | } -------------------------------------------------------------------------------- /lib/streams/named_properties.ts: -------------------------------------------------------------------------------- 1 | import {TopLevelProperties} from "./top_level_properties" 2 | import {PropertyKind, PropertyType} from "../enums" 3 | import {PropertyTagLiterals} from "../property_tags" 4 | import {EntryStream, EntryStreamItem, IndexAndKindInformation} from "./entry_stream" 5 | import {GuidStream} from "./guid_stream" 6 | import {StringStream} from "./string_stream" 7 | 8 | type NamedProperty = { 9 | nameIdentifier: number 10 | kind: PropertyKind 11 | nameSize: number 12 | name: string 13 | guid: Uint8Array 14 | } 15 | type NamedPropertyTag = { 16 | id: number 17 | name: string 18 | guid: Uint8Array 19 | propertyType: PropertyType 20 | } 21 | 22 | export class NamedProperties extends Array { 23 | private readonly _topLevelProperties: TopLevelProperties 24 | 25 | constructor(topLevelProperties: TopLevelProperties) { 26 | super() 27 | this._topLevelProperties = topLevelProperties 28 | } 29 | 30 | /** 31 | * adds a NamedPropertyTag. Only support for properties by ID for now. 32 | * @param mapiTag {NamedProperty} 33 | * @param obj {any} 34 | */ 35 | addProperty(mapiTag: NamedPropertyTag, obj: any): void { 36 | throw new Error("Not implemented") 37 | } 38 | 39 | /** 40 | * Writes the properties to the storage. Unfortunately this is going to have to be used after we already written the top level properties. 41 | * @param storage {any} 42 | * @param messageSize {number} 43 | */ 44 | writeProperties(storage: any, messageSize: number = 0): void { 45 | // Grab the nameIdStorage, 3.1 on the SPEC 46 | storage = storage.getStorage(PropertyTagLiterals.NameIdStorage) 47 | const entryStream = new EntryStream(storage) 48 | const stringStream = new StringStream(storage) 49 | const guidStream = new GuidStream(storage) 50 | const entryStream2 = new EntryStream(storage) 51 | // TODO: 52 | const guids = this.map(np => np.guid).filter( 53 | /* TODO: unique*/ 54 | () => { 55 | throw new Error() 56 | }, 57 | ) 58 | guids.forEach(g => guidStream.push(g)) 59 | this.forEach((np, propertyIndex) => { 60 | // (ushort) (guids.IndexOf(namedProperty.Guid) + 3); 61 | const guidIndex = guids.indexOf(np.guid) + 3 62 | // Depending on the property type. This is doing name. 63 | entryStream.push(new EntryStreamItem(np.nameIdentifier, new IndexAndKindInformation(propertyIndex, guidIndex, PropertyKind.Lid))) //+3 as per spec. 64 | 65 | entryStream2.push(new EntryStreamItem(np.nameIdentifier, new IndexAndKindInformation(propertyIndex, guidIndex, PropertyKind.Lid))) 66 | //3.2.2 of the SPEC [MS-OXMSG] 67 | entryStream2.write(storage, NamedProperties._generateStreamString(np.nameIdentifier, guidIndex, np.kind)) 68 | // 3.2.2 of the SPEC Needs to be written, because the stream changes as per named object. 69 | entryStream2.splice(0, entryStream2.length) 70 | }) 71 | guidStream.write(storage) 72 | entryStream.write(storage) 73 | stringStream.write(storage) 74 | } 75 | 76 | /** 77 | * generates the stream strings 78 | * @param nameIdentifier {number} was uint 79 | * @param guidTarget {number} was uint 80 | * @param propertyKind {PropertyKind} 1 byte 81 | */ 82 | static _generateStreamString(nameIdentifier: number, guidTarget: number, propertyKind: PropertyKind): string { 83 | switch (propertyKind) { 84 | case PropertyKind.Lid: 85 | const number = ((4096 + ((nameIdentifier ^ (guidTarget << 1)) % 0x1f)) << 16) | 0x00000102 86 | return "__substg1.0_" + number.toString(16).toUpperCase().padStart(8, "0") 87 | 88 | default: 89 | throw new Error("not implemented!") 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /lib/streams/string_stream.ts: -------------------------------------------------------------------------------- 1 | import {PropertyTagLiterals} from "../property_tags.js" 2 | import {makeByteBuffer} from "../utils/utils.js" 3 | import type ByteBuffer from "bytebuffer" 4 | import type {CFBStorage} from "../cfb_storage.js" 5 | 6 | /** 7 | * The string stream MUST be named "__substg1.0_00040102". It MUST consist of one entry for each 8 | * string named property, and all entries MUST be arranged consecutively, like in an array. 9 | * As specified in section 2.2.3.1.2, the offset, in bytes, to use for a particular property is stored in the 10 | * corresponding entry in the entry stream.That is a byte offset into the string stream from where the 11 | * entry for the property can be read.The strings MUST NOT be null-terminated. Implementers can add a 12 | * terminating null character to the string 13 | * See https://msdn.microsoft.com/en-us/library/ee124409(v=exchg.80).aspx 14 | */ 15 | export class StringStream extends Array { 16 | /** 17 | * create StringStream and read all the StringStreamItems from the given storage, if any. 18 | */ 19 | constructor(storage?: CFBStorage) { 20 | super() 21 | if (storage == null) return 22 | const stream = storage.getStream(PropertyTagLiterals.StringStream) 23 | const buf = makeByteBuffer(undefined, stream) 24 | 25 | while (buf.offset < buf.limit) { 26 | this.push(StringStreamItem.fromBuffer(buf)) 27 | } 28 | } 29 | 30 | /** 31 | * write all the StringStreamItems as a stream to the storage 32 | * @param storage 33 | */ 34 | write(storage: any): void { 35 | const buf = makeByteBuffer() 36 | this.forEach(s => s.write(buf)) 37 | storage.addStream(PropertyTagLiterals.StringStream, buf) 38 | } 39 | } 40 | 41 | /** 42 | * Represents one Item in the StringStream 43 | */ 44 | export class StringStreamItem { 45 | /** 46 | * the length of the following name field in bytes 47 | * was uint 48 | * @type number 49 | */ 50 | readonly length: number 51 | 52 | /** 53 | * A Unicode string that is the name of the property. A new entry MUST always start 54 | * on a 4 byte boundary; therefore, if the size of the Name field is not an exact multiple of 4, and 55 | * another Name field entry occurs after it, null characters MUST be appended to the stream after it 56 | * until the 4-byte boundary is reached.The Name Length field for the next entry will then start at 57 | * the beginning of the next 4-byte boundary 58 | * @type {string} 59 | */ 60 | readonly name: string 61 | 62 | /** 63 | * create a StringStreamItem from a byte buffer 64 | * @param buf {ByteBuffer} 65 | */ 66 | static fromBuffer(buf: ByteBuffer): StringStreamItem { 67 | const length = buf.readUint32() 68 | // Encoding.Unicode.GetString(binaryReader.ReadBytes((int) Length)).Trim('\0'); 69 | const name = buf.readUTF8String(length) 70 | const boundary = StringStreamItem.get4BytesBoundary(length) 71 | buf.offset = buf.offset + boundary 72 | return new StringStreamItem(name) 73 | } 74 | 75 | constructor(name: string) { 76 | this.length = name.length 77 | this.name = name 78 | } 79 | 80 | /** 81 | * write this item to the ByteBuffer 82 | * @param buf {ByteBuffer} 83 | */ 84 | write(buf: ByteBuffer): void { 85 | buf.writeUint32(this.length) 86 | buf.writeUTF8String(this.name) 87 | const boundary = StringStreamItem.get4BytesBoundary(this.length) 88 | 89 | for (let i = 0; i < boundary; i++) { 90 | buf.writeUint8(0) 91 | } 92 | } 93 | 94 | /** 95 | * Extract 4 from the given until the result is smaller 96 | * than 4 and then returns this result 97 | * @param length {number} was uint 98 | */ 99 | static get4BytesBoundary(length: number): number { 100 | if (length === 0) return 4 101 | 102 | while (length >= 4) length -= 4 103 | 104 | return length 105 | } 106 | } -------------------------------------------------------------------------------- /lib/mime/header/received.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Class that holds information about one "Received:" header line. 3 | * Visit these RFCs for more information: 4 | * RFC 5321 section 4.4 5 | * RFC 4021 section 3.6.7 6 | * RFC 2822 section 3.6.7 7 | * RFC 2821 section 4.4 8 | */ 9 | export class Received { 10 | /// The date of this received line. 11 | /// Is if not present in the received header line. 12 | date: number // TODO: DateTime 13 | 14 | /// A dictionary that contains the names and values of the 15 | /// received header line.
16 | /// If the received header is invalid and contained one name 17 | /// multiple times, the first one is used and the rest is ignored. 18 | // 19 | /// If the header lines looks like: 20 | /// 21 | /// from sending.com (localMachine [127.0.0.1]) by test.net (Postfix) 22 | /// 23 | /// then the dictionary will contain two keys: "from" and "by" with the values 24 | /// "sending.com (localMachine [127.0.0.1])" and "test.net (Postfix)".$ 25 | names: Record 26 | /// The raw input string that was parsed into this class. 27 | raw: string 28 | 29 | /// Parses a Received header value. 30 | /// 31 | /// The value for the header to be parsed 32 | /// 33 | /// If is 34 | /// 35 | constructor(headerValue: string) { 36 | if (headerValue == null) throw new Error("headerValue must be a string!") 37 | this.raw = headerValue 38 | this.date = 0 39 | 40 | if (headerValue.includes(";")) { 41 | const semiIdx = headerValue.lastIndexOf(";") 42 | const datePart = headerValue.substring(semiIdx + 1) 43 | this.date = Date.parse(datePart) 44 | } 45 | 46 | this.names = Received.parseValue(headerValue) 47 | } 48 | 49 | /** 50 | * Parses the Received header name-value-list into a dictionary. 51 | * @param headerValue {string} The full header value for the Received header 52 | * @returns {{[string]: string}} 53 | */ 54 | static parseValue(headerValue: string): Record { 55 | const dict: Record = {} 56 | const semiIdx = headerValue.lastIndexOf(";") 57 | headerValue = headerValue.replace(/\s+/, " ") 58 | const headerValueWithoutDate = semiIdx > -1 ? headerValue.substring(0, semiIdx) : headerValue 59 | // The regex below should capture the following: 60 | // The name consists of non-whitespace characters followed by a whitespace and then the value follows. 61 | // There are multiple cases for the value part: 62 | // 1: Value is just some characters not including any whitespace 63 | // 2: Value is some characters, a whitespace followed by an unlimited number of 64 | // parenthesized values which can contain whitespaces, each delimited by whitespace 65 | // 66 | // Cheat sheet for regex: 67 | // \s means every whitespace character 68 | // [^\s] means every character except whitespace characters 69 | // +? is a non-greedy equivalent of + 70 | //const string pattern = @"(?[^\s]+)\s(?[^\s]+(\s\(.+?\))*)"; 71 | const pattern = /(?[^\s]+)\s(?[^\s]+(\s\(.+?\))*)/ 72 | // Find each match in the string 73 | const matches = headerValueWithoutDate.matchAll(pattern) 74 | 75 | for (let match of matches) { 76 | // Add the name and value part found in the matched result to the dictionary 77 | if (match.groups == null) throw new Error("unreachable!") 78 | const name = match.groups["name"] 79 | const value = match.groups["value"] 80 | // Check if the name is really a comment. 81 | // In this case, the first entry in the header value 82 | // is a comment 83 | if (name.startsWith("(")) continue 84 | // Only add the first name pair 85 | // All subsequent pairs are ignored, as they are invalid anyway 86 | if (dict[name] != null) continue 87 | dict[name] = value 88 | } 89 | 90 | return dict 91 | } 92 | } -------------------------------------------------------------------------------- /lib/attachment.ts: -------------------------------------------------------------------------------- 1 | import {PropertyTags} from "./property_tags.js" 2 | import {generateInstanceKey, generateRecordKey} from "./utils/mapi.js" 3 | import {fileNameToDosFileName, getPathExtension, isNullOrEmpty} from "./utils/utils.js" 4 | import {getMimeType} from "./utils/mime.js" 5 | import {Properties} from "./properties.js" 6 | import {AttachmentFlags, AttachmentType, MapiObjectType, PropertyFlag, StoreSupportMaskConst} from "./enums.js" 7 | import {CFBStorage} from "./cfb_storage"; 8 | 9 | export class Attachment { 10 | readonly data: Uint8Array 11 | readonly fileName: string 12 | readonly type: AttachmentType 13 | readonly contentId: string 14 | readonly renderingPosition: number 15 | readonly isContactPhoto: boolean 16 | 17 | constructor( 18 | data: Uint8Array, // was: Stream 19 | fileName: string, 20 | contentId: string = "", 21 | type: AttachmentType = AttachmentType.ATTACH_BY_VALUE, 22 | renderingPosition: number = -1, 23 | isContactPhoto: boolean = false, 24 | ) { 25 | this.data = data 26 | this.fileName = fileName 27 | this.type = type 28 | this.renderingPosition = renderingPosition 29 | this.contentId = contentId 30 | this.isContactPhoto = isContactPhoto 31 | } 32 | 33 | writeProperties(storage: CFBStorage, index: number): number { 34 | const attachmentProperties = new Properties() 35 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_NUM, index, PropertyFlag.PROPATTR_READABLE) 36 | attachmentProperties.addBinaryProperty(PropertyTags.PR_INSTANCE_KEY, generateInstanceKey(), PropertyFlag.PROPATTR_READABLE) 37 | attachmentProperties.addBinaryProperty(PropertyTags.PR_RECORD_KEY, generateRecordKey(), PropertyFlag.PROPATTR_READABLE) 38 | attachmentProperties.addProperty(PropertyTags.PR_RENDERING_POSITION, this.renderingPosition, PropertyFlag.PROPATTR_READABLE) 39 | attachmentProperties.addProperty(PropertyTags.PR_OBJECT_TYPE, MapiObjectType.MAPI_ATTACH) 40 | 41 | if (!isNullOrEmpty(this.fileName)) { 42 | attachmentProperties.addProperty(PropertyTags.PR_DISPLAY_NAME_W, this.fileName) 43 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_FILENAME_W, fileNameToDosFileName(this.fileName)) 44 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_LONG_FILENAME_W, this.fileName) 45 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_EXTENSION_W, getPathExtension(this.fileName)) 46 | 47 | if (!isNullOrEmpty(this.contentId)) { 48 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_CONTENT_ID_W, this.contentId) 49 | } 50 | 51 | // TODO: get mimetype from user. 52 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_MIME_TAG_W, getMimeType(this.fileName)) 53 | } 54 | 55 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_METHOD, this.type) 56 | 57 | switch (this.type) { 58 | case AttachmentType.ATTACH_BY_VALUE: 59 | case AttachmentType.ATTACH_EMBEDDED_MSG: 60 | attachmentProperties.addBinaryProperty(PropertyTags.PR_ATTACH_DATA_BIN, this.data) 61 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_SIZE, this.data.length) 62 | break 63 | 64 | case AttachmentType.ATTACH_BY_REF_ONLY: 65 | case AttachmentType.ATTACH_BY_REFERENCE: 66 | case AttachmentType.ATTACH_BY_REF_RESOLVE: 67 | case AttachmentType.NO_ATTACHMENT: 68 | case AttachmentType.ATTACH_OLE: 69 | throw new Error(`Attachment type "${AttachmentType[this.type]} is not supported`) 70 | } 71 | 72 | if (this.contentId) { 73 | attachmentProperties.addProperty(PropertyTags.PR_ATTACHMENT_HIDDEN, true) 74 | attachmentProperties.addProperty(PropertyTags.PR_ATTACH_FLAGS, AttachmentFlags.ATT_MHTML_REF) 75 | } 76 | 77 | attachmentProperties.addDateProperty(PropertyTags.PR_CREATION_TIME, new Date()) 78 | attachmentProperties.addDateProperty(PropertyTags.PR_LAST_MODIFICATION_TIME, new Date()) 79 | attachmentProperties.addProperty(PropertyTags.PR_STORE_SUPPORT_MASK, StoreSupportMaskConst, PropertyFlag.PROPATTR_READABLE) 80 | return attachmentProperties.writeProperties(storage, buf => { 81 | // Reserved (8 bytes): This field MUST be set to 82 | // zero when writing a .msg file and MUST be ignored when reading a .msg file. 83 | buf.writeUint64(0) 84 | }) 85 | } 86 | } -------------------------------------------------------------------------------- /lib/utils/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | import { 3 | bigInt64FromParts, 4 | bigInt64ToParts, 5 | byteBufferAsUint8Array, 6 | fileNameToDosFileName, 7 | makeByteBuffer 8 | } from "./utils.js" 9 | 10 | o.spec("utils", function () { 11 | o("makeByteBuffer", function () { 12 | const anotherBuf = makeByteBuffer(16) 13 | anotherBuf.writeUint64(123) 14 | anotherBuf.writeString("hello") 15 | o(anotherBuf.littleEndian).equals(true) 16 | o(makeByteBuffer(undefined).toDebug()).equals("<00>") 17 | o(makeByteBuffer().toDebug()).equals("<00>") 18 | o(makeByteBuffer(undefined).toDebug()).equals("<00>") 19 | o(makeByteBuffer(0).toDebug()).equals("<00>") 20 | o(() => makeByteBuffer(-1)).throws(Error) 21 | o(makeByteBuffer(undefined, anotherBuf).toDebug()).equals("7B 00 00 00 00 00 00 00 68 65 6C 6C 6F<00 00 00>") 22 | o(makeByteBuffer(undefined, anotherBuf).littleEndian).equals(true) 23 | }) 24 | o("byteBufferToUint8Array", function () { 25 | const buf = makeByteBuffer(16) 26 | buf.writeUint64(123) 27 | buf.writeString("hello") 28 | const arr = byteBufferAsUint8Array(buf) 29 | o(arr.toString()).equals("123,0,0,0,0,0,0,0,104,101,108,108,111") 30 | o(arr.length).equals(13) 31 | o(arr instanceof Uint8Array).equals(true) 32 | }) 33 | o("fileNameToDosFileName", function () { 34 | o(fileNameToDosFileName("canvas.png")).equals("CANVAS.PNG") 35 | o(fileNameToDosFileName("somelongname.png")).equals("SOMELO~1.PNG") 36 | o(fileNameToDosFileName("somelongnamewithLongext.jpeg")).equals("SOMELO~1.JPE") 37 | o(fileNameToDosFileName("z12.stf.txt")).equals("Z12STF.TXT") 38 | o(fileNameToDosFileName(".jpeg")).equals(".JPE") 39 | o(fileNameToDosFileName(".png")).equals(".PNG") 40 | o(fileNameToDosFileName("blabla")).equals("BLABLA") 41 | o(fileNameToDosFileName("blablabla")).equals("BLABLA~1") 42 | }) 43 | let bigints = [ 44 | [ 45 | 0n, 46 | { 47 | lower: 0, 48 | upper: 0, 49 | }, 50 | ], 51 | [ 52 | 1n, 53 | { 54 | lower: 1, 55 | upper: 0, 56 | }, 57 | ], 58 | [ 59 | 42n, 60 | { 61 | lower: 42, 62 | upper: 0, 63 | }, 64 | ], 65 | [ 66 | 9001n, 67 | { 68 | lower: 9001, 69 | upper: 0, 70 | }, 71 | ], 72 | [ 73 | 0x00000000ffffffffn, 74 | { 75 | lower: 0xffffffff, 76 | upper: 0, 77 | }, 78 | ], 79 | [ 80 | 0x7000000000000000n, 81 | { 82 | lower: 0, 83 | upper: 0x70000000, 84 | }, 85 | ], 86 | [ 87 | 0xffffffffffffffffn, 88 | { 89 | lower: 0xffffffff, 90 | upper: 0xffffffff, 91 | }, 92 | ], 93 | [ 94 | 0xfedcba0987654321n, 95 | { 96 | lower: 0x87654321, 97 | upper: 0xfedcba09, 98 | }, 99 | ], 100 | [ 101 | 0x2020202000000000n, 102 | { 103 | lower: 0, 104 | upper: 0x20202020, 105 | }, 106 | ], 107 | [ 108 | 0x0000000020202020n, 109 | { 110 | lower: 0x20202020, 111 | upper: 0, 112 | }, 113 | ], 114 | [ 115 | 0xdeadbeef0000bbbbn, 116 | { 117 | lower: 0x0000bbbb, 118 | upper: 0xdeadbeef, 119 | }, 120 | ], 121 | [ 122 | 0x1234567890abcdefn, 123 | { 124 | lower: 0x90abcdef, 125 | upper: 0x12345678, 126 | }, 127 | ], 128 | ] as const 129 | o("big int 64 to parts", function () { 130 | for (let [val, parts] of bigints) { 131 | const output = bigInt64ToParts(val) 132 | o(output).deepEquals(parts) 133 | } 134 | }) 135 | o("parts to big int 64", function () { 136 | for (let [val, {lower, upper}] of bigints) { 137 | const output = bigInt64FromParts(lower, upper) 138 | o(output).equals(val) 139 | } 140 | }) 141 | }) -------------------------------------------------------------------------------- /lcid.json: -------------------------------------------------------------------------------- 1 | { 2 | "4": "zh_CHS", 3 | "1025": "ar_SA", 4 | "1026": "bg_BG", 5 | "1027": "ca_ES", 6 | "1028": "zh_TW", 7 | "1029": "cs_CZ", 8 | "1030": "da_DK", 9 | "1031": "de_DE", 10 | "1032": "el_GR", 11 | "1033": "en_US", 12 | "1034": "es_ES", 13 | "1035": "fi_FI", 14 | "1036": "fr_FR", 15 | "1037": "he_IL", 16 | "1038": "hu_HU", 17 | "1039": "is_IS", 18 | "1040": "it_IT", 19 | "1041": "ja_JP", 20 | "1042": "ko_KR", 21 | "1043": "nl_NL", 22 | "1044": "nb_NO", 23 | "1045": "pl_PL", 24 | "1046": "pt_BR", 25 | "1047": "rm_CH", 26 | "1048": "ro_RO", 27 | "1049": "ru_RU", 28 | "1050": "hr_HR", 29 | "1051": "sk_SK", 30 | "1052": "sq_AL", 31 | "1053": "sv_SE", 32 | "1054": "th_TH", 33 | "1055": "tr_TR", 34 | "1056": "ur_PK", 35 | "1057": "id_ID", 36 | "1058": "uk_UA", 37 | "1059": "be_BY", 38 | "1060": "sl_SI", 39 | "1061": "et_EE", 40 | "1062": "lv_LV", 41 | "1063": "lt_LT", 42 | "1064": "tg_TJ", 43 | "1065": "fa_IR", 44 | "1066": "vi_VN", 45 | "1067": "hy_AM", 46 | "1069": "eu_ES", 47 | "1070": "wen_DE", 48 | "1071": "mk_MK", 49 | "1074": "tn_ZA", 50 | "1076": "xh_ZA", 51 | "1077": "zu_ZA", 52 | "1078": "af_ZA", 53 | "1079": "ka_GE", 54 | "1080": "fo_FO", 55 | "1081": "hi_IN", 56 | "1082": "mt_MT", 57 | "1083": "se_NO", 58 | "1086": "ms_MY", 59 | "1087": "kk_KZ", 60 | "1088": "ky_KG", 61 | "1089": "sw_KE", 62 | "1090": "tk_TM", 63 | "1092": "tt_RU", 64 | "1093": "bn_IN", 65 | "1094": "pa_IN", 66 | "1095": "gu_IN", 67 | "1096": "or_IN", 68 | "1097": "ta_IN", 69 | "1098": "te_IN", 70 | "1099": "kn_IN", 71 | "1100": "ml_IN", 72 | "1101": "as_IN", 73 | "1102": "mr_IN", 74 | "1103": "sa_IN", 75 | "1104": "mn_MN", 76 | "1105": "bo_CN", 77 | "1106": "cy_GB", 78 | "1107": "kh_KH", 79 | "1108": "lo_LA", 80 | "1109": "my_MM", 81 | "1110": "gl_ES", 82 | "1111": "kok_IN", 83 | "1114": "syr_SY", 84 | "1115": "si_LK", 85 | "1118": "am_ET", 86 | "1121": "ne_NP", 87 | "1122": "fy_NL", 88 | "1123": "ps_AF", 89 | "1124": "fil_PH", 90 | "1125": "div_MV", 91 | "1128": "ha_NG", 92 | "1130": "yo_NG", 93 | "1131": "quz_BO", 94 | "1132": "ns_ZA", 95 | "1133": "ba_RU", 96 | "1134": "lb_LU", 97 | "1135": "kl_GL", 98 | "1144": "ii_CN", 99 | "1146": "arn_CL", 100 | "1148": "moh_CA", 101 | "1150": "br_FR", 102 | "1152": "ug_CN", 103 | "1153": "mi_NZ", 104 | "1154": "oc_FR", 105 | "1155": "co_FR", 106 | "1156": "gsw_FR", 107 | "1157": "sah_RU", 108 | "1158": "qut_GT", 109 | "1159": "rw_RW", 110 | "1160": "wo_SN", 111 | "1164": "gbz_AF", 112 | "2049": "ar_IQ", 113 | "2052": "zh_CN", 114 | "2055": "de_CH", 115 | "2057": "en_GB", 116 | "2058": "es_MX", 117 | "2060": "fr_BE", 118 | "2064": "it_CH", 119 | "2067": "nl_BE", 120 | "2068": "nn_NO", 121 | "2070": "pt_PT", 122 | "2077": "sv_FI", 123 | "2080": "ur_IN", 124 | "2092": "az_AZ", 125 | "2094": "dsb_DE", 126 | "2107": "se_SE", 127 | "2108": "ga_IE", 128 | "2110": "ms_BN", 129 | "2115": "uz_UZ", 130 | "2128": "mn_CN", 131 | "2129": "bo_BT", 132 | "2141": "iu_CA", 133 | "2143": "tmz_DZ", 134 | "2155": "quz_EC", 135 | "3073": "ar_EG", 136 | "3076": "zh_HK", 137 | "3079": "de_AT", 138 | "3081": "en_AU", 139 | "3082": "es_ES", 140 | "3084": "fr_CA", 141 | "3098": "sr_SP", 142 | "3131": "se_FI", 143 | "3179": "quz_PE", 144 | "4097": "ar_LY", 145 | "4100": "zh_SG", 146 | "4103": "de_LU", 147 | "4105": "en_CA", 148 | "4106": "es_GT", 149 | "4108": "fr_CH", 150 | "4122": "hr_BA", 151 | "4155": "smj_NO", 152 | "5121": "ar_DZ", 153 | "5124": "zh_MO", 154 | "5127": "de_LI", 155 | "5129": "en_NZ", 156 | "5130": "es_CR", 157 | "5132": "fr_LU", 158 | "5179": "smj_SE", 159 | "6145": "ar_MA", 160 | "6153": "en_IE", 161 | "6154": "es_PA", 162 | "6156": "fr_MC", 163 | "6203": "sma_NO", 164 | "7169": "ar_TN", 165 | "7177": "en_ZA", 166 | "7178": "es_DO", 167 | "7194": "sr_BA", 168 | "7227": "sma_SE", 169 | "8193": "ar_OM", 170 | "8201": "en_JA", 171 | "8202": "es_VE", 172 | "8218": "bs_BA", 173 | "8251": "sms_FI", 174 | "9217": "ar_YE", 175 | "9225": "en_CB", 176 | "9226": "es_CO", 177 | "9275": "smn_FI", 178 | "10241": "ar_SY", 179 | "10249": "en_BZ", 180 | "10250": "es_PE", 181 | "11265": "ar_JO", 182 | "11273": "en_TT", 183 | "11274": "es_AR", 184 | "12289": "ar_LB", 185 | "12297": "en_ZW", 186 | "12298": "es_EC", 187 | "13313": "ar_KW", 188 | "13321": "en_PH", 189 | "13322": "es_CL", 190 | "14337": "ar_AE", 191 | "14346": "es_UR", 192 | "15361": "ar_BH", 193 | "15370": "es_PY", 194 | "16385": "ar_QA", 195 | "16394": "es_BO", 196 | "17417": "en_MY", 197 | "17418": "es_SV", 198 | "18441": "en_IN", 199 | "18442": "es_HN", 200 | "19466": "es_NI", 201 | "20490": "es_PR", 202 | "21514": "es_US", 203 | "31748": "zh_CHT" 204 | } 205 | -------------------------------------------------------------------------------- /lib/utils/lcid.ts: -------------------------------------------------------------------------------- 1 | export enum Locale { 2 | zh_CHS = 4, 3 | ar_SA = 1025, 4 | bg_BG = 1026, 5 | ca_ES = 1027, 6 | zh_TW = 1028, 7 | cs_CZ = 1029, 8 | da_DK = 1030, 9 | de_DE = 1031, 10 | el_GR = 1032, 11 | en_US = 1033, 12 | // es_ES = 1034, // Spanish can be one of two LCIDs, but objects can't have duplicate keys :| 13 | fi_FI = 1035, 14 | fr_FR = 1036, 15 | he_IL = 1037, 16 | hu_HU = 1038, 17 | is_IS = 1039, 18 | it_IT = 1040, 19 | ja_JP = 1041, 20 | ko_KR = 1042, 21 | nl_NL = 1043, 22 | nb_NO = 1044, 23 | pl_PL = 1045, 24 | pt_BR = 1046, 25 | rm_CH = 1047, 26 | ro_RO = 1048, 27 | ru_RU = 1049, 28 | hr_HR = 1050, 29 | sk_SK = 1051, 30 | sq_AL = 1052, 31 | sv_SE = 1053, 32 | th_TH = 1054, 33 | tr_TR = 1055, 34 | ur_PK = 1056, 35 | id_ID = 1057, 36 | uk_UA = 1058, 37 | be_BY = 1059, 38 | sl_SI = 1060, 39 | et_EE = 1061, 40 | lv_LV = 1062, 41 | lt_LT = 1063, 42 | tg_TJ = 1064, 43 | fa_IR = 1065, 44 | vi_VN = 1066, 45 | hy_AM = 1067, 46 | eu_ES = 1069, 47 | wen_DE = 1070, 48 | mk_MK = 1071, 49 | tn_ZA = 1074, 50 | xh_ZA = 1076, 51 | zu_ZA = 1077, 52 | af_ZA = 1078, 53 | ka_GE = 1079, 54 | fo_FO = 1080, 55 | hi_IN = 1081, 56 | mt_MT = 1082, 57 | se_NO = 1083, 58 | ms_MY = 1086, 59 | kk_KZ = 1087, 60 | ky_KG = 1088, 61 | sw_KE = 1089, 62 | tk_TM = 1090, 63 | tt_RU = 1092, 64 | bn_IN = 1093, 65 | pa_IN = 1094, 66 | gu_IN = 1095, 67 | or_IN = 1096, 68 | ta_IN = 1097, 69 | te_IN = 1098, 70 | kn_IN = 1099, 71 | ml_IN = 1100, 72 | as_IN = 1101, 73 | mr_IN = 1102, 74 | sa_IN = 1103, 75 | mn_MN = 1104, 76 | bo_CN = 1105, 77 | cy_GB = 1106, 78 | kh_KH = 1107, 79 | lo_LA = 1108, 80 | my_MM = 1109, 81 | gl_ES = 1110, 82 | kok_IN = 1111, 83 | syr_SY = 1114, 84 | si_LK = 1115, 85 | am_ET = 1118, 86 | ne_NP = 1121, 87 | fy_NL = 1122, 88 | ps_AF = 1123, 89 | fil_PH = 1124, 90 | div_MV = 1125, 91 | ha_NG = 1128, 92 | yo_NG = 1130, 93 | quz_BO = 1131, 94 | ns_ZA = 1132, 95 | ba_RU = 1133, 96 | lb_LU = 1134, 97 | kl_GL = 1135, 98 | ii_CN = 1144, 99 | arn_CL = 1146, 100 | moh_CA = 1148, 101 | br_FR = 1150, 102 | ug_CN = 1152, 103 | mi_NZ = 1153, 104 | oc_FR = 1154, 105 | co_FR = 1155, 106 | gsw_FR = 1156, 107 | sah_RU = 1157, 108 | qut_GT = 1158, 109 | rw_RW = 1159, 110 | wo_SN = 1160, 111 | gbz_AF = 1164, 112 | ar_IQ = 2049, 113 | zh_CN = 2052, 114 | de_CH = 2055, 115 | en_GB = 2057, 116 | es_MX = 2058, 117 | fr_BE = 2060, 118 | it_CH = 2064, 119 | nl_BE = 2067, 120 | nn_NO = 2068, 121 | pt_PT = 2070, 122 | sv_FI = 2077, 123 | ur_IN = 2080, 124 | az_AZ = 2092, 125 | dsb_DE = 2094, 126 | se_SE = 2107, 127 | ga_IE = 2108, 128 | ms_BN = 2110, 129 | uz_UZ = 2115, 130 | mn_CN = 2128, 131 | bo_BT = 2129, 132 | iu_CA = 2141, 133 | tmz_DZ = 2143, 134 | quz_EC = 2155, 135 | ar_EG = 3073, 136 | zh_HK = 3076, 137 | de_AT = 3079, 138 | en_AU = 3081, 139 | es_ES = 3082, 140 | fr_CA = 3084, 141 | sr_SP = 3098, 142 | se_FI = 3131, 143 | quz_PE = 3179, 144 | ar_LY = 4097, 145 | zh_SG = 4100, 146 | de_LU = 4103, 147 | en_CA = 4105, 148 | es_GT = 4106, 149 | fr_CH = 4108, 150 | hr_BA = 4122, 151 | smj_NO = 4155, 152 | ar_DZ = 5121, 153 | zh_MO = 5124, 154 | de_LI = 5127, 155 | en_NZ = 5129, 156 | es_CR = 5130, 157 | fr_LU = 5132, 158 | smj_SE = 5179, 159 | ar_MA = 6145, 160 | en_IE = 6153, 161 | es_PA = 6154, 162 | fr_MC = 6156, 163 | sma_NO = 6203, 164 | ar_TN = 7169, 165 | en_ZA = 7177, 166 | es_DO = 7178, 167 | sr_BA = 7194, 168 | sma_SE = 7227, 169 | ar_OM = 8193, 170 | en_JA = 8201, 171 | es_VE = 8202, 172 | bs_BA = 8218, 173 | sms_FI = 8251, 174 | ar_YE = 9217, 175 | en_CB = 9225, 176 | es_CO = 9226, 177 | smn_FI = 9275, 178 | ar_SY = 10241, 179 | en_BZ = 10249, 180 | es_PE = 10250, 181 | ar_JO = 11265, 182 | en_TT = 11273, 183 | es_AR = 11274, 184 | ar_LB = 12289, 185 | en_ZW = 12297, 186 | es_EC = 12298, 187 | ar_KW = 13313, 188 | en_PH = 13321, 189 | es_CL = 13322, 190 | ar_AE = 14337, 191 | es_UR = 14346, 192 | ar_BH = 15361, 193 | es_PY = 15370, 194 | ar_QA = 16385, 195 | es_BO = 16394, 196 | en_MY = 17417, 197 | es_SV = 17418, 198 | en_IN = 18441, 199 | es_HN = 18442, 200 | es_NI = 19466, 201 | es_PR = 20490, 202 | es_US = 21514, 203 | zh_CHT = 31748, 204 | } -------------------------------------------------------------------------------- /lib/utils/mapi.ts: -------------------------------------------------------------------------------- 1 | import {v4} from "uuid" 2 | import {stringToUtf16LeArray} from "./utils.js" 3 | 4 | /// contains MAPI related helper methods 5 | function makeUUIDBuffer(): Uint8Array { 6 | return v4({}, new Uint8Array(16), 0) 7 | } 8 | 9 | let instanceKey: Uint8Array | null = null 10 | 11 | // A search key is used to compare the data in two objects. An object's search key is stored in its 12 | // (PidTagSearchKey) property. Because a search key 13 | // represents an object's data and not the object itself, two different objects with the same data can have the same 14 | // search key. When an object is copied, for example, both the original object and its copy have the same data and the 15 | // same search key. Messages and messaging users have search keys. The search key of a message is a unique identifier 16 | // of the message's data. Message store providers furnish a message's 17 | // property at message creation time.The search key of an address book entry is computed from its address type( 18 | // (PidTagAddressType)) and address 19 | // ( (PidTagEmailAddress)). If the address book entry is writable, 20 | // its search key might not be available until the address type and address have been set by using the 21 | // IMAPIProp::SetProps method and the entry has been saved by using the IMAPIProp::SaveChanges method.When these 22 | // address properties change, it is possible for the corresponding search key not to be synchronized with the new 23 | // values until the changes have been committed with a SaveChanges call. The value of an object's record key can be 24 | // the same as or different than the value of its search key, depending on the service provider. Some service providers 25 | // use the same value for an object's search key, record key, and entry identifier.Other service providers assign unique 26 | // values for each of its objects identifiers. 27 | export function generateSearchKey(addressType: string, emailAddress: string): Uint8Array { 28 | return stringToUtf16LeArray(addressType + emailAddress) 29 | } 30 | // A record key is used to compare two objects. Message store and address book objects must have record keys, which 31 | // are stored in their (PidTagRecordKey) property. Because a record key 32 | // identifies an object and not its data, every instance of an object has a unique record key. The scope of a record 33 | // key for folders and messages is the message store. The scope for address book containers, messaging users, and 34 | // distribution lists is the set of top-level containers provided by MAPI for use in the integrated address book. 35 | // Record keys can be duplicated in another resource. For example, different messages in two different message stores 36 | // can have the same record key. This is different from long-term entry identifiers; because long-term entry 37 | // identifiers contain a reference to the service provider, they have a wider scope.A message store's record key is 38 | // similar in scope to a long-term entry identifier; it should be unique across all message store providers. To ensure 39 | // this uniqueness, message store providers typically set their record key to a value that is the combination of their 40 | // (PidTagStoreProvider) property and an identifier that is unique to the 41 | // message store. 42 | export function generateRecordKey(): Uint8Array { 43 | return makeUUIDBuffer() 44 | } 45 | // This property is a binary value that uniquely identifies a row in a table view. It is a required column in most 46 | // tables. If a row is included in two views, there are two different instance keys. The instance key of a row may 47 | // differ each time the table is opened, but remains constant while the table is open. Rows added while a table is in 48 | // use do not reuse an instance key that was previously used. 49 | // message store. 50 | export function generateInstanceKey(): Uint8Array { 51 | if (instanceKey == null) { 52 | instanceKey = makeUUIDBuffer().slice(0, 4) 53 | } 54 | 55 | return instanceKey 56 | } 57 | // The PR_ENTRYID property contains a MAPI entry identifier used to open and edit properties of a particular MAPI 58 | // object. 59 | // The PR_ENTRYID property identifies an object for OpenEntry to instantiate and provides access to all of its 60 | // properties through the appropriate derived interface of IMAPIProp. PR_ENTRYID is one of the base address properties 61 | // for all messaging users. The PR_ENTRYID for CEMAPI always contains long-term identifiers. 62 | // - Required on folder objects 63 | // - Required on message store objects 64 | // - Required on status objects 65 | // - Changed in a copy operation 66 | // - Unique within entire world 67 | export function generateEntryId(): Uint8Array { 68 | // Encoding.Unicode.GetBytes(Guid.NewGuid().ToString()); 69 | const val = v4() 70 | // v4 without args gives a string 71 | return stringToUtf16LeArray(val) 72 | } -------------------------------------------------------------------------------- /lib/address/recipients.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "./address" 2 | import {AddressType, MapiObjectType, RecipientRowDisplayType, RecipientType} from "../enums" 3 | import {PropertyTagLiterals, PropertyTags} from "../property_tags" 4 | import {generateEntryId, generateInstanceKey, generateSearchKey} from "../utils/mapi" 5 | import {RecipientProperties} from "../streams/recipient_properties" 6 | import {X8} from "../utils/utils" 7 | import type {CFBStorage} from "../cfb_storage" 8 | 9 | /** 10 | * Wrapper around a list of recipients 11 | */ 12 | export class Recipients extends Array { 13 | /** 14 | * add a new To-Recipient to the list 15 | * @param email email address of the recipient 16 | * @param displayName display name of the recipient (optional) 17 | * @param addressType address type of the recipient (default SMTP) 18 | * @param objectType mapiObjectType of the recipient (default MAPI_MAILUSER) 19 | * @param displayType recipientRowDisplayType of the recipient (default MessagingUser) 20 | */ 21 | addTo( 22 | email: string, 23 | displayName: string = "", 24 | addressType: AddressType = "SMTP", 25 | objectType: MapiObjectType = MapiObjectType.MAPI_MAILUSER, 26 | displayType: RecipientRowDisplayType = RecipientRowDisplayType.MessagingUser, 27 | ) { 28 | this.push(new Recipient(this.length, email, displayName, addressType, RecipientType.To, objectType, displayType)) 29 | } 30 | 31 | /** 32 | * add a new Cc-Recipient to the list 33 | * @param email email address of the recipient 34 | * @param displayName display name of the recipient (optional) 35 | * @param addressType address type of the recipient (default SMTP) 36 | * @param objectType mapiObjectType of the recipient (default MAPI_MAILUSER) 37 | * @param displayType recipientRowDisplayType of the recipient (default MessagingUser) 38 | */ 39 | addCc( 40 | email: string, 41 | displayName: string = "", 42 | addressType: AddressType = "SMTP", 43 | objectType: MapiObjectType = MapiObjectType.MAPI_MAILUSER, 44 | displayType: RecipientRowDisplayType = RecipientRowDisplayType.MessagingUser, 45 | ) { 46 | this.push(new Recipient(this.length, email, displayName, addressType, RecipientType.Cc, objectType, displayType)) 47 | } 48 | 49 | addBcc( 50 | email: string, 51 | displayName: string = "", 52 | addressType: AddressType = "SMTP", 53 | objectType: MapiObjectType = MapiObjectType.MAPI_MAILUSER, 54 | displayType: RecipientRowDisplayType = RecipientRowDisplayType.MessagingUser, 55 | ) { 56 | this.push(new Recipient(this.length, email, displayName, addressType, RecipientType.Bcc, objectType, displayType)) 57 | } 58 | 59 | writeToStorage(rootStorage: CFBStorage): number { 60 | let size = 0 61 | 62 | for (let i = 0; i < this.length; i++) { 63 | const recipient = this[i] 64 | const storage = rootStorage.addStorage(PropertyTagLiterals.RecipientStoragePrefix + X8(i)) 65 | size += recipient.writeProperties(storage) 66 | } 67 | 68 | return size 69 | } 70 | } 71 | export class Recipient extends Address { 72 | private readonly _rowId: number 73 | readonly recipientType: RecipientType 74 | private readonly _displayType: RecipientRowDisplayType 75 | private readonly _objectType: MapiObjectType 76 | 77 | constructor( 78 | rowId: number, 79 | email: string, 80 | displayName: string, 81 | addressType: AddressType, 82 | recipientType: RecipientType, 83 | objectType: MapiObjectType, 84 | displayType: RecipientRowDisplayType, 85 | ) { 86 | super(email, displayName, addressType) 87 | this._rowId = rowId 88 | this.recipientType = recipientType 89 | this._displayType = displayType 90 | this._objectType = objectType 91 | } 92 | 93 | writeProperties(storage: CFBStorage): number { 94 | const propertiesStream = new RecipientProperties() 95 | propertiesStream.addProperty(PropertyTags.PR_ROWID, this._rowId) 96 | propertiesStream.addProperty(PropertyTags.PR_ENTRYID, generateEntryId()) 97 | propertiesStream.addProperty(PropertyTags.PR_INSTANCE_KEY, generateInstanceKey()) 98 | propertiesStream.addProperty(PropertyTags.PR_RECIPIENT_TYPE, this.recipientType) 99 | propertiesStream.addProperty(PropertyTags.PR_ADDRTYPE_W, this.addressType) 100 | propertiesStream.addProperty(PropertyTags.PR_EMAIL_ADDRESS_W, this.email) 101 | propertiesStream.addProperty(PropertyTags.PR_OBJECT_TYPE, this._objectType) 102 | propertiesStream.addProperty(PropertyTags.PR_DISPLAY_TYPE, this._displayType) 103 | propertiesStream.addProperty(PropertyTags.PR_DISPLAY_NAME_W, this.displayName) 104 | propertiesStream.addProperty(PropertyTags.PR_SEARCH_KEY, generateSearchKey(this.addressType, this.email)) 105 | return propertiesStream.writeProperties(storage, () => {}) 106 | } 107 | } -------------------------------------------------------------------------------- /lib/property.ts: -------------------------------------------------------------------------------- 1 | import {fileTimeToDate, oADateToDate} from "./utils/time.js" 2 | import {bigInt64FromParts, name, stringToUtf8Array, utf8ArrayToString, X4} from "./utils/utils.js" 3 | import {PropertyFlag, PropertyType} from "./enums.js" 4 | import {v4} from "uuid" 5 | 6 | /** 7 | * A property inside the MSG file 8 | */ 9 | export class Property { 10 | readonly id: number 11 | readonly type: PropertyType 12 | readonly _flags: number 13 | private readonly _multiValue: boolean 14 | readonly _data: Uint8Array 15 | 16 | constructor(obj: { id: number; type: PropertyType; data: Uint8Array; multiValue?: boolean; flags?: number }) { 17 | this.id = obj.id 18 | this.type = obj.type 19 | this._data = obj.data 20 | this._multiValue = obj.multiValue == null ? false : obj.multiValue 21 | this._flags = obj.flags == null ? 0 : obj.flags 22 | } 23 | 24 | name(): string { 25 | return name(this) 26 | } 27 | 28 | shortName(): string { 29 | return X4(this.id) 30 | } 31 | 32 | flagsCollection(): Array { 33 | const result: number[] = [] 34 | if ((this._flags & PropertyFlag.PROPATTR_MANDATORY) !== 0) result.push(PropertyFlag.PROPATTR_MANDATORY) 35 | if ((this._flags & PropertyFlag.PROPATTR_READABLE) !== 0) result.push(PropertyFlag.PROPATTR_READABLE) 36 | if ((this._flags & PropertyFlag.PROPATTR_WRITABLE) !== 0) result.push(PropertyFlag.PROPATTR_WRITABLE) 37 | return result 38 | } 39 | 40 | asInt(): number { 41 | const view = new DataView(this._data.buffer, 0) 42 | 43 | switch (this.type) { 44 | case PropertyType.PT_SHORT: 45 | return view.getInt16(0, false) 46 | 47 | case PropertyType.PT_LONG: 48 | return view.getInt32(0, false) 49 | 50 | default: 51 | throw new Error("type is not PT_SHORT or PT_LONG") 52 | } 53 | } 54 | 55 | asSingle(): number { 56 | const view = new DataView(this._data.buffer, 0) 57 | 58 | switch (this.type) { 59 | case PropertyType.PT_FLOAT: 60 | return view.getFloat32(0, false) 61 | 62 | default: 63 | throw new Error("type is not PT_FLOAT") 64 | } 65 | } 66 | 67 | asDouble(): number { 68 | const view = new DataView(this._data.buffer, 0) 69 | 70 | switch (this.type) { 71 | case PropertyType.PT_FLOAT: 72 | return view.getFloat64(0, false) 73 | 74 | default: 75 | throw new Error("type is not PT_DOUBLE") 76 | } 77 | } 78 | 79 | asDecimal(): number { 80 | const view = new DataView(this._data.buffer, 0) 81 | 82 | switch (this.type) { 83 | case PropertyType.PT_FLOAT: 84 | // TODO: is there a .Net decimal equivalent for js? 85 | return view.getFloat32(0, false) 86 | 87 | default: 88 | throw new Error("type is not PT_FLOAT") 89 | } 90 | } 91 | 92 | asDateTime(): Date { 93 | const view = new DataView(this._data.buffer, 0) 94 | 95 | switch (this.type) { 96 | case PropertyType.PT_APPTIME: 97 | // msg stores .Net DateTime as OADate, number of days since 30 dec 1899 as a double value 98 | const oaDate = view.getFloat64(0, false) 99 | return oADateToDate(oaDate) 100 | 101 | case PropertyType.PT_SYSTIME: 102 | // https://docs.microsoft.com/de-de/office/client-developer/outlook/mapi/filetime 103 | const fileTimeLower = view.getUint32(0, false) 104 | const fileTimeUpper = view.getUint32(4, false) 105 | return fileTimeToDate(bigInt64FromParts(fileTimeLower, fileTimeUpper)) 106 | 107 | default: 108 | throw new Error("type is not PT_APPTIME or PT_SYSTIME") 109 | } 110 | } 111 | 112 | asBool(): boolean { 113 | switch (this.type) { 114 | case PropertyType.PT_BOOLEAN: 115 | return Boolean(this._data[0]) 116 | 117 | default: 118 | throw new Error("type is not PT_BOOLEAN") 119 | } 120 | } 121 | 122 | // TODO: this will fail for very large numbers 123 | asLong(): number { 124 | const view = new DataView(this._data.buffer, 0) 125 | 126 | switch (this.type) { 127 | case PropertyType.PT_LONG: 128 | case PropertyType.PT_LONGLONG: 129 | const val = view.getFloat64(0, false) 130 | if (val > Number.MAX_SAFE_INTEGER) throw new Error("implementation can't handle big longs yet") 131 | return val 132 | 133 | default: 134 | throw new Error("type is not PT_LONG") 135 | } 136 | } 137 | 138 | asString(): string { 139 | switch (this.type) { 140 | case PropertyType.PT_UNICODE: 141 | return utf8ArrayToString(this._data) 142 | 143 | case PropertyType.PT_STRING8: 144 | return String.fromCharCode(...this._data) 145 | 146 | default: 147 | throw new Error("Type is not PT_UNICODE or PT_STRING8") 148 | } 149 | } 150 | 151 | asGuid(): Uint8Array { 152 | switch (PropertyType.PT_CLSID) { 153 | case this.type: 154 | const stringGuid = v4({ 155 | random: this._data.slice(0, 16), 156 | }) 157 | return stringToUtf8Array(stringGuid) 158 | 159 | default: 160 | throw new Error("Type is not PT_CLSID") 161 | } 162 | } 163 | 164 | asBinary(): Uint8Array { 165 | switch (this.type) { 166 | case PropertyType.PT_BINARY: 167 | return this._data.slice() 168 | 169 | default: 170 | throw new Error("Type is not PT_BINARY") 171 | } 172 | } 173 | } -------------------------------------------------------------------------------- /lib/streams/entry_stream.ts: -------------------------------------------------------------------------------- 1 | import {byteBufferAsUint8Array, makeByteBuffer} from "../utils/utils.js" 2 | import {PropertyTagLiterals} from "../property_tags.js" 3 | import type ByteBuffer from "bytebuffer" 4 | import {PropertyKind} from "../enums.js" 5 | import {CFBStorage} from "../cfb_storage.js" 6 | 7 | /** 8 | * The entry stream MUST be named "__substg1.0_00030102" and consist of 8-byte entries, one for each 9 | * named property being stored. The properties are assigned unique numeric IDs (distinct from any property 10 | * ID assignment) starting from a base of 0x8000. The IDs MUST be numbered consecutively, like an array. 11 | * In this stream, there MUST be exactly one entry for each named property of the Message object or any of 12 | * its subobjects. The index of the entry for a particular ID is calculated by subtracting 0x8000 from it. 13 | * For example, if the ID is 0x8005, the index for the corresponding 8-byte entry would be 0x8005 – 0x8000 = 5. 14 | * The index can then be multiplied by 8 to get the actual byte offset into the stream from where to start 15 | * reading the corresponding entry. 16 | * 17 | * see: https://msdn.microsoft.com/en-us/library/ee159689(v=exchg.80).aspx 18 | */ 19 | export class EntryStream extends Array { 20 | /** 21 | * creates this object and reads all the EntryStreamItems from 22 | * the given storage 23 | */ 24 | constructor(storage?: CFBStorage) { 25 | super() 26 | if (storage == null) return 27 | const stream = storage.getStream(PropertyTagLiterals.EntryStream) 28 | 29 | if (stream.byteLength <= 0) { 30 | storage.addStream(PropertyTagLiterals.EntryStream, Uint8Array.of()) 31 | } 32 | 33 | const buf = makeByteBuffer(undefined, stream) 34 | 35 | while (buf.offset < buf.limit) { 36 | const entryStreamItem = EntryStreamItem.fromBuffer(buf) 37 | this.push(entryStreamItem) 38 | } 39 | } 40 | 41 | /** 42 | * writes all the EntryStreamItems as a stream to the storage 43 | */ 44 | write(storage: CFBStorage, streamName: string = PropertyTagLiterals.EntryStream): void { 45 | const buf = makeByteBuffer() 46 | this.forEach(entry => entry.write(buf)) 47 | storage.addStream(streamName, byteBufferAsUint8Array(buf)) 48 | } 49 | } 50 | 51 | /** 52 | * Represents one item in the EntryStream 53 | */ 54 | export class EntryStreamItem { 55 | /** 56 | * the Property Kind subfield of the Index and Kind Information field), this value is the LID part of the 57 | * PropertyName structure, as specified in [MS-OXCDATA] section 2.6.1. If this property is a string named 58 | * property, this value is the offset in bytes into the strings stream where the value of the Name field of 59 | * the PropertyName structure is located. 60 | * was ushort 61 | * */ 62 | readonly nameIdentifierOrStringOffset: number 63 | readonly nameIdentifierOrStringOffsetHex: string 64 | 65 | /** 66 | * The following structure specifies the stream indexes and whether the property is a numerical 67 | * named property or a string named property 68 | * @type {IndexAndKindInformation} 69 | */ 70 | readonly indexAndKindInformation: IndexAndKindInformation 71 | 72 | /** 73 | * creates this objcet and reads all the properties from the given buffer 74 | * @param buf {ByteBuffer} 75 | */ 76 | static fromBuffer(buf: ByteBuffer): EntryStreamItem { 77 | const nameIdentifierOrStringOffset = buf.readUint16() 78 | const indexAndKindInformation = IndexAndKindInformation.fromBuffer(buf) 79 | return new EntryStreamItem(nameIdentifierOrStringOffset, indexAndKindInformation) 80 | } 81 | 82 | /** 83 | * creates this object from the properties 84 | * @param nameIdentifierOrStringOffset {number} 85 | * @param indexAndKindInformation {IndexAndKindInformation} 86 | */ 87 | constructor(nameIdentifierOrStringOffset: number, indexAndKindInformation: IndexAndKindInformation) { 88 | this.nameIdentifierOrStringOffset = nameIdentifierOrStringOffset 89 | this.nameIdentifierOrStringOffsetHex = nameIdentifierOrStringOffset.toString(16).toUpperCase().padStart(4, "0") 90 | this.indexAndKindInformation = indexAndKindInformation 91 | } 92 | 93 | /** 94 | * write all the internal properties to the given buffer 95 | * @param buf {ByteBuffer} 96 | */ 97 | write(buf: ByteBuffer): void { 98 | buf.writeUint32(this.nameIdentifierOrStringOffset) 99 | const packed = (this.indexAndKindInformation.guidIndex << 1) | this.indexAndKindInformation.propertyKind 100 | buf.writeUint16(packed) 101 | buf.writeUint16(this.indexAndKindInformation.propertyIndex) //Doesn't seem to be the case in the spec. 102 | // Fortunately section 3.2 clears this up. 103 | } 104 | } 105 | export class IndexAndKindInformation { 106 | // System.Uint16 107 | readonly propertyIndex: number 108 | readonly guidIndex: number 109 | // 1 byte 110 | readonly propertyKind: PropertyKind 111 | 112 | static fromBuffer(buf: ByteBuffer): IndexAndKindInformation { 113 | const propertyIndex = buf.readUint16() 114 | const packedValue = buf.readUint16() 115 | const guidIndex = (packedValue >>> 1) & 0xffff 116 | const propertyKind = packedValue & 0x07 117 | if (![0xff, 0x01, 0x00].includes(propertyKind)) throw new Error("invalid propertyKind:" + propertyKind) 118 | return new IndexAndKindInformation(propertyIndex, guidIndex, propertyKind) 119 | } 120 | 121 | constructor(propertyIndex: number, guidIndex: number, propertyKind: PropertyKind) { 122 | this.guidIndex = guidIndex 123 | this.propertyIndex = propertyIndex 124 | this.propertyKind = propertyKind 125 | } 126 | 127 | write(buf: ByteBuffer): void { 128 | buf.writeUint16(this.propertyIndex) 129 | buf.writeUint32(this.guidIndex + this.propertyKind) 130 | } 131 | } -------------------------------------------------------------------------------- /lib/helpers/crc32.ts: -------------------------------------------------------------------------------- 1 | import ByteBuffer from "bytebuffer" 2 | 3 | const CRC32_TABLE = [ 4 | 0x00000000, 5 | 0x77073096, 6 | 0xee0e612c, 7 | 0x990951ba, 8 | 0x076dc419, 9 | 0x706af48f, 10 | 0xe963a535, 11 | 0x9e6495a3, 12 | 0x0edb8832, 13 | 0x79dcb8a4, 14 | 0xe0d5e91e, 15 | 0x97d2d988, 16 | 0x09b64c2b, 17 | 0x7eb17cbd, 18 | 0xe7b82d07, 19 | 0x90bf1d91, 20 | 0x1db71064, 21 | 0x6ab020f2, 22 | 0xf3b97148, 23 | 0x84be41de, 24 | 0x1adad47d, 25 | 0x6ddde4eb, 26 | 0xf4d4b551, 27 | 0x83d385c7, 28 | 0x136c9856, 29 | 0x646ba8c0, 30 | 0xfd62f97a, 31 | 0x8a65c9ec, 32 | 0x14015c4f, 33 | 0x63066cd9, 34 | 0xfa0f3d63, 35 | 0x8d080df5, 36 | 0x3b6e20c8, 37 | 0x4c69105e, 38 | 0xd56041e4, 39 | 0xa2677172, 40 | 0x3c03e4d1, 41 | 0x4b04d447, 42 | 0xd20d85fd, 43 | 0xa50ab56b, 44 | 0x35b5a8fa, 45 | 0x42b2986c, 46 | 0xdbbbc9d6, 47 | 0xacbcf940, 48 | 0x32d86ce3, 49 | 0x45df5c75, 50 | 0xdcd60dcf, 51 | 0xabd13d59, 52 | 0x26d930ac, 53 | 0x51de003a, 54 | 0xc8d75180, 55 | 0xbfd06116, 56 | 0x21b4f4b5, 57 | 0x56b3c423, 58 | 0xcfba9599, 59 | 0xb8bda50f, 60 | 0x2802b89e, 61 | 0x5f058808, 62 | 0xc60cd9b2, 63 | 0xb10be924, 64 | 0x2f6f7c87, 65 | 0x58684c11, 66 | 0xc1611dab, 67 | 0xb6662d3d, 68 | 0x76dc4190, 69 | 0x01db7106, 70 | 0x98d220bc, 71 | 0xefd5102a, 72 | 0x71b18589, 73 | 0x06b6b51f, 74 | 0x9fbfe4a5, 75 | 0xe8b8d433, 76 | 0x7807c9a2, 77 | 0x0f00f934, 78 | 0x9609a88e, 79 | 0xe10e9818, 80 | 0x7f6a0dbb, 81 | 0x086d3d2d, 82 | 0x91646c97, 83 | 0xe6635c01, 84 | 0x6b6b51f4, 85 | 0x1c6c6162, 86 | 0x856530d8, 87 | 0xf262004e, 88 | 0x6c0695ed, 89 | 0x1b01a57b, 90 | 0x8208f4c1, 91 | 0xf50fc457, 92 | 0x65b0d9c6, 93 | 0x12b7e950, 94 | 0x8bbeb8ea, 95 | 0xfcb9887c, 96 | 0x62dd1ddf, 97 | 0x15da2d49, 98 | 0x8cd37cf3, 99 | 0xfbd44c65, 100 | 0x4db26158, 101 | 0x3ab551ce, 102 | 0xa3bc0074, 103 | 0xd4bb30e2, 104 | 0x4adfa541, 105 | 0x3dd895d7, 106 | 0xa4d1c46d, 107 | 0xd3d6f4fb, 108 | 0x4369e96a, 109 | 0x346ed9fc, 110 | 0xad678846, 111 | 0xda60b8d0, 112 | 0x44042d73, 113 | 0x33031de5, 114 | 0xaa0a4c5f, 115 | 0xdd0d7cc9, 116 | 0x5005713c, 117 | 0x270241aa, 118 | 0xbe0b1010, 119 | 0xc90c2086, 120 | 0x5768b525, 121 | 0x206f85b3, 122 | 0xb966d409, 123 | 0xce61e49f, 124 | 0x5edef90e, 125 | 0x29d9c998, 126 | 0xb0d09822, 127 | 0xc7d7a8b4, 128 | 0x59b33d17, 129 | 0x2eb40d81, 130 | 0xb7bd5c3b, 131 | 0xc0ba6cad, 132 | 0xedb88320, 133 | 0x9abfb3b6, 134 | 0x03b6e20c, 135 | 0x74b1d29a, 136 | 0xead54739, 137 | 0x9dd277af, 138 | 0x04db2615, 139 | 0x73dc1683, 140 | 0xe3630b12, 141 | 0x94643b84, 142 | 0x0d6d6a3e, 143 | 0x7a6a5aa8, 144 | 0xe40ecf0b, 145 | 0x9309ff9d, 146 | 0x0a00ae27, 147 | 0x7d079eb1, 148 | 0xf00f9344, 149 | 0x8708a3d2, 150 | 0x1e01f268, 151 | 0x6906c2fe, 152 | 0xf762575d, 153 | 0x806567cb, 154 | 0x196c3671, 155 | 0x6e6b06e7, 156 | 0xfed41b76, 157 | 0x89d32be0, 158 | 0x10da7a5a, 159 | 0x67dd4acc, 160 | 0xf9b9df6f, 161 | 0x8ebeeff9, 162 | 0x17b7be43, 163 | 0x60b08ed5, 164 | 0xd6d6a3e8, 165 | 0xa1d1937e, 166 | 0x38d8c2c4, 167 | 0x4fdff252, 168 | 0xd1bb67f1, 169 | 0xa6bc5767, 170 | 0x3fb506dd, 171 | 0x48b2364b, 172 | 0xd80d2bda, 173 | 0xaf0a1b4c, 174 | 0x36034af6, 175 | 0x41047a60, 176 | 0xdf60efc3, 177 | 0xa867df55, 178 | 0x316e8eef, 179 | 0x4669be79, 180 | 0xcb61b38c, 181 | 0xbc66831a, 182 | 0x256fd2a0, 183 | 0x5268e236, 184 | 0xcc0c7795, 185 | 0xbb0b4703, 186 | 0x220216b9, 187 | 0x5505262f, 188 | 0xc5ba3bbe, 189 | 0xb2bd0b28, 190 | 0x2bb45a92, 191 | 0x5cb36a04, 192 | 0xc2d7ffa7, 193 | 0xb5d0cf31, 194 | 0x2cd99e8b, 195 | 0x5bdeae1d, 196 | 0x9b64c2b0, 197 | 0xec63f226, 198 | 0x756aa39c, 199 | 0x026d930a, 200 | 0x9c0906a9, 201 | 0xeb0e363f, 202 | 0x72076785, 203 | 0x05005713, 204 | 0x95bf4a82, 205 | 0xe2b87a14, 206 | 0x7bb12bae, 207 | 0x0cb61b38, 208 | 0x92d28e9b, 209 | 0xe5d5be0d, 210 | 0x7cdcefb7, 211 | 0x0bdbdf21, 212 | 0x86d3d2d4, 213 | 0xf1d4e242, 214 | 0x68ddb3f8, 215 | 0x1fda836e, 216 | 0x81be16cd, 217 | 0xf6b9265b, 218 | 0x6fb077e1, 219 | 0x18b74777, 220 | 0x88085ae6, 221 | 0xff0f6a70, 222 | 0x66063bca, 223 | 0x11010b5c, 224 | 0x8f659eff, 225 | 0xf862ae69, 226 | 0x616bffd3, 227 | 0x166ccf45, 228 | 0xa00ae278, 229 | 0xd70dd2ee, 230 | 0x4e048354, 231 | 0x3903b3c2, 232 | 0xa7672661, 233 | 0xd06016f7, 234 | 0x4969474d, 235 | 0x3e6e77db, 236 | 0xaed16a4a, 237 | 0xd9d65adc, 238 | 0x40df0b66, 239 | 0x37d83bf0, 240 | 0xa9bcae53, 241 | 0xdebb9ec5, 242 | 0x47b2cf7f, 243 | 0x30b5ffe9, 244 | 0xbdbdf21c, 245 | 0xcabac28a, 246 | 0x53b39330, 247 | 0x24b4a3a6, 248 | 0xbad03605, 249 | 0xcdd70693, 250 | 0x54de5729, 251 | 0x23d967bf, 252 | 0xb3667a2e, 253 | 0xc4614ab8, 254 | 0x5d681b02, 255 | 0x2a6f2b94, 256 | 0xb40bbe37, 257 | 0xc30c8ea1, 258 | 0x5a05df1b, 259 | 0x2d02ef8d, 260 | ] as const 261 | 262 | export class Crc32 { 263 | /** 264 | * calculates a checksum of a ByteBuffers contents 265 | * @param buffer {ByteBuffer} 266 | * @returns {number} the crc32 of this buffer's contents between offset and limit 267 | */ 268 | static calculate(buffer: ByteBuffer): number { 269 | if (buffer.offset >= buffer.limit) return 0 270 | const origOffset = buffer.offset 271 | let result = 0 272 | 273 | while (buffer.offset < buffer.limit) { 274 | const cur = buffer.readUint8() 275 | result = CRC32_TABLE[(result ^ cur) & 0xff] ^ (result >>> 8) 276 | } 277 | 278 | buffer.offset = origOffset 279 | // unsigned representation. (-1 >>> 0) === 4294967295 280 | return result >>> 0 281 | } 282 | } -------------------------------------------------------------------------------- /lib/address/one_off_entry_id.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "./address" 2 | import type {AddressType} from "../enums" 3 | import {MessageFormat} from "../enums" 4 | import {byteBufferAsUint8Array, makeByteBuffer, stringToUtf16LeArray} from "../utils/utils" 5 | 6 | export class OneOffEntryId extends Address { 7 | private readonly _messageFormat: MessageFormat 8 | private readonly _canLookupEmailAddress: boolean 9 | 10 | constructor( 11 | email: string, 12 | displayName: string, 13 | addressType: AddressType = "SMTP", 14 | messageFormat: MessageFormat = MessageFormat.TextAndHtml, 15 | canLookupEmailAddress: boolean = false, 16 | ) { 17 | super(email, displayName, addressType) 18 | this._messageFormat = messageFormat 19 | this._canLookupEmailAddress = canLookupEmailAddress 20 | } 21 | 22 | toByteArray(): Uint8Array { 23 | const buf = makeByteBuffer() 24 | // Flags (4 bytes): This value is set to 0x00000000. Bits in this field indicate under what circumstances 25 | // a short-term EntryID is valid. However, in any EntryID stored in a property value, these 4 bytes are 26 | // zero, indicating a long-term EntryID. 27 | buf.writeUint32(0) 28 | // ProviderUID (16 bytes): The identifier of the provider that created the EntryID. This value is used to 29 | // route EntryIDs to the correct provider and MUST be set to %x81.2B.1F.A4.BE.A3.10.19.9D.6E.00.DD.01.0F.54.02. 30 | buf.append(Uint8Array.from([0x81, 0x2b, 0x1f, 0xa4, 0xbe, 0xa3, 0x10, 0x19, 0x9d, 0x6e, 0x00, 0xdd, 0x01, 0x0f, 0x54, 0x02])) 31 | // Version (2 bytes): This value is set to 0x0000. 32 | buf.writeUint16(0) 33 | let bits = 0x0000 34 | 35 | // Pad(1 bit): (mask 0x8000) Reserved.This value is set to '0'. 36 | // bits |= 0x00 << 0 37 | // MAE (2 bits): (mask 0x0C00) The encoding used for Macintosh-specific data attachments, as specified in 38 | // [MS-OXCMAIL] section 2.1.3.4.3. The values for this field are specified in the following table. 39 | // Name | Word value | Field value | Description 40 | // BinHex 0x0000 b'00' BinHex encoded. 41 | // UUENCODE 0x0020 b'01' UUENCODED.Not valid if the message is in Multipurpose Internet Mail 42 | // Extensions(MIME) format, in which case the flag will be ignored and 43 | // BinHex used instead. 44 | // AppleSingle 0x0040 b'10' Apple Single encoded.Allowed only when the message format is MIME. 45 | // AppleDouble 0x0060 b'11' Apple Double encoded.Allowed only when the message format is MIME. 46 | // bits |= 0x00 << 1 47 | // bits |= 0x00 << 2 48 | // Format (4 bits): (enumeration, mask 0x1E00) The message format desired for this recipient (1), as specified 49 | // in the following table. 50 | // Name | Word value | Field value | Description 51 | // TextOnly 0x0006 b'0011' Send a plain text message body. 52 | // HtmlOnly 0x000E b'0111' Send an HTML message body. 53 | // TextAndHtml 0x0016 b'1011' Send a multipart / alternative body with both plain text and HTML. 54 | switch (this._messageFormat) { 55 | case MessageFormat.TextOnly: 56 | //bits |= 0x00 << 3 57 | //bits |= 0x00 << 4 58 | bits |= 0x01 << 5 59 | bits |= 0x01 << 6 60 | break 61 | 62 | case MessageFormat.HtmlOnly: 63 | //bits |= 0x00 << 3 64 | bits |= 0x01 << 4 65 | bits |= 0x01 << 5 66 | bits |= 0x01 << 6 67 | break 68 | 69 | case MessageFormat.TextAndHtml: 70 | bits |= 0x01 << 3 71 | //bits |= 0x00 << 4 72 | bits |= 0x01 << 5 73 | bits |= 0x01 << 6 74 | break 75 | } 76 | 77 | // M (1 bit): (mask 0x0100) A flag that indicates how messages are to be sent. If b'0', indicates messages are 78 | // to be sent to the recipient (1) in Transport Neutral Encapsulation Format (TNEF) format; if b'1', messages 79 | // are sent to the recipient (1) in pure MIME format. 80 | bits |= 0x01 << 7 81 | // U (1 bit): (mask 0x0080) A flag that indicates the format of the string fields that follow. If b'1', the 82 | // string fields following are in Unicode (UTF-16 form) with 2-byte terminating null characters; if b'0', the 83 | // string fields following are multibyte character set (MBCS) characters terminated by a single 0 byte. 84 | bits |= 0x01 << 8 85 | 86 | // R (2 bits): (mask 0x0060) Reserved. This value is set to b'00'. 87 | //bits |= 0x00 << 9 88 | //bits |= 0x00 << 10 89 | // L (1 bit): (mask 0x0010) A flag that indicates whether the server can look up an address in the address 90 | // book. If b'1', server cannot look up this user's email address in the address book. If b'0', server can 91 | // look up this user's email address in the address book. 92 | if (this._canLookupEmailAddress) { 93 | bits |= 0x01 << 11 94 | } 95 | 96 | // Pad (4 bits): (mask 0x000F) Reserved. This value is set to b'0000'. 97 | // bits |= 0x01 << 12 98 | // bits |= 0x01 << 13 99 | // bits |= 0x01 << 14 100 | // bits |= 0x01 << 15 101 | // if (BitConverter.IsLittleEndian) { 102 | // bits = bits.Reverse().ToArray(); 103 | // binaryWriter.Write(bits); 104 | // } else { 105 | // binaryWriter.Write(bits); 106 | // } 107 | buf.writeUint8((bits >>> 8) & 0xff) 108 | buf.writeUint8(bits & 0xff) 109 | //Strings.WriteNullTerminatedUnicodeString(binaryWriter, DisplayName); 110 | buf.append(stringToUtf16LeArray(this.displayName)) 111 | buf.writeUint16(0) 112 | buf.append(stringToUtf16LeArray(this.addressType)) 113 | buf.writeUint16(0) 114 | buf.append(stringToUtf16LeArray(this.email)) 115 | buf.writeUint16(0) 116 | return byteBufferAsUint8Array(buf) 117 | } 118 | } -------------------------------------------------------------------------------- /lib/structures/report_tag.ts: -------------------------------------------------------------------------------- 1 | import {byteBufferAsUint8Array, makeByteBuffer} from "../utils/utils.js" 2 | 3 | /** 4 | * The PidTagReportTag property ([MS-OXPROPS] section 2.917) contains the data that is used to correlate the report 5 | * and the original message. The property can be absent if the sender does not request a reply or response to the 6 | * original e-mail message. If the original E-mail object has either the PidTagResponseRequested property (section 7 | * 2.2.1.46) set to 0x01 or the PidTagReplyRequested property (section 2.2.1.45) set to 0x01, then the property is set 8 | * on the original E-mail object by using the following format. 9 | * See https://msdn.microsoft.com/en-us/library/ee160822(v=exchg.80).aspx 10 | */ 11 | export class ReportTag { 12 | // (9 bytes): A null-terminated string of nine characters used for validation; set to "PCDFEB09". 13 | cookie: string = "PCDFEB09\0" 14 | // (4 bytes): This field specifies the version. If the SearchFolderEntryId field is present, this field MUST be set to 15 | // 0x00020001; otherwise, this field MUST be set to 0x00010001. 16 | version: number = 0x00010001 17 | // (4 bytes): Size of the StoreEntryId field. 18 | storeEntryIdSize: number = 0x00000000 19 | // (Variable length of bytes): This field specifies the entry ID of the mailbox that contains the original message. If 20 | // the value of the 21 | // StoreEntryIdSize field is 0x00000000, this field is omitted. If the value is not zero, this field is filled with 22 | // the number of bytes specified by the StoreEntryIdSize field. 23 | // storeEntryId: Uint8Array 24 | 25 | // (4 bytes): Size of the FolderEntryId field. 26 | folderEntryIdSize: number = 0x00000000 27 | // (Variable): This field specifies the entry ID of the folder that contains the original message. If the value of the 28 | // FolderEntryIdSize field is 0x00000000, this field is omitted. If the value is not zero, the field is filled with 29 | // the number of bytes specified by the FolderEntryIdSize field. 30 | folderEntryId: number = 0 31 | // (4 bytes): Size of the MessageEntryId field. 32 | messageEntryIdSize: number = 0x00000000 33 | // (Variable): This field specifies the entry ID of the original message. If the value of the MessageEntryIdSize field 34 | // is 0x00000000, this field is omitted. If the value is not zero, the field is filled with the number of bytes 35 | // specified by the MessageEntryIdSize field. 36 | messageEntryId: number = 0 37 | // (4 bytes): Size of the SearchFolderEntryId field. 38 | searchFolderEntryIdSize: number = 0x00000000 39 | // (Variable): This field specifies the entry ID of an alternate folder that contains the original message. If the 40 | // value of the SearchFolderEntryIdSize field is 0x00000000, this field is omitted. If the value is not zero, the 41 | // field is filled with the number of bytes specified by the SearchFolderEntryIdSize field. 42 | // searchFolderEntryId: Uint8Array 43 | 44 | // (4 bytes): Size of the MessageSearchKey field. 45 | messageSearchKeySize: number = 0x00000000 46 | // (variable): This field specifies the search key of the original message. If the value of the MessageSearchKeySize 47 | // field is 0x00000000, this field is omitted. If the value is not zero, the MessageSearchKey field is filled with the 48 | // number of bytes specified by the MessageSearchKeySize field. 49 | // messageSearchKey: Uint8Array 50 | 51 | // (Variable): The subject of the original message. If the value of the ANSITextSize field is 0x00000000, this field 52 | // is omitted. If the value is not zero, the field is filled with the number of bytes specified by the ANSITextSize 53 | // field. 54 | ansiText: string 55 | 56 | constructor(ansiText: string) { 57 | this.ansiText = ansiText 58 | } 59 | 60 | /** 61 | * Returns this object as a byte array 62 | */ 63 | toByteArray(): Uint8Array { 64 | const buf = makeByteBuffer() 65 | // Cookie (9 bytes): A null-terminated string of nine characters used for validation; set to "PCDFEB09". 66 | // TODO: 67 | buf.writeUTF8String(this.cookie) 68 | // Version (4 bytes): This field specifies the version. If the SearchFolderEntryId field is present, 69 | // this field MUST be set to 0x00020001; otherwise, this field MUST be set to 0x00010001. 70 | buf.writeUint32(this.version) 71 | // (4 bytes): Size of the StoreEntryId field. 72 | buf.writeUint32(this.storeEntryIdSize) 73 | // (Variable length of bytes): This field specifies the entry ID of the mailbox that contains the original message. If 74 | // the value of the StoreEntryIdSize field is 0x00000000, this field is omitted. If the value is not zero, this field 75 | // is filled with the number of bytes specified by the StoreEntryIdSize field. 76 | 77 | //buf.append(this.storeEntryId); 78 | 79 | // (4 bytes): Size of the FolderEntryId field. 80 | buf.writeUint32(this.folderEntryIdSize) 81 | // (Variable): This field specifies the entry ID of the folder that contains the original message. If the value of the 82 | // FolderEntryIdSize field is 0x00000000, this field is omitted. If the value is not zero, the field is filled with 83 | // the number of bytes specified by the FolderEntryIdSize field. 84 | //buf.append(this.folderEntryId) 85 | // (4 bytes): Size of the MessageEntryId field. 86 | buf.writeUint32(this.messageEntryIdSize) 87 | // (Variable): This field specifies the entry ID of the original message. If the value of the MessageEntryIdSize field 88 | // is 0x00000000, this field is omitted. If the value is not zero, the field is filled with the number of bytes 89 | // specified by the MessageEntryIdSize field. 90 | //buf.append(this.messageEntryId) 91 | // (4 bytes): Size of the SearchFolderEntryId field. 92 | buf.writeUint32(this.searchFolderEntryIdSize) 93 | // (Variable): This field specifies the entry ID of an alternate folder that contains the original message. If the 94 | // value of the SearchFolderEntryIdSize field is 0x00000000, this field is omitted. If the value is not zero, the 95 | // field is filled with the number of bytes specified by the SearchFolderEntryIdSize field. 96 | //buf.append(this.searchFolderEntryId) 97 | // (4 bytes): Size of the MessageSearchKey field. 98 | buf.writeUint32(this.messageSearchKeySize) 99 | // (variable): This field specifies the search key of the original message. If the value of the MessageSearchKeySize 100 | // field is 0x00000000, this field is omitted. If the value is not zero, the MessageSearchKey field is filled with the 101 | // number of bytes specified by the MessageSearchKeySize field. 102 | //buf.append(this.messageSearchKey) 103 | // (4 bytes): Number of characters in the ANSI Text field. 104 | buf.writeUint32(this.ansiText.length) 105 | // (Variable): The subject of the original message. If the value of the ANSITextSize field is 0x00000000, this field 106 | // is omitted. If the value is not zero, the field is filled with the number of bytes specified by the ANSITextSize 107 | // field. 108 | buf.writeUTF8String(this.ansiText) 109 | return byteBufferAsUint8Array(buf) 110 | } 111 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oxmsg 2 | 3 | write Microsoft .msg Outlook Item files. 4 | 5 | ## MSG Outlook Items 6 | These are compound files in cfb format, which is a simplified 7 | filesystem-in-a-file with directories (called storages) and files (called streams). 8 | 9 | msg can contain outlook appointments and emails, they consist of a property stream in the top-level storage and several 10 | sub-storages (ie for attachments). 11 | 12 | Streams contain either raw data or a list of Properties of 16 bytes each. 13 | Properties consist of a 2 byte type (binary, boolean, long etc), 2 byte id (usage), 4 bytes of flags and 8 bytes of value. 14 | Property tags (the first 4 bytes of a property) are defined in `lib/property_tags.js`. 15 | 16 | ## MsgKit 17 | [MsgKit](https://github.com/Sicos1977/MsgKit) is the C# library oxmsg was based on. 18 | 19 | ### Emails 20 | When creating a new Email instance, oxmsg will prepare the data structure to store all attributes, 21 | like a list of attachments, a list of recipients, and so on. These can be filled by adding the items to the Email object. 22 | 23 | The final .msg will be created by calling .msg() on the Email, at which point the Email object will serialize itself into 24 | a cfb compound file provided by [`cfb`](https://www.npmjs.com/package/cfb), which is then written to an Uint8Array. 25 | Example usage in examples/node.js. 26 | 27 | ### Appointments 28 | MsgKit also supports exporting Outlook Appointments to msg, but those parts are not translated yet. 29 | 30 | ## TODO 31 | - The library currently works in node, but could be made to work in the browser if it's shrunk and the dependencies that rely on node Buffers are replaced (mainly the parsers in lib/mime/header/ that use iconv-lite). BigInt would need to be polyfilled and bigint literals removed 32 | - it would probably be good to generate/provide an `index.d.ts` type definition file, which should not be too hard since the actual API surface is tiny. 33 | - The library has the capability to parse email headers if that should be necessary (`lib/mime/header/`), but that's not 34 | done yet. 35 | - There's also code to read .msg files into an Email object that's not useful yet 36 | 37 | The next steps would include using the library to export mails and figure out if the api makes sense and if the timestamps match expectations. 38 | 39 | ## Docs & Tools 40 | 41 | - [OxMsg Spec](https://interoperability.blob.core.windows.net/files/MS-OXMSG/%5bMS-OXMSG%5d.pdf) 42 | - [CFB Spec](https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-CFB/%5bMS-CFB%5d.pdf) 43 | 44 | ### CFB viewer 45 | [online cfb viewer](https://oss.sheetjs.com/cfb-editor/#/cfb-editor/) 46 | 47 | This online tool (made by the developers of the `cfb` library) shows the contents of a .msg file, consisting of all storages with their names, and streams with their contents (in hex and ASCII). 48 | 49 | ### Comparer 50 | `examples/comparer.js` is a small utility that takes two cfb files and shows the differences between them. It will show streams/storages that are contained in only one of them, and if the element is contained in both but has different contents, it will show the difference. 51 | 52 | Example (run with `-a` after the files to compare to show identical elements as well): 53 | ``` 54 | $ node examples/comparer.js test/testOut_attach.msg test/msgKitOut_attach.msg -a 55 | 56 | MISSING: 4 Root Entry/Sh33tJ5 not in /home/nils/Projects/repositories/oxmsg/test/msgKitOut_attach.msg 57 | tag: undefined 58 | 0000: 37 32 36 32 59 | 7262 60 | 61 | SAME: size: 38 Root Entry/__substg1.0_0E1D001F 62 | tag: PR_NORMALIZED_SUBJECT_W 63 | 64 | DIFFERENCE: 72:72 Root Entry/__substg1.0_0FFF0102 type 2 65 | tag: PR_ENTRYID 66 | test/testOut_attach.msg || test/msgKitOut_attach.msg 67 | no tag || no tag 68 | 0000: 65 00 65 00 36 00 62 00 30 00 34 00 35 00 62 00 || 64 00 35 00 66 00 62 00 36 00 38 00 61 00 35 00 69 | no tag || no tag 70 | 0010: 2d 00 61 00 31 00 64 00 63 00 2d 00 34 00 64 00 || 2d 00 30 00 37 00 37 00 66 00 2d 00 34 00 63 00 71 | no tag || no tag 72 | 0020: 38 00 62 00 2d 00 38 00 34 00 31 00 65 00 2d 00 || 36 00 66 00 2d 00 38 00 66 00 66 00 31 00 2d 00 73 | no tag || no tag 74 | 0030: 63 00 34 00 35 00 36 00 38 00 39 00 63 00 37 00 || 38 00 61 00 34 00 38 00 35 00 32 00 39 00 37 00 75 | no tag || no tag 76 | 0040: 63 00 36 00 36 00 33 00 || 30 00 63 00 31 00 61 00 77 | ee6b045b-a1dc-4d8b-841e-c45689c7c663 78 | d5fb68a5-077f-4c6f-8ff1-8a4852970c1a 79 | 80 | DIFFERENCE: 644:644 Root Entry/__properties_version1.0 type 2 81 | tag: undefined 82 | test/testOut_attach.msg || test/msgKitOut_attach.msg 83 | PR_CLIENT_SUBMIT_TIME || PR_CLIENT_SUBMIT_TIME 84 | 00c0: 40 00 39 00 06 00 00 00 00 00 00 00 00 00 00 00 || 40 00 39 00 06 00 00 00 00 3f cf 4e 90 4d d2 01 85 | PR_CREATION_TIME || PR_CREATION_TIME 86 | 0140: 40 00 07 30 06 00 00 00 00 00 00 00 00 00 00 00 || 40 00 07 30 06 00 00 00 9c 93 95 53 cc ce d6 01 87 | PR_LAST_MODIFICATION_TIME || PR_LAST_MODIFICATION_TIME 88 | 0150: 40 00 08 30 06 00 00 00 00 00 00 00 00 00 00 00 || 40 00 08 30 06 00 00 00 9c 93 95 53 cc ce d6 01 89 | no tag || no tag 90 | 0270: 03 00 08 0e 06 00 00 00 00 00 00 00 00 00 00 00 || 03 00 08 0e 06 00 00 00 64 fa 03 00 00 00 00 00 91 | 4848 92 | 93 | ???? 94 | @9???7((=p(@00? 95 | ``` 96 | This is part of the result of comparing MsgKit Output and oxmsg output with the same contents. 97 | - It's reporting that the stream `Root Entry/Sh33tJ` is in the oxmsg output, but not added by MsgKit (that's a "maker's mark" added by the devs of `cfb` that doesn't impact functionality). The `4` means that the stream contains 4 bytes, `tag: undefined` means that there's no meaningful part of a .msg file associated with the stream name and `0000: 37 32 36 32` | `7262` are the contents of the stream in hex and ascii. `type 2` just means it's a stream and not a storage. 98 | - The stream `Root Entry/__substg1.0_0E1D001F` is contained in both files and identical. Its name is associated with the property tag `PR_NORMALIZED_SUBJECT_W` (which can be searched in the code to find out about it) and 38 bytes long. 99 | - `Root Entry/__substg1.0_0FFF0102` is contained in both, but different. `72:72` means that both streams are 72 bytes long (if they don't have the same length, it's likely that the content listing below won't line up). The name is associated with `PR_ENTRYID` which, if looked up in `lib/property_tags.js`, contains binary data (a GUID in ascii, to be precise, which should be different). 100 | - `Root Entry/__properties_version1.0` is the top-level properties stream, which doesn't have an associated property tag. It contains several different properties that are different. The `PR_CLIENT_SUBMIT_TIME` property at offset `00c0` is different between the two files (`oxmsg` set it to 0), as is the creation time and the last modification time. The property at `0270` is a checksum at the end of the stream. 101 | -------------------------------------------------------------------------------- /lib/mime/header/rfc_mail_address.ts: -------------------------------------------------------------------------------- 1 | import * as rfc2822Parser from "address-rfc2822" 2 | import type {Address} from "address-rfc2822" 3 | import {splitAtUnquoted} from "../../utils/utils.js" 4 | import {decode as decodeRfc2047} from "./rfc2047.js" 5 | 6 | // This class is used for RFC compliant email addresses. 7 | // The class cannot be instantiated from outside the library. 8 | // The MailAddress does not cover all the possible formats 9 | // for RFC 5322 section 3.4 compliant email addresses. 10 | // This class is used as an address wrapper to account for that deficiency. 11 | export class RfcMailAddress { 12 | // A string representation of the object 13 | // Returns the string representation for the object 14 | toString(): string { 15 | if (!!this.mailAddress && this.hasValidMailAddress()) return this.mailAddress.format() 16 | else return this.raw 17 | } 18 | 19 | /// The email address of this
20 | /// It is possibly string.Empty since RFC mail addresses does not require an email address specified. 21 | /// Example header with email address:
22 | /// To: Test test@mail.com
23 | /// Address will be test@mail.com
24 | /// Example header without email address:
25 | /// To: Test
26 | /// Address will be . 27 | readonly address: string 28 | /// The display name of this
29 | /// It is possibly since RFC mail addresses does not require a display name to be 30 | /// specified. 31 | // 32 | /// Example header with display name:
33 | /// To: Test test@mail.com
34 | /// DisplayName will be Test 35 | // 36 | /// Example header without display name: 37 | /// To: test@test.com 38 | /// DisplayName will be "" 39 | readonly displayName: string 40 | // This is the Raw string used to describe the RfcMailAddress 41 | readonly raw: string 42 | /// The mailAddress associated with the rfcMailAddress 43 | /// The value of this property can be null in instances where the mailAddress 44 | /// represent the address properly. 45 | /// Use hasValidMailAddress property to see if this property is valid. 46 | readonly mailAddress: Address | null 47 | 48 | /// 49 | /// Specifies if the object contains a valid reference. 50 | /// 51 | hasValidMailAddress(): boolean { 52 | return this.mailAddress != null 53 | } 54 | 55 | /// 56 | /// Constructs an object from a object.
57 | /// This constructor is used when we were able to construct a from a string. 58 | ///
59 | /// The address that was parsed into 60 | /// The raw unparsed input which was parsed into the 61 | /// 62 | /// If or is 63 | /// 64 | /// 65 | constructor(mailAddress: Address | null, raw: string) { 66 | if (!raw) throw new Error("raw is null!") 67 | this.mailAddress = mailAddress 68 | this.raw = raw 69 | 70 | if (!mailAddress) { 71 | this.address = "" 72 | this.displayName = raw 73 | } else { 74 | this.address = mailAddress.address 75 | this.displayName = mailAddress.name() 76 | } 77 | } 78 | 79 | /// Parses an email address from a MIME header
80 | ///
81 | /// Examples of input: 82 | /// Eksperten mailrobot <noreply@mail.eksperten.dk>
83 | /// "Eksperten mailrobot" <noreply@mail.eksperten.dk>
84 | /// <noreply@mail.eksperten.dk>
85 | /// noreply@mail.eksperten.dk
86 | /// It might also contain encoded text, which will then be decoded. 87 | /// The value to parse out and email and/or a username 88 | /// A 89 | /// If is 90 | // 91 | /// RFC 5322 section 3.4 for more details on email 92 | /// syntax.
93 | /// For more information about encoded text. 94 | static parseMailAddress(input: string): RfcMailAddress { 95 | if (input == null) throw new Error("input was null!") 96 | input = decodeRfc2047(input.trim()) 97 | //Remove any redundant sets of angle brackets around the email address 98 | const lastOpenAngleBracketIdx = input.lastIndexOf("<") 99 | const lastCloseAngleBracketIdx = input.lastIndexOf(">") 100 | //Find the index of the first angle bracket in this series of angle brackets, e.g "a>b" <> wouldn't find the angle bracket in the display name 101 | let firstOpenAngleBracketIdx = lastOpenAngleBracketIdx 102 | let firstCloseAngleBracketIdx = lastCloseAngleBracketIdx 103 | 104 | while ( 105 | firstOpenAngleBracketIdx > 0 && //There is a character before the last open angle bracket 106 | input[firstOpenAngleBracketIdx - 1] === "<" && //The character before the last open angle bracket is another open angle bracket 107 | input[firstCloseAngleBracketIdx - 1] === ">" //The character before the last close angle bracket is another close angle bracket 108 | ) { 109 | //Update the first angle bracket indices 110 | firstOpenAngleBracketIdx-- 111 | firstCloseAngleBracketIdx-- 112 | } 113 | 114 | //If the email address in the input string is enclosed in multiple angle brackets 115 | if (firstOpenAngleBracketIdx !== lastOpenAngleBracketIdx) { 116 | //Part before any angle brackets (display name if there is one) 117 | const dpn = input.substring(0, firstOpenAngleBracketIdx) 118 | //actual email address, including one angle bracket either side 119 | const ma = input.substring(lastOpenAngleBracketIdx, firstCloseAngleBracketIdx + 1) 120 | input = dpn + ma 121 | } 122 | 123 | try { 124 | const result = rfc2822Parser.parse(input)[0] 125 | return new RfcMailAddress(result, input) 126 | } catch (e) { 127 | // It could be that the format used was simply a name 128 | // which is indeed valid according to the RFC 129 | // Example: 130 | // Eksperten mailrobot 131 | } 132 | 133 | return new RfcMailAddress(null, input) 134 | } 135 | 136 | // Parses input of the form
137 | // Eksperten mailrobot <noreply@mail.eksperten.dk>, ...
138 | // to a list of RFCMailAddresses 139 | // The input that is a comma-separated list of EmailAddresses to parse 140 | // A List of RfcMailAddress objects extracted from the input parameter. 141 | // If is 142 | static parseMailAddresses(input: string): Array { 143 | if (input == null) throw new Error("input is null!") 144 | return splitAtUnquoted(input, ",").map(RfcMailAddress.parseMailAddress) 145 | } 146 | } -------------------------------------------------------------------------------- /lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import type {PropertyTag} from "../property_tags.js" 2 | import {PropertyTagLiterals} from "../property_tags.js" 3 | import type {Property} from "../property.js" 4 | import ByteBuffer from "bytebuffer" 5 | import {Locale} from "./lcid.js" 6 | 7 | function Xp(n: number, p: number): string { 8 | return n.toString(16).padStart(p, "0").toUpperCase() 9 | } 10 | 11 | function xp(n: number, p: number): string { 12 | return n.toString(16).padStart(p, "0") 13 | } 14 | 15 | export function x2(n: number): string { 16 | return xp(n, 2) 17 | } 18 | 19 | /** 20 | * get an uppercase hex string of a number zero-padded to 2 digits 21 | * @param n {number} 22 | * @returns {string} 23 | */ 24 | export function X2(n: number): string { 25 | return Xp(n, 2) 26 | } 27 | 28 | /** 29 | * get an uppercase hex string of a number zero-padded to 4 digits 30 | * @param n {number} the number 31 | * @returns {string} 4-digit uppercase hex representation of the number 32 | */ 33 | export function X4(n: number): string { 34 | return Xp(n, 4) 35 | } 36 | 37 | /** 38 | * get an uppercase hex string of a number zero-padded to 8 digits 39 | * @param n {number} the number 40 | * @returns {string} 41 | */ 42 | export function X8(n: number): string { 43 | return Xp(n, 8) 44 | } 45 | 46 | export function name(tag: PropertyTag | Property): string { 47 | return PropertyTagLiterals.SubStorageStreamPrefix + X4(tag.id) + X4(tag.type) 48 | } 49 | 50 | export function shortName(tag: PropertyTag | Property): string { 51 | return X4(tag.id) 52 | } 53 | 54 | /** 55 | * convert UTF-8 Uint8Array to string 56 | * @param array {Uint8Array} 57 | * @returns {string} 58 | */ 59 | export function utf8ArrayToString(array: Uint8Array): string { 60 | return new TextDecoder().decode(array) 61 | } 62 | 63 | /** 64 | * convert string to UTF-8 Uint8Array 65 | * @param str {string} 66 | * @returns {Uint8Array} 67 | */ 68 | export function stringToUtf8Array(str: string): Uint8Array { 69 | return new TextEncoder().encode(str) 70 | } 71 | 72 | /** 73 | * convert string to UTF-16LE Uint8Array 74 | * @param str {string} 75 | * @returns {Uint8Array|Uint8Array} 76 | */ 77 | export function stringToUtf16LeArray(str: string): Uint8Array { 78 | const u16 = Uint16Array.from(str.split("").map(c => c.charCodeAt(0))) 79 | return new Uint8Array(u16.buffer, u16.byteOffset, u16.byteLength) 80 | } 81 | 82 | /** 83 | * convert UTF-16LE Uint8Array to string 84 | * @param u8 {Uint8Array} raw bytes 85 | * @returns {string} 86 | */ 87 | export function utf16LeArrayToString(u8: Uint8Array): string { 88 | const u16 = new Uint16Array(u8.buffer, u8.byteOffset, u8.byteLength) 89 | // mapping directly over u16 insists on converting the result to Uint16Array again. 90 | return Array.from(u16) 91 | .map(c => String.fromCharCode(c)) 92 | .join("") 93 | } 94 | 95 | /** 96 | * convert a string to a Uint8Array with terminating 0 byte 97 | * @throws if the string contains characters not in the ANSI range (0-255) 98 | * @param str 99 | */ 100 | export function stringToAnsiArray(str: string): Uint8Array { 101 | const codes = str.split("").map(c => c.charCodeAt(0)) 102 | if (codes.findIndex(c => c > 255) > -1) throw new Error("can't encode ansi string with char codes > 255!") 103 | codes.push(0) 104 | return Uint8Array.from(codes) 105 | } 106 | 107 | /** 108 | * decode a string from a Uint8Array with terminating 0, interpreting the values as 109 | * ANSI characters. 110 | * @throws if the array does not have a terminating 0 111 | * @param u8 {Uint8Array} 112 | * @returns {string} 113 | */ 114 | export function ansiArrayToString(u8: Uint8Array): string { 115 | if (u8.length === 0 || u8[u8.length - 1] !== 0) throw new Error("can't decode ansi array without terminating 0 byte!") 116 | return Array.from(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength - 1)) 117 | .map(c => String.fromCharCode(c)) 118 | .join("") 119 | } 120 | 121 | /** 122 | * convert a file name to its DOS 8.3 version. 123 | * @param fileName {string} a file name (not a path!) 124 | */ 125 | export function fileNameToDosFileName(fileName: string): string { 126 | const parts = fileName.split(".") 127 | let name, extension 128 | 129 | if (parts.length < 2) { 130 | name = parts[0] 131 | extension = null 132 | } else { 133 | name = parts.slice(0, -1).join("") 134 | extension = parts[parts.length - 1] 135 | } 136 | 137 | if (name !== "") { 138 | name = (name.length > 8 ? name.substring(0, 6) + "~1" : name).toUpperCase() 139 | } 140 | 141 | if (extension != null) { 142 | name += "." + (extension.length > 3 ? extension.substring(0, 3) : extension).toUpperCase() 143 | } 144 | 145 | return name 146 | } 147 | 148 | /** 149 | * turn a ByteBuffer into a Uint8Array, using the current offset as a limit. 150 | * buf.limit will change to buf.offset, and its buf.offset will be reset to 0. 151 | * @param buf {ByteBuffer} the buffer to convert 152 | * @returns {Uint8Array} a new Uint8Array containing the 153 | */ 154 | export function byteBufferAsUint8Array(buf: ByteBuffer): Uint8Array { 155 | buf.limit = buf.offset 156 | buf.offset = 0 157 | return new Uint8Array(buf.toBuffer(true)) 158 | } 159 | 160 | /** 161 | * make an new byte buffer with the correct settings 162 | * @param otherBuffer {ByteBuffer | ArrayBuffer | Uint8Array} other buffer to wrap into a ByteBuffer 163 | * @param initCap {number?} initial capacity. ignored if otherBuffer is given. 164 | */ 165 | export function makeByteBuffer(initCap?: number, otherBuffer?: ByteBuffer | ArrayBuffer | Uint8Array): ByteBuffer { 166 | if (initCap != null && initCap < 0) throw new Error("initCap must be non-negative!") 167 | return otherBuffer == null ? new ByteBuffer(initCap || 1, ByteBuffer.LITTLE_ENDIAN) : ByteBuffer.wrap(otherBuffer, undefined, ByteBuffer.LITTLE_ENDIAN) 168 | } 169 | 170 | export function getPathExtension(p: string): string { 171 | if (!p.includes(".")) return "" 172 | const parts = p.split(".") 173 | return "." + parts[parts.length - 1] 174 | } 175 | 176 | export function isNullOrEmpty(str: string | null | undefined): boolean { 177 | return !str || str === "" 178 | } 179 | 180 | export function isNullOrWhiteSpace(str: string | null | undefined): boolean { 181 | return str == null || str.trim() === "" 182 | } 183 | 184 | export function isNotNullOrWhitespace(str: string | null | undefined): str is string { 185 | return !isNullOrWhiteSpace(str) 186 | } 187 | 188 | export function splitAtUnquoted(input: string, sep: string): Array { 189 | if (sep.length !== 1) throw new Error("sep needs to be a char!") 190 | const elements: string[] = [] 191 | let lastSplitLocation = 0 192 | let insideQuote = false 193 | const characters = input.split("") 194 | 195 | for (let i = 0; i < characters.length; i++) { 196 | let character = characters[i] 197 | if (character === '"') insideQuote = !insideQuote 198 | // Only split if we are not inside quotes 199 | if (character !== sep || insideQuote) continue 200 | // We need to split 201 | const length = i - lastSplitLocation 202 | elements.push(input.substring(lastSplitLocation, lastSplitLocation + length)) 203 | // Update last split location 204 | // + 1 so that we do not include the character used to split with next time 205 | lastSplitLocation = i + 1 206 | } 207 | 208 | // Add the last part 209 | elements.push(input.substring(lastSplitLocation)) 210 | return elements 211 | } 212 | 213 | export function unquote(input: string): string { 214 | if (input == null) throw new Error("text needs to be a string") 215 | return input.length > 1 && input[0] === '"' && input[input.length - 1] === '"' ? input.substring(1, input.length - 1) : input 216 | } 217 | 218 | export function localeId(): Locale { 219 | return Locale[getLang()] 220 | } 221 | 222 | function getLang(): keyof typeof Locale { 223 | return "en_US" 224 | } 225 | 226 | /** 227 | * get the upper and lower 32 bits from a 64bit int in a bignum 228 | */ 229 | export function bigInt64ToParts( 230 | num: bigint, 231 | ): { 232 | lower: number 233 | upper: number 234 | } { 235 | const u64 = BigInt.asUintN(64, num) 236 | const lower = Number(u64 & (2n ** 32n - 1n)) 237 | const upper = Number((u64 / 2n ** 32n) & (2n ** 32n - 1n)) 238 | return { 239 | lower, 240 | upper, 241 | } 242 | } 243 | 244 | /** 245 | * create a 64bit int in a bignum from two 32bit ints in numbers 246 | * @param lower 247 | * @param upper 248 | */ 249 | export function bigInt64FromParts(lower: number, upper: number): bigint { 250 | return BigInt.asUintN(64, BigInt(lower) + BigInt(upper) * 2n ** 32n) 251 | } -------------------------------------------------------------------------------- /lib/mime/header/header_field_parser.ts: -------------------------------------------------------------------------------- 1 | import {ContentTransferEncoding, MailPriority} from "../../enums" 2 | import {isNullOrEmpty, unquote} from "../../utils/utils" 3 | import {decode as decodeRfc2047} from "./rfc2047" 4 | import {SizeParser} from "../decode/size_parser" 5 | import {Rfc2231Decoder} from "../decode/rfc2231decoder" 6 | 7 | export type ContentType = { 8 | mediaType: string 9 | boundary: string 10 | charset: string 11 | name: string 12 | parameters: Record 13 | } 14 | 15 | export type ContentDisposition = { 16 | dispositionType: string 17 | fileName: string 18 | creationDate: number 19 | modificationDate: number 20 | readDate: number 21 | size: number 22 | parameters: Record 23 | } 24 | 25 | export class HeaderFieldParser { 26 | /** 27 | * Parses the Content-Transfer-Encoding header. 28 | * @param value {string} the value for the header to be parsed 29 | * @returns {string} a ContentTransferEncoding 30 | */ 31 | static parseContentTransferEncoding(value: string): ContentTransferEncoding { 32 | if (value == null) throw new Error("value must not be null!") 33 | const normalized = value.trim().toLowerCase(); 34 | return normalized in ContentTransferEncoding ? ContentTransferEncoding[normalized as keyof typeof ContentTransferEncoding] : ContentTransferEncoding.SevenBit 35 | } 36 | 37 | /** 38 | * Parses an ImportanceType from a given Importance header value. 39 | * @param value {string} the value to be parsed 40 | * @returns {number} a mail priority, defaulting to normal if value is not recognized 41 | */ 42 | static parseImportance(value: string): MailPriority { 43 | if (value == null) throw new Error("value must not be null!") 44 | 45 | switch (value.toUpperCase()) { 46 | case "5": 47 | case "HIGH": 48 | return MailPriority.High 49 | 50 | case "3": 51 | case "NORMAL": 52 | return MailPriority.Normal 53 | 54 | case "1": 55 | case "LOW": 56 | return MailPriority.Low 57 | 58 | default: 59 | return MailPriority.Normal 60 | } 61 | } 62 | 63 | /** 64 | * parses the value for the content-type header into an object 65 | * @param value {string} the value to be parsed 66 | * @returns {ContentType} 67 | */ 68 | static parseContentType(value: string): ContentType { 69 | if (value == null) throw new Error("value must not be null!") 70 | // We create an empty Content-Type which we will fill in when we see the value 71 | const contentType: ContentType = { 72 | mediaType: "", 73 | boundary: "", 74 | charset: "", 75 | name: "", 76 | parameters: {}, 77 | } 78 | // Now decode the parameters 79 | const parameters = Rfc2231Decoder.decode(value) 80 | parameters.forEach(kvp => { 81 | let key = kvp.key.toUpperCase().trim() 82 | let value = unquote(kvp.value.trim()) 83 | 84 | switch (key) { 85 | case "": 86 | // This is the MediaType - it has no key since it is the first one mentioned in the 87 | // headerValue and has no = in it. 88 | // Check for illegal content-type 89 | // const v = value.ToUpperCase().trim('\0') 90 | const v = value 91 | .toUpperCase() 92 | .replace(/^[\x00]/, "") 93 | .replace(/[\x00]$/, "") 94 | contentType.mediaType = v === "TEXT" || v === "TEXT/" ? "text/plain" : value 95 | break 96 | 97 | case "BOUNDARY": 98 | contentType.boundary = value 99 | break 100 | 101 | case "CHARSET": 102 | contentType.charset = value 103 | break 104 | 105 | case "NAME": 106 | contentType.name = decodeRfc2047(value) 107 | break 108 | 109 | default: 110 | // We add the unknown value to our parameters list 111 | // "Known" unknown values are: 112 | // - title 113 | // - report-type 114 | contentType.parameters[key] = value 115 | break 116 | } 117 | }) 118 | return contentType 119 | } 120 | 121 | /** 122 | * Parses a the value for the header Content-Disposition to an object 123 | * @param value {string} the header value to decode 124 | * @returns {ContentDisposition} 125 | */ 126 | static parseContentDisposition(value: string): ContentDisposition { 127 | if (value == null) throw new Error("value must not be null!") 128 | // See http://www.ietf.org/rfc/rfc2183.txt for RFC definition 129 | // Create empty ContentDisposition - we will fill in details as we read them 130 | const contentDisposition: ContentDisposition = { 131 | dispositionType: "", 132 | fileName: "", 133 | creationDate: 0, 134 | modificationDate: 0, 135 | readDate: 0, 136 | size: 0, 137 | parameters: {}, 138 | } 139 | // Now decode the parameters 140 | const parameters = Rfc2231Decoder.decode(value) 141 | parameters.forEach(kvp => { 142 | let key = kvp.key.toUpperCase().trim() 143 | let value = unquote(kvp.value.trim()) 144 | 145 | switch (key) { 146 | case "": 147 | // This is the DispisitionType - it has no key since it is the first one 148 | // and has no = in it. 149 | contentDisposition.dispositionType = value 150 | break 151 | 152 | // The correct name of the parameter is filename, but some emails also contains the parameter 153 | // name, which also holds the name of the file. Therefore we use both names for the same field. 154 | case "NAME": 155 | case "FILENAME": 156 | // The filename might be in quotes, and it might be encoded-word encoded 157 | contentDisposition.fileName = decodeRfc2047(value) 158 | break 159 | 160 | case "CREATION-DATE": 161 | // Notice that we need to create a new DateTime because of a failure in .NET 2.0. 162 | // The failure is: you cannot give contentDisposition a DateTime with a Kind of UTC 163 | // It will set the CreationDate correctly, but when trying to read it out it will throw an exception. 164 | // It is the same with ModificationDate and ReadDate. 165 | // This is fixed in 4.0 - maybe in 3.0 too. 166 | // Therefore we create a new DateTime which have a DateTimeKind set to unspecified 167 | // new DateTime(Rfc2822DateTime.StringToDate(value).Ticks); 168 | contentDisposition.creationDate = Date.parse(value) 169 | break 170 | 171 | case "MODIFICATION-DATE": 172 | // var midificationDate = new DateTime(Rfc2822DateTime.StringToDate(value).Ticks); 173 | contentDisposition.modificationDate = Date.parse(value) 174 | break 175 | 176 | case "READ-DATE": 177 | // var readDate = new DateTime(Rfc2822DateTime.StringToDate(value).Ticks); 178 | contentDisposition.readDate = Date.parse(value) 179 | break 180 | 181 | case "SIZE": 182 | contentDisposition.size = SizeParser.parse(value) 183 | break 184 | 185 | case "CHARSET": // ignoring invalid parameter in Content-Disposition 186 | 187 | case "VOICE": 188 | break 189 | 190 | default: 191 | if (!key.startsWith("X-")) throw new Error("Unknown parameter in Content-Disposition. Ask developer to fix! Parameter: " + key) 192 | contentDisposition.parameters[key] = value 193 | break 194 | } 195 | }) 196 | return contentDisposition 197 | } 198 | 199 | /** 200 | * Parses an ID like Message-Id and Content-Id. 201 | * Example: 202 | * 203 | * into 204 | * test@test.com 205 | * 206 | * @param value {string} the id to parse 207 | * @returns {string} a parsed id 208 | */ 209 | static parseId(value: string): string { 210 | // Remove whitespace in front and behind since 211 | // whitespace is allowed there 212 | // Remove the last > and the first < 213 | return value.trim().replace(/[>]+$/, "").replace(/^[<]+/, "") 214 | } 215 | 216 | /** 217 | * parses multiple ids from a single string like In-Reply-To 218 | * @param value {string} the value to parse 219 | */ 220 | static parseMultipleIds(value: string): Array { 221 | // Split the string by > 222 | // We cannot use ' ' (space) here since this is a possible value: 223 | // 224 | return value 225 | .trim() 226 | .split(">") 227 | .filter(s => !isNullOrEmpty(s)) 228 | .map(HeaderFieldParser.parseId) 229 | } 230 | } -------------------------------------------------------------------------------- /lib/helpers/rtf_compressor.ts: -------------------------------------------------------------------------------- 1 | import ByteBuffer from "bytebuffer" 2 | import {byteBufferAsUint8Array, makeByteBuffer, stringToUtf8Array} from "../utils/utils.js" 3 | import {Crc32} from "./crc32.js" 4 | 5 | const INIT_DICT_SIZE: number = 207 6 | const MAX_DICT_SIZE: number = 4096 7 | const COMP_TYPE: string = "LZFu" 8 | const HEADER_SIZE: number = 16 9 | 10 | type MatchInfo = { 11 | dictionaryOffset: number 12 | length: number 13 | } 14 | 15 | function getInitialDict(): ByteBuffer { 16 | const builder: string[] = [] 17 | builder.push("{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}") 18 | builder.push("{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript ") 19 | builder.push("\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0") 20 | builder.push("\r\n") 21 | builder.push("\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx") 22 | const res = builder.join("") 23 | let initialDictionary = makeByteBuffer(undefined, stringToUtf8Array(res)) 24 | initialDictionary.ensureCapacity(MAX_DICT_SIZE) 25 | initialDictionary.limit = MAX_DICT_SIZE 26 | initialDictionary.offset = INIT_DICT_SIZE 27 | return initialDictionary 28 | } 29 | 30 | /** 31 | * find the longest match of the start of the current input in the dictionary. 32 | * finds the length of the longest match of the start of the current input in the dictionary and 33 | * the position of it in the dictionary. 34 | * @param dictionary {ByteBuffer} part of the MS-OXRTFCP spec. 35 | * @param inputBuffer {ByteBuffer} pointing at the input data 36 | * @returns {MatchInfo} object containing dictionaryOffset, length 37 | */ 38 | function findLongestMatch(dictionary: ByteBuffer, inputBuffer: ByteBuffer): MatchInfo { 39 | const positionData: MatchInfo = { 40 | length: 0, 41 | dictionaryOffset: 0, 42 | } 43 | if (inputBuffer.offset >= inputBuffer.limit) return positionData 44 | inputBuffer.mark() 45 | dictionary.mark() // previousWriteOffset 46 | 47 | let matchLength = 0 48 | let dictionaryIndex = 0 49 | 50 | while (true) { 51 | const inputCharacter = inputBuffer.readUint8() 52 | const dictCharacter = dictionary.readUint8(dictionaryIndex % MAX_DICT_SIZE) 53 | 54 | if (dictCharacter === inputCharacter) { 55 | matchLength += 1 56 | 57 | if (matchLength <= 17 && matchLength > positionData.length) { 58 | positionData.dictionaryOffset = dictionaryIndex - matchLength + 1 59 | dictionary.writeUint8(inputCharacter) 60 | dictionary.offset = dictionary.offset % MAX_DICT_SIZE 61 | positionData.length = matchLength 62 | } 63 | 64 | if (inputBuffer.offset >= inputBuffer.limit) break 65 | } else { 66 | inputBuffer.reset() 67 | inputBuffer.mark() 68 | matchLength = 0 69 | if (inputBuffer.offset >= inputBuffer.limit) break 70 | } 71 | 72 | dictionaryIndex += 1 73 | if (dictionaryIndex >= dictionary.markedOffset + positionData.length) break 74 | } 75 | 76 | inputBuffer.reset() 77 | return positionData 78 | } 79 | 80 | /** 81 | * Takes in input, compresses it using LZFu by Microsoft. Can be viewed in the [MS-OXRTFCP].pdf document. 82 | * https://msdn.microsoft.com/en-us/library/cc463890(v=exchg.80).aspx. Returns the input as a byte array. 83 | * @param input {Uint8Array} the input to compress 84 | * @returns {Uint8Array} compressed input 85 | */ 86 | export function compress(input: Uint8Array): Uint8Array { 87 | let matchData: MatchInfo = { 88 | length: 0, 89 | dictionaryOffset: 0, 90 | } 91 | const inputBuffer = makeByteBuffer(undefined, input) 92 | const dictionary = getInitialDict() 93 | const tokenBuffer = makeByteBuffer(16) 94 | const resultBuffer = makeByteBuffer(17) 95 | // The writer MUST set the input cursor to the first byte in the input buffer. 96 | // The writer MUST set the output cursor to the 17th byte (to make space for the compressed header). 97 | resultBuffer.offset = HEADER_SIZE 98 | // (1) The writer MUST (re)initialize the run by setting its 99 | // Control Byte to 0 (zero), its control bit to 0x01, and its token offset to 0 (zero). 100 | let controlByte = 0 101 | let controlBit = 0x01 102 | 103 | while (true) { 104 | // (3) Locate the longest match in the dictionary for the current input cursor, 105 | // as specified in section 3.3.4.2.1. Note that the dictionary is updated during this procedure. 106 | matchData = findLongestMatch(dictionary, inputBuffer) 107 | 108 | if (inputBuffer.offset >= inputBuffer.limit) { 109 | // (2) If there is no more input, the writer MUST exit the compression loop (by advancing to step 8). 110 | // (8) A dictionary reference (see section 2.2.1.5) MUST be created from an offset equal 111 | // to the current write offset of the dictionary and a length of 0 (zero), and inserted 112 | // in the token buffer as a big-endian word at the current token offset. The writer MUST 113 | // then advance the token offset by 2. The control bit MUST be ORed into the Control Byte, 114 | // thus setting the bit that corresponds to the current token to 1. 115 | let dictReference = (dictionary.offset & 0xfff) << 4 116 | tokenBuffer.writeUint8((dictReference >>> 8) & 0xff) 117 | tokenBuffer.writeUint8((dictReference >>> 0) & 0xff) 118 | controlByte |= controlBit 119 | // (9) The writer MUST write the current run to the output by writing the BYTE Control Byte, 120 | // and then copying token offset number of BYTEs from the token buffer to the output. 121 | // The output cursor is advanced by token offset + 1 BYTE. 122 | resultBuffer.writeUint8(controlByte) 123 | tokenBuffer.limit = tokenBuffer.offset 124 | tokenBuffer.offset = 0 125 | resultBuffer.append(tokenBuffer) 126 | break 127 | } 128 | 129 | if (matchData.length <= 1) { 130 | // (4) If the match is 0 (zero) or 1 byte in length, the writer 131 | // MUST copy the literal at the input cursor to the Run's token 132 | // buffer at token offset. The writer MUST increment the token offset and the input cursor. 133 | const inputCharacter = inputBuffer.readUint8() 134 | 135 | if (matchData.length === 0) { 136 | dictionary.writeUint8(inputCharacter) 137 | dictionary.offset = dictionary.offset % dictionary.limit 138 | } 139 | 140 | tokenBuffer.writeUint8(inputCharacter) 141 | } else { 142 | // (5) If the match is 2 bytes or longer, the writer MUST create a dictionary 143 | // reference (see section 2.2.1.5) from the offset of the match and the length. 144 | // (Note: The value stored in the Length field in REFERENCE is length minus 2.) 145 | // The writer MUST insert this dictionary reference in the token buffer as a 146 | // big-endian word at the current token offset. The control bit MUST be bitwise 147 | // ORed into the Control Byte, thus setting the bit that corresponds to the 148 | // current token to 1. The writer MUST advance the token offset by 2 and 149 | // MUST advance the input cursor by the length of the match. 150 | let dictReference = ((matchData.dictionaryOffset & 0xfff) << 4) | ((matchData.length - 2) & 0xf) 151 | controlByte |= controlBit 152 | tokenBuffer.writeUint8((dictReference >>> 8) & 0xff) 153 | tokenBuffer.writeUint8((dictReference >>> 0) & 0xff) 154 | inputBuffer.offset = inputBuffer.offset + matchData.length 155 | } 156 | 157 | matchData.length = 0 158 | 159 | if (controlBit === 0x80) { 160 | // (7) If the control bit is equal to 0x80, the writer MUST write the run 161 | // to the output by writing the BYTE Control Byte, and then copying the 162 | // token offset number of BYTEs from the token buffer to the output. The 163 | // writer MUST advance the output cursor by token offset + 1 BYTEs. 164 | // Continue with compression by returning to step (1). 165 | resultBuffer.writeUint8(controlByte) 166 | tokenBuffer.limit = tokenBuffer.offset 167 | tokenBuffer.offset = 0 168 | resultBuffer.append(tokenBuffer) 169 | controlByte = 0 170 | controlBit = 0x01 171 | tokenBuffer.clear() 172 | continue 173 | } 174 | 175 | // (6) If the control bit is not 0x80, the control bit MUST be left-shifted by one bit and compression MUST 176 | // continue building the run by returning to step (2). 177 | controlBit <<= 1 178 | } 179 | 180 | // After the output has been completed by execution of step (9), the writer 181 | // MUST complete the output by filling the header, as specified in section 3.3.4.2.2. 182 | // The writer MUST fill in the header by using the following process: 183 | // 1.Set the COMPSIZE (see section 2.2.1.1) field of the header to the number of CONTENTS bytes in the output buffer plus 12. 184 | resultBuffer.limit = resultBuffer.offset 185 | resultBuffer.writeUint32(resultBuffer.limit - HEADER_SIZE + 12, 0) 186 | // 2.Set the RAWSIZE (see section 2.2.1.1) field of the header to the number of bytes read from the input. 187 | resultBuffer.writeUint32(input.length, 4) 188 | // 3.Set the COMPTYPE (see section 2.2.1.1) field of the header to COMPRESSED. 189 | resultBuffer.writeUTF8String(COMP_TYPE, 8) 190 | // 4.Set the CRC (see section 3.1.3.2) field of the header to the CRC (see section 3.1.1.1.2) generated from the CONTENTS bytes. 191 | resultBuffer.offset = HEADER_SIZE 192 | resultBuffer.writeUint32(Crc32.calculate(resultBuffer), 12) 193 | resultBuffer.offset = resultBuffer.limit 194 | return byteBufferAsUint8Array(resultBuffer) 195 | } -------------------------------------------------------------------------------- /lib/mime/header/rfc2047.ts: -------------------------------------------------------------------------------- 1 | // converted to ES6 from https://www.npmjs.com/package/rfc2047 2 | import * as iconvLite from "iconv-lite" 3 | 4 | // Initialize array used as lookup table (int (octet) => string) 5 | const qpTokenByOctet = new Array(256) 6 | 7 | for (let i = 0; i < 256; i += 1) { 8 | qpTokenByOctet[i] = `=${i < 16 ? "0" : ""}${i.toString(16).toUpperCase()}` 9 | } 10 | 11 | "!#$%&'*+-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\\^`abcdefghijklmnopqrstuvwxyz{|}~" 12 | .split(/(?:)/) 13 | .forEach(safeAsciiChar => (qpTokenByOctet[safeAsciiChar.charCodeAt(0)] = safeAsciiChar)) 14 | qpTokenByOctet[32] = "_" 15 | // Build a regexp for determining whether (part of) a token has to be encoded: 16 | const headerSafeAsciiChars = " !\"#$%&'()*+-,-./0123456789:;<=>@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" 17 | let headerUnsafeAsciiChars = "" 18 | 19 | for (let i = 0; i < 128; i += 1) { 20 | const ch = String.fromCharCode(i) 21 | 22 | if (headerSafeAsciiChars.indexOf(ch) === -1) { 23 | // O(n^2) but only happens at startup 24 | headerUnsafeAsciiChars += ch 25 | } 26 | } 27 | 28 | const isUtf8RegExp = /^utf-?8$/i 29 | const isLatin1RegExp = /^(?:iso-8859-1|latin1)$/i 30 | const encodedWordRegExp = /=\?([^?]+)\?([QB])\?([^?]*)\?=/gi 31 | const unsafeTokenRegExp = new RegExp(`[\u0080-\uffff${quoteCharacterClass(headerUnsafeAsciiChars)}]`) 32 | // Very conservative limit to prevent creating an encoded word of more than 72 ascii chars 33 | const maxNumCharsPerEncodedWord = 8 34 | 35 | function stringify(obj: any): string { 36 | if (typeof obj === "string") { 37 | return obj 38 | } else if (obj == null) { 39 | return "" 40 | } else { 41 | return String(obj) 42 | } 43 | } 44 | 45 | const replacementCharacterBuffer = Buffer.from("�") 46 | 47 | function decodeBuffer(encodedText: string, encoding: string): Buffer { 48 | if (encoding === "q") { 49 | encodedText = encodedText.replace(/_/g, " ") 50 | let numValidlyEncodedBytes = 0 51 | let i 52 | 53 | for (i = 0; i < encodedText.length; i += 1) { 54 | if (encodedText[i] === "=" && /^[0-9a-f]{2}$/i.test(encodedText.slice(i + 1, i + 3))) { 55 | numValidlyEncodedBytes += 1 56 | } 57 | } 58 | 59 | const buffer = Buffer.alloc(encodedText.length - numValidlyEncodedBytes * 2) 60 | let j = 0 61 | 62 | for (i = 0; i < encodedText.length; i += 1) { 63 | if (encodedText[i] === "=") { 64 | const hexChars = encodedText.slice(i + 1, i + 3) 65 | 66 | if (/^[0-9a-f]{2}$/i.test(hexChars)) { 67 | buffer[j] = parseInt(encodedText.substr(i + 1, 2), 16) 68 | i += 2 69 | } else { 70 | buffer[j] = encodedText.charCodeAt(i) 71 | } 72 | } else { 73 | buffer[j] = encodedText.charCodeAt(i) 74 | } 75 | 76 | j += 1 77 | } 78 | 79 | return buffer 80 | } else { 81 | return Buffer.from(encodedText, "base64") 82 | } 83 | } 84 | 85 | // Returns either a string (if successful) or undefined 86 | function decodeEncodedWord(encodedText: string, encoding: string, charset: string): string | null { 87 | if (encoding === "q" && isLatin1RegExp.test(charset)) { 88 | return unescape( 89 | encodedText 90 | .replace(/_/g, " ") 91 | .replace(/%/g, "%25") 92 | .replace(/=(?=[0-9a-f]{2})/gi, "%"), 93 | ) 94 | } else { 95 | let buffer 96 | 97 | try { 98 | buffer = decodeBuffer(encodedText, encoding) 99 | } catch (e) { 100 | return null 101 | } 102 | 103 | if (/^ks_c_5601/i.test(charset)) { 104 | charset = "CP949" 105 | } 106 | 107 | if (isUtf8RegExp.test(charset)) { 108 | const decoded = buffer.toString("utf-8") 109 | 110 | if (!/\ufffd/.test(decoded) || buffer.includes(replacementCharacterBuffer)) { 111 | return decoded 112 | } else { 113 | return null 114 | } 115 | } else if (/^(?:us-)?ascii$/i.test(charset)) { 116 | return buffer.toString("ascii") 117 | } else if (iconvLite.encodingExists(charset)) { 118 | const decoded = iconvLite.decode(buffer, charset) 119 | 120 | if (!/\ufffd/.test(decoded) || buffer.includes(replacementCharacterBuffer)) { 121 | return decoded 122 | } else { 123 | return null 124 | } 125 | } else { 126 | return null 127 | } 128 | } 129 | } 130 | 131 | export function decode(text: string): string { 132 | text = stringify(text).replace(/\?=\s+=\?/g, "?==?") // Strip whitespace between neighbouring encoded words 133 | 134 | let numEncodedWordsToIgnore = 0 135 | return text.replace(encodedWordRegExp, function (encodedWord, charset, encoding, encodedText, index) { 136 | if (numEncodedWordsToIgnore > 0) { 137 | numEncodedWordsToIgnore -= 1 138 | return "" 139 | } 140 | 141 | encoding = encoding.toLowerCase() 142 | let decodedTextOrBuffer = decodeEncodedWord(encodedText, encoding, charset) 143 | 144 | while (decodedTextOrBuffer == null) { 145 | // The encoded word couldn't be decoded because it contained a partial character in a multibyte charset. 146 | // Keep trying to look ahead and consume an additional encoded word right after this one, and if its 147 | // encoding and charsets match, try to decode the concatenation. 148 | // The ongoing replace call is unaffected by this trick, so we don't need to reset .lastIndex afterwards: 149 | encodedWordRegExp.lastIndex = index + encodedWord.length 150 | const matchNextEncodedWord = encodedWordRegExp.exec(text) 151 | 152 | if ( 153 | matchNextEncodedWord && 154 | matchNextEncodedWord.index === index + encodedWord.length && 155 | matchNextEncodedWord[1] === charset && 156 | matchNextEncodedWord[2].toLowerCase() === encoding 157 | ) { 158 | numEncodedWordsToIgnore += 1 159 | encodedWord += matchNextEncodedWord[0] 160 | encodedText += matchNextEncodedWord[3] 161 | decodedTextOrBuffer = decodeEncodedWord(encodedText, encoding, charset) 162 | } else { 163 | return encodedWord 164 | } 165 | } 166 | 167 | return decodedTextOrBuffer 168 | }) 169 | } 170 | 171 | // Fast encoder for quoted-printable data in the "encoded-text" part of encoded words. 172 | // This scenario differs from regular quoted-printable (as used in e.g. email bodies) 173 | // in that the space character is represented by underscore, and fewer ASCII are 174 | // allowed (see rfc 2047, section 2). 175 | function bufferToQuotedPrintableString(buffer: Buffer): string { 176 | let result = "" 177 | 178 | for (let i = 0; i < buffer.length; i += 1) { 179 | result += qpTokenByOctet[buffer[i]] 180 | } 181 | 182 | return result 183 | } 184 | 185 | function quoteCharacterClass(chars: string): string { 186 | return chars.replace(/[\\|^*+?[\]().-]/g, "\\$&") 187 | } 188 | 189 | export function encode(text: string): string { 190 | text = stringify(text).replace(/\s/g, " ") // Normalize whitespace 191 | 192 | const tokens = text.match(/([^\s]*\s*)/g) // Split at space, but keep trailing space as part of each token 193 | 194 | let previousTokenWasEncodedWord = false // Consecutive encoded words must have a space between them, so this state must be kept 195 | 196 | let previousTokenWasWhitespaceFollowingEncodedWord = false 197 | let result = "" 198 | 199 | if (tokens) { 200 | for (let i = 0; i < tokens.length; i += 1) { 201 | let token = tokens[i] 202 | 203 | if (unsafeTokenRegExp.test(token)) { 204 | const matchQuotesAtBeginning = token.match(/^"+/) 205 | 206 | if (matchQuotesAtBeginning) { 207 | previousTokenWasEncodedWord = false 208 | result += matchQuotesAtBeginning[0] 209 | tokens[i] = token = token.substr(matchQuotesAtBeginning[0].length) 210 | tokens.splice(i, 0, matchQuotesAtBeginning[0]) 211 | i += 1 212 | } 213 | 214 | const matchWhitespaceOrQuotesAtEnd = token.match(/\\?[\s"]+$/) 215 | 216 | if (matchWhitespaceOrQuotesAtEnd) { 217 | tokens.splice(i + 1, 0, matchWhitespaceOrQuotesAtEnd[0]) 218 | token = token.substr(0, token.length - matchWhitespaceOrQuotesAtEnd[0].length) 219 | } 220 | 221 | // Word contains at least one header unsafe char, an encoded word must be created. 222 | if (token.length > maxNumCharsPerEncodedWord) { 223 | tokens.splice(i + 1, 0, token.substr(maxNumCharsPerEncodedWord)) 224 | token = token.substr(0, maxNumCharsPerEncodedWord) 225 | } 226 | 227 | if (previousTokenWasWhitespaceFollowingEncodedWord) { 228 | token = ` ${token}` 229 | } 230 | 231 | const charset = "utf-8" 232 | // Around 25% faster than encodeURIComponent(token.replace(/ /g, "_")).replace(/%/g, "="): 233 | const encodedWordBody = bufferToQuotedPrintableString(Buffer.from(token, "utf-8")) 234 | 235 | if (previousTokenWasEncodedWord) { 236 | result += " " 237 | } 238 | 239 | result += `=?${charset}?Q?${encodedWordBody}?=` 240 | previousTokenWasWhitespaceFollowingEncodedWord = false 241 | previousTokenWasEncodedWord = true 242 | } else { 243 | // Word only contains header safe chars, no need to encode: 244 | result += token 245 | previousTokenWasWhitespaceFollowingEncodedWord = /^\s*$/.test(token) && previousTokenWasEncodedWord 246 | previousTokenWasEncodedWord = false 247 | } 248 | } 249 | } 250 | 251 | return result 252 | } -------------------------------------------------------------------------------- /lib/mime/header/rfc2047.spec.ts: -------------------------------------------------------------------------------- 1 | import * as rfc2047 from "./rfc2047" 2 | import o from "ospec" 3 | 4 | const expectEncoding = (input, output) => o(rfc2047.encode(input)).equals(output) 5 | 6 | const expectDecoding = (input, output) => o(rfc2047.decode(input)).equals(output) 7 | 8 | const expectRoundtrip = (input, output) => { 9 | expectEncoding(input, output) 10 | expectDecoding(output, input) 11 | } 12 | 13 | o.spec("rfc2047", function () { 14 | o.spec("#encode() and #decode()", function () { 15 | o("should handle the empty string", function () { 16 | expectRoundtrip("", "") 17 | }) 18 | o("should handle a string only containing a space", function () { 19 | expectRoundtrip(" ", " ") 20 | }) 21 | o("should not encode an equals sign", function () { 22 | expectRoundtrip("=", "=") 23 | }) 24 | o("should handle a string that does not need to be encoded", function () { 25 | expectRoundtrip("Andreas Lind ", "Andreas Lind ") 26 | }) 27 | o("should handle a multi-word string where the middle word has to be encoded", function () { 28 | expectRoundtrip("Andreas Lindø ", "Andreas =?utf-8?Q?Lind=C3=B8?= ") 29 | }) 30 | o("should use an UTF-8 encoded word when a character is not in iso-8859-1", function () { 31 | expectRoundtrip("Mr. Smiley face aka ☺ ", "Mr. Smiley face aka =?utf-8?Q?=E2=98=BA?= ") 32 | }) 33 | o("should handle two neighbouring words that have to be encoded", function () { 34 | expectRoundtrip("¡Hola, señor!", "=?utf-8?Q?=C2=A1Hola=2C?= =?utf-8?Q?_se=C3=B1or!?=") 35 | expectRoundtrip("På lördag", "=?utf-8?Q?P=C3=A5?= =?utf-8?Q?_l=C3=B6rdag?=") 36 | }) 37 | o("should not rely on the space between neighbouring encoded words to be preserved", function () { 38 | expectRoundtrip("☺ ☺", "=?utf-8?Q?=E2=98=BA?= =?utf-8?Q?_=E2=98=BA?=") 39 | }) 40 | o("should handle some dreamed up edge cases", function () { 41 | expectRoundtrip("lördag", "=?utf-8?Q?l=C3=B6rdag?=") 42 | }) 43 | o("should handle a multi-word string where the middle word has to be left unencoded", function () { 44 | expectRoundtrip("Så er fødselen i gang", "=?utf-8?Q?S=C3=A5?= er =?utf-8?Q?f=C3=B8dselen?= i gang") 45 | }) 46 | o("should place leading quotes correctly", function () { 47 | expectRoundtrip('"ÅÄÖ" ', '"=?utf-8?Q?=C3=85=C3=84=C3=96?=" ') 48 | }) 49 | o("should place trailing quotes correctly", function () { 50 | expectRoundtrip('"TEST ÅÄÖ" ', '"TEST =?utf-8?Q?=C3=85=C3=84=C3=96?=" ') 51 | }) 52 | // Regression test for #2: 53 | o("should handle an emoji test case", function () { 54 | expectRoundtrip('{"tags":"","fullName":"😬"}', '=?utf-8?Q?{=22tags=22=3A?=""=?utf-8?Q?=2C=22fullNa?= =?utf-8?Q?me=22=3A=22=F0=9F=98=AC=22?=}') 55 | }) 56 | o("should handle the replacement character", function () { 57 | expectRoundtrip("test_�.docx", "=?utf-8?Q?test=5F=EF=BF=BD=2Ed?=ocx") 58 | }) 59 | }) 60 | o.spec("#encode()", function () { 61 | o("should handle non-string values correctly", function () { 62 | expectEncoding(-1, "-1") 63 | expectEncoding(Infinity, "Infinity") 64 | expectEncoding(false, "false") 65 | expectEncoding(true, "true") 66 | expectEncoding(/bla/, "/bla/") 67 | expectEncoding(undefined, "") 68 | expectEncoding(null, "") 69 | }) 70 | o("should handle a tab character at the beginning of a word", function () { 71 | expectEncoding("\tfoo", " foo") 72 | }) 73 | o("should handle control chars", function () { 74 | expectEncoding( 75 | "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f", 76 | "=?utf-8?Q?=00=01=02=03=04=05=06=07?= =?utf-8?Q?=08?= =?utf-8?Q?_=0E=0F=10=11=12=13=14=15?= =?utf-8?Q?=16=17=18=19=1A=1B=1C=1D?= =?utf-8?Q?=1E=1F?=", 77 | ) 78 | }) 79 | o("should handle a tab character at the end of a word", function () { 80 | expectEncoding("foo\t", "foo ") 81 | }) 82 | o("should handle a tab character with spaces around it", function () { 83 | expectEncoding("bar \t foo", "bar foo") 84 | }) 85 | o("should not split a backslash from the doublequote it is escaping", function () { 86 | expectEncoding('"Öland\\""', '"=?utf-8?Q?=C3=96land?=\\""') 87 | }) 88 | }) 89 | o.spec("#decode()", function () { 90 | o("should handle non-string values correctly", function () { 91 | expectDecoding(-1, "-1") 92 | expectDecoding(Infinity, "Infinity") 93 | expectDecoding(false, "false") 94 | expectDecoding(true, "true") 95 | expectDecoding(/bla/, "/bla/") 96 | expectDecoding(undefined, "") 97 | expectDecoding(null, "") 98 | }) 99 | o("should decode encoded word with invalid quoted-printable, decodeURIComponent case", function () { 100 | expectDecoding("=?UTF-8?Q?=xxfoo?=", "=xxfoo") 101 | }) 102 | o("should decode encoded word with invalid quoted-printable, unescape case", function () { 103 | expectDecoding("=?iso-8859-1?Q?=xxfoo?=", "=xxfoo") 104 | }) 105 | o("should decode encoded word with invalid base64", function () { 106 | expectDecoding("=?iso-8859-1?B?\u0000``?=", "") 107 | }) 108 | o("should decode separated encoded words", function () { 109 | expectDecoding( 110 | "=?utf-8?Q?One.com=E2=80?= =?utf-8?Q?=99s_=E2=80=9CDon=E2=80=99t_screw_it_up=E2=80=9D_?= =?utf-8?Q?code?=", 111 | "One.com’s “Don’t screw it up” code", 112 | ) 113 | }) 114 | o("should handle the test cases listed in RFC 2047", function () { 115 | expectDecoding("=?ISO-8859-1?Q?Olle_J=E4rnefors?= ", "Olle Järnefors ") 116 | expectDecoding("=?ISO-8859-1?Q?Patrik_F=E4ltstr=F6m?= ", "Patrik Fältström ") 117 | expectDecoding( 118 | "Nathaniel Borenstein (=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?=)", 119 | "Nathaniel Borenstein (םולש ןב ילטפנ)", 120 | ) 121 | expectDecoding("(=?ISO-8859-1?Q?a?=)", "(a)") 122 | expectDecoding("(=?ISO-8859-1?Q?a?= b)", "(a b)") 123 | expectDecoding("(=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=)", "(ab)") 124 | expectDecoding("(=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=)", "(ab)") 125 | expectDecoding("(=?ISO-8859-1?Q?a_b?=)", "(a b)") 126 | expectDecoding("(=?ISO-8859-1?Q?a?= =?ISO-8859-2?Q?_b?=)", "(a b)") 127 | }) 128 | o("should handle subject found in mail with X-Mailer: MailChimp Mailer", function () { 129 | expectDecoding( 130 | "=?utf-8?Q?Spar=2020=20%=20p=C3=A5=20de=20bedste=20businessb=C3=B8ger=20fra=20Gyldendal=21?=", 131 | "Spar 20 % på de bedste businessbøger fra Gyldendal!", 132 | ) 133 | expectDecoding("=?iso-8859-1?Q?Spar 20 %...?=", "Spar 20 %...") 134 | }) 135 | o("should handle multiple base64 encoded words issued by Thunderbird", function () { 136 | expectDecoding( 137 | "=?UTF-8?B?Rm9vw6YsIEZvbyDDpiwgw6bDuMOmw7jDpsO4w6bDuMOmw7jDpsO4LCA=?==?UTF-8?B?4pi6IE1y4pi6IOKYuuKYuuKYuuKYuuKYuuKYuuKYuuKYuuKYuuKYuuKYuuKYuuKYug==?= =?UTF-8?B?4pi64pi64pi64pi64pi64pi64pi6?=", 138 | "Fooæ, Foo æ, æøæøæøæøæøæø, ☺ Mr☺ ☺☺☺☺☺☺☺☺☺☺☺☺☺☺☺☺☺☺☺☺", 139 | ) 140 | }) 141 | o("should handle two back-to-back UTF-8 encoded words from the subject in a raygun mail", function () { 142 | expectDecoding( 143 | "=?utf-8?B?d2VibWFpbCBwcm9kdWN0aW9uIC0gbmV3IGVycm9yIC0gR2XD?==?utf-8?B?p2Vyc2l6IGRlxJ9pxZ9rZW4u?=", 144 | "webmail production - new error - Geçersiz değişken.", 145 | ) 146 | }) 147 | o("should keep encoded words with partial sequences separate if there is text between them", function () { 148 | expectDecoding( 149 | "=?utf-8?B?d2VibWFpbCBwcm9kdWN0aW9uIC0gbmV3IGVycm9yIC0gR2XD?=foo=?utf-8?B?p2Vyc2l6IGRlxJ9pxZ9rZW4u?=", 150 | "=?utf-8?B?d2VibWFpbCBwcm9kdWN0aW9uIC0gbmV3IGVycm9yIC0gR2XD?=foo=?utf-8?B?p2Vyc2l6IGRlxJ9pxZ9rZW4u?=", 151 | ) 152 | }) 153 | o("should decode a UTF-8 smiley (illegally) split up into 2 encoded words", function () { 154 | expectDecoding("=?utf-8?Q?=E2=98?= =?utf-8?Q?=BA?=", "☺") 155 | }) 156 | o("should decode a UTF-8 smiley (illegally) split up into 3 encoded words", function () { 157 | expectDecoding("=?utf-8?Q?=E2?= =?utf-8?Q?=98?= =?utf-8?Q?=BA?=", "☺") 158 | }) 159 | o("should give up decoding a UTF-8 smiley (illegally) split up into 3 encoded words if there is regular text between the encoded words", function () { 160 | expectDecoding("=?utf-8?Q?=E2?= =?utf-8?Q?=98?=a=?utf-8?Q?=BA?==?utf-8?Q?=BA?=a", "=?utf-8?Q?=E2?==?utf-8?Q?=98?=a=?utf-8?Q?=BA?==?utf-8?Q?=BA?=a") 161 | }) 162 | o("should decode an encoded word following a undecodable sequence of encoded words", function () { 163 | expectDecoding("=?utf-8?Q?=E2?= =?utf-8?Q?=98?= =?iso-8859-1?Q?=A1?=Hola, se=?iso-8859-1?Q?=F1?=or!", "=?utf-8?Q?=E2?==?utf-8?Q?=98?=¡Hola, señor!") 164 | }) 165 | o("should handle test cases from the MIME tools package", function () { 166 | // From http://search.cpan.org/~dskoll/MIME-tools-5.502/lib/MIME/Words.pm: 167 | expectDecoding("=?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?= ", "Keld Jørn Simonsen ") 168 | expectDecoding("=?US-ASCII?Q?Keith_Moore?= ", "Keith Moore ") 169 | expectDecoding("=?ISO-8859-1?Q?Andr=E9_?= Pirard ", "André Pirard ") 170 | expectDecoding("=?iso-8859-1?Q?J=F8rgen_Nellemose?=", "Jørgen Nellemose") 171 | expectDecoding( 172 | "=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?==?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?==?US-ASCII?Q?.._cool!?=", 173 | "If you can read this you understand the example... cool!", 174 | ) 175 | }) 176 | o("should handle a file name found in a Korean mail", function () { 177 | expectDecoding("=?ks_c_5601-1987?B?MTMwMTE3X8HWwvfA5V+1tcDlX7jetLq+8y5wZGY=?=", "130117_주차장_도장_메뉴얼.pdf") 178 | }) 179 | o("should handle bogus encoded words (spotted in the wild)", function () { 180 | expectDecoding("=?utf-8?Q??= ", " ") 181 | }) 182 | }) 183 | }) -------------------------------------------------------------------------------- /lib/helpers/crc32.spec.ts: -------------------------------------------------------------------------------- 1 | import o from "ospec" 2 | import {Crc32} from "./crc32" 3 | import ByteBuffer from "bytebuffer" 4 | import {makeByteBuffer} from "../utils/utils" 5 | // test vectors generated with MsgKit 6 | const TEST_VECTORS = { 7 | "6bd6d63e9bfaad34dd7834855964ef6d8b88cf678a028aa2ba0fa041b740221d41de7cd6c0ce83f6388cecabad2d3927": 1123334631, 8 | d76608bc4cd0694c33838c596b7d9d5517447be818adbc04d92ffd15def58dd579b9dfda1960f40e128a8c4af79ccc0f4f5695e8be3f6f2aa903: 1944757927, 9 | "1dd2536d3df5b6e0b19dc23bb6506cd58e20720d3c62edc68860a416394ab531167dd73bbf1c4e5441bc1a72": 3652142290, 10 | "1ade98526ae10937f6a6bae48de5dd519fa767e791253979301571d11dcc80477b396e810cc4cf69acf91fd61c5732377003a115b38bfbf4a4": 840955160, 11 | "21547037192a26726953775c44dbd8fd3b9706635cd9e33961192a6a94ef3e79dceda27af974e0f5afa9": 2197976691, 12 | debf14bfc63db55063ee40c2477136d6ffffbcde7915fd7f57385d04c6b711b4b3ec04794c5a642ddf9fdb8b9b2a: 202001547, 13 | "09c39ee0f5f3ca73c9c294678ddfb189acdc8c0e5b6cbc89a49a8effda3a72e32cdd544128d61bc1e8783bb7b8f2d64e2d162eae0f1cff9c06143b5a64cb99": 1462544538, 14 | "4fb03bb08b6f61d5c1ca25e33104d0b2c340d1c40bc3d410deb73b139ac05c1ddfab6708b726c3beb459ed45f61c6b0143": 1861315611, 15 | "9724fad5848cdc2bd2d3344d3a016d0804859dc8fb9d7d130fb1d68f19c24b39d0102938230927db2c8b54ef89671eebd7ee17e664": 239365028, 16 | "8ef9a7e7c6d2b54c111087fb7c29d735e5fa5ec19c6f488d89a793eab82baa65d555822f903c435426df7a44678eeaf414a1091d87a3dd": 2696684269, 17 | b0195d1e3fca94e666212527a7a8a5a8b60a9abdf50425fbbc9ed617228cde3277637e77de2514: 484153903, 18 | "4a93dd4568c34c6cfc60667d082029acf36161a0f47dc4d943f3af44292ec991f53e736127df93f8516a1ac22560295739d2837dc37367cccf": 2057370487, 19 | "0174140842ce96d587c9adc88513ce694f896202b3e3ca570b57aa4cce81d7a69122de1e": 1626561873, 20 | "49284c2b50d860aa704d6faf5a24dd6acd501eaabcfdea23490606f106366235af85": 2972582388, 21 | "6115d6630a69ad9a0a3b9d745df93cc4d2bf74274c3d29e249d26ea33aea39ffd51e7b54f7ed13400f62e0e7ac92a8f8ca728f763a5d": 125344596, 22 | "24ebf41938fb0a601f029d9f3e7de8cce5ab3318e95c4235b72976d8c85bc2c57765576803d3db450402c1": 1950754462, 23 | "0ff36a7ae2a5a9ca0645cfc7a9be610e853198c33fd827d9267fc911d0ee13e79b5e26c3bffbf622b1f8be319590be": 4211371506, 24 | "72ccc0d36afda436cb915462db98f91831e82b4b979d4f893b75124719f54395c0859f042e407d9eb9eff82ea7934719fea5ffad64": 4278701414, 25 | acd47d3697f5ea5c1dd54ebea156f8f5e9f16b8d91a8a521e1a72df94973f595314dd8cb87f7a888b35cd222dfe9f1f02008bb0e42d7e2: 1597038474, 26 | "2e5b55f0c7f113a9dfb88d547e2d6ef249e3d934d58341f8b53cd840b8e752596ad39c2ba2b8c0c1492942275c9d72f39ac9c55f": 756674558, 27 | ad2fab1a5d3a8a19d3f1f86533e9aa90424f912218ebac40199b18cae5a5ee22f35cbda4f011457d36a876384ac129b3c8: 4012932174, 28 | "5779333de5c2836b00c121c0332d02d572d72c059f310a13eb6f7573f658726401834d74eeb676fa21851084": 376972030, 29 | "76754835744f5287c2c2266f2051754d0f74a84ebcbeb0e08ee6bc6031e4192186b775fa2c": 1521819254, 30 | "810820dd3fc02704cd6400ad111068359fb8c468a78d93f156dda804e969dc7b20d1f3a02edf7eefb88919585f83d208b95017bc35c496f374": 3349808023, 31 | "876118221b6487858d401dc807bf39d4af1e743b92d20b9f4b985220455b3b002bff581cd7956db5d0d9f592ebf049": 469142713, 32 | "836014777e399bb45678d68211673f32041d9c07671cfd1ab0bf6ab8dd78b35a4ffa9ce546c381f164a3c65fbeeaa3699ac38dce53": 3532514533, 33 | ef6663fac7bfcee2d7ddc47b3317ca5aec419caba3c3569d5bc61c001e1aebe5070c0a36814bfcc222: 6742071, 34 | cce89a42b68b9f3fd74c32a89f2c0fc59f00a2cdc4bdf2de74270c93d9a044da8822f76e0218: 81287520, 35 | "9061de439fb35e6dde26bbabfb1f55bd0ff6bccd8f7e0b66360758207c34a1c387f1116ce579522b16b92e6c": 3549933163, 36 | f449ba3365e778464a7f1255780c975b3ef0a985f8240a0de8d795a390b7d6509e4841be9dec174d287c3c71ff166c6dbbcfd8c884e725: 2346363972, 37 | "3fac4a8d51d5b592a9c2b72fcad9bd51d85b5d7ce7980ed26a28e7c1de0dcbb723717ff0": 644439712, 38 | "8afc53c6a9c83c5f3da2e21187ef2989a9e8a69e782229edf4b49cf7ff0b5959cdae4e5f09b53dd0": 3487362040, 39 | "732d8505af571da12d7a89d8522a68d266d1f52c455f31498844d8a1c980f3aad6cc05f5a3e7445c7e55d191d0807a84e68ca3e347a38c13": 613216947, 40 | "3c7d6a7e7cd8ad86de018625722f7e8d99adf08da07907ca53bb3cdc0f623240f1fa666ad9dd027c240b": 289041309, 41 | "7ff35f735d2556aef69c855bc375a02bc19cd0767bcf463378c813b3bbab7369958788d6f347deb7b9b43ca46b9ea3f439b240e0549476afad": 3176620970, 42 | fb9411a3f24a281cc63cedc93779bd18bdc692a2c7d9251f79096a93150010d829c74c49a615c1f1: 1629899693, 43 | "4fd867076f7ac7223bd6af1d0a64216ff229e9df9506c52b149f6f2d7372a704a0267800bd17098ea370d92961bb1f63": 1981662236, 44 | d9b4ebd811e649133cf2d00b99aec92faa7ce3ec21b2da205a3c242c9bc959800c0399bdbc2c8e40: 1935808139, 45 | ec8124398309c210fab63f3b760827dacb7ed5c21c7772994ffe96140b737eeda3bd3430b5e6d732611ca113194596fa72e3f0aeb97e12740f8aea8573ad04: 3553441671, 46 | "385198b9d4f724e4a6a26000d65f800a689ca22882d03424aabf212afc51bfb31719dbc8addd267abf0b07161d129d": 1923492819, 47 | "0adf1dd60bdc04671d730e14d5f9e855e2ace8bd84b75c8a76276977c81d07982171201c34e8a83b1473aa3ace11a5c523b2347129e02b52839260e372": 2941927482, 48 | "9f006c75f263d9cb20f903ada8100d22bc6482a35416e9956e4145cd99a288c5c887d4aace591e41ec57913a6d7c": 2649563112, 49 | "1a4560d6fc61ce398817d606b11d0b327e713ee4213c6254624640b7fc85ae28f1092a8838cb8c63f94e4b93f7526c23e02412c82e9816": 625240691, 50 | e20c73b520163c0268ae14a8f383f9b3e5daead3f1cf5c10d91dd7d772e5f959f3: 3938046397, 51 | a52b58d643e311492a493dc4305c2a5fe354ae43ed12b062dc033f649082b4bbb4a6dece5a0f9707c1a7a412d314ec7bad1302a5abb05f927bf5f940: 3954678998, 52 | adb9a79581108a7d5c04d4bca681456e9d8fc716c3b77d800f0f04464b526515576314eefc7d510462fd500e2ee8042e1a24dd66322ae0f63c27: 4078961277, 53 | "710b44310a9fbe65436d5671203dc3b3c5676e52260bd52b2774ffe22c831db2d5f7dff91ecb456e6f83c1c1c4b3bd286f761a": 2656420350, 54 | cdd55e6635180a959fdb38c04fa39c285dca94fa21ffe2a34a146674b6d773b5: 145849661, 55 | "8ee5ff9979c306c1b0cf934a0171211695caa5e1206dcdf3bb1c20b196dec8688257c0bd9c8fe4068e3870304e5948a3455d210cced34821180cdd5da2542b": 3066354466, 56 | "062bc7aab0637a06e69e53e7fd9ca461d37d6c38bdbc99af0e9fa590b187643a0d9f2a22929ef2e1e31b26308060247b67": 3216821156, 57 | "8924e7f2144a92189b11beea545f3a18da5d7282cf348b8b55a295692ead7b292835fd3f531a8be0907903e022f8c6c6f66886c609e5ac55995b9d72b429ea": 907717975, 58 | "9595c21f61fac5c0d291f15531aa695cdbcf72c627b51f4289a7a5f77c182c668f15": 465291004, 59 | "8d36a765fba2c5f377f35624eff24096e5b623c6e0757f95ba55ce43b9658bc51b5fdb253369772361c1a74e3283b6043b8246b06b4e6a17b711aaa6d48239": 2522548140, 60 | "6890088f171a627b3e00651ece3063369ec907371eda5acdf5c46e22bed14ede8895e5a17a": 1948789057, 61 | ed201fcb716be84b4986ae74070a7a02f38e363b9a52ac40bc6db28696384ac1bc77dce7: 3274865305, 62 | "5369eee4a97be8744ac9436485a2576b3f8c9a74de0f04b861b4103becb8902d93e99f254756f0": 1603834845, 63 | c3cc71a36e221096f3567cea42723944643683f56aac87923915d19c023223e99c: 1047634039, 64 | b1129d614a3fa37a48963ba6eb20b05bc011d2fb6c1645df6a58517493c032934e7ec126a603eb4451f064f057922ab0eba7721216: 400683700, 65 | "579acdbef6f1ee2ee68764a7576d5f89b4bd258ea97bb25487d72ea089ec4e207dad272759e80cfa562209dc8caf3abc5062f2b4ec": 3633475111, 66 | f6dbe878361fc34da5fa39432aaa46382fccb02b388772d5c8d89c715027fa9933f6b64b3fe0b1ed: 1938079882, 67 | "087c0fa34884907617322504c14e6f6912b05d8252fc7eff9f1034f3faf94e1bc2f52f0a62327f540cfa38": 1750486135, 68 | d49572a747e22c30536be30bfe100338509d7c1ee30a41cb1e6407e030097658c4892a0a9d8cab18cd1ec75711230c522e47036e7e1911ca54: 1906524473, 69 | "89e2db4cff4af58b4774e5f99bc4f16351c443e9bafa58b4b1e90607d9aa7860b5f603c83d811e7d6285dc986d35ab3f7c1ac84c876aec": 1902064246, 70 | "8a269a1544edb29cfb8443a4c02926d0a5c587351ebf477c3dc68abfe22bd94b0a753d16e6093974": 2647485924, 71 | "409ce8f1077e53f3f8a6c1e3b670cadf1dd77d63f2bacfabf8994beb10e99c8c1261dd235e8a3648": 112568137, 72 | cbea85176753057237c931bb11e086cc3df96d3368e30e0a34d05592a78093a1352c27cf4a9390464678d8206ad0008157562090c814cee7db7be297: 2487975447, 73 | "72cbb6359086e5b8b682aa6927ecef0bee9f64cfd33a506002d8205d3ff4cfb0f947d3ac21eada491bc8a1d47627a028de": 133714852, 74 | eddb7ec72b387a5633b7668779c2b2f970df1943e903c51ab40a987ce9db37f87f6306f331311c9b32cbb622: 3009760418, 75 | "934fdf217cb6c10e9bf3ea166413209ffe6c58806e0815bbbfc73ec37d101d4ca23620bbb95a6d2476e9e3701cdc": 791741936, 76 | "1efcfb345e5ea2758a392362b477fd908bd59d742df264e6a3ff33098525a59ee2fa20c682dcbfee00c1958195f7b8": 3453860354, 77 | "7858589b7f689f2c9715905e2b99f065948480ba57360eaf15af746c5ce350ab4786902c": 1468730088, 78 | "0a1f795af42f6c4b60c366d130ec1077fd554149a985cff433cfb4c5e517a408c3988a65065f6307a263b5ec0d9a2cf6403449304ee323844f852625": 2987411843, 79 | a765491e5e0e9762ab709df23aa745cfe3e699a2bd84b0cd74bae075051581393d8cfafe6ccdaf8c95: 3531119147, 80 | "94c4ab3d143bdeb56c9e8d82bd22b57baaa42e225c95ef64b4adac476316370d0d29f0ec2136a5a49750": 645563184, 81 | e2abbbced848f0230a5e7337fed44788668f9651556680759432c95a74fe8a8b0cec0cb8f9dfd4fe73f2: 3504170306, 82 | ae9af1f117daad3f1400ff537b2b4639a57f96af0cd4e72b12c78f8f86bb98dd6239d1: 4242403130, 83 | a340b17fc6bf758a0d99918cb3a52e7fdd4320dab2c6df4f4b1eb8596821f0415a6b9664cd: 1494508011, 84 | e84c5e5d1d793602dce08c19001f18f629dc616234a8071c22eba84f32480fc9b1c65bd47c54a8: 2556761297, 85 | "184841d7cc4f20d1296341b9036be14045423a32d0e7cf94d1c36739c3447ad434fb49": 4233867762, 86 | "662f4fd3cbd626ce0656c645e51b8e391a76953178ad0609e7e70de4e96d8407": 706711022, 87 | f5d916f36766f9c0018d7afd2254a8b640b9bd661773b82945ece4c941e498d1be22f0417846b30f9b71771f8b0cc4: 1429572861, 88 | "652ea9b4c6a10b8220ecad7a82307e1cf5a83eff63663df906aecaa6fb999d3927": 459355549, 89 | "63133ae40a1d176d218b99388c6bf25a2ae2cec52ffef0b0ad18d76486874e735331cc6be1119d20e7f545b15fce215eb3a06eba6b612165228a36f246b2": 1373196540, 90 | "993c26e45a9e86781944addd39b83e50dff678b0e6189308f110664957daabd9": 3429862123, 91 | "0eab7539e9c7bf2d275576bd83e8ade33957fbce010081a81df34a383d213e99d3a92a0cc917f52fb883bec31d4a6d27b9651d83af": 2345874504, 92 | "320e2acc1bf67c87f00517dce9dabda0192261cc157e42be8ad385227ed704bae9c7761ce2fdfbffb23193b8ac40609269194bfa22e3f6b4cb": 1107469338, 93 | "4148705a0819015cf32164a1bd1c276119e4eb89124a72f2b8658b099806d1c25076bbb28e58287ab64f4d": 3593064858, 94 | "2ec85b2fd2c0ca11c8425af7d67ea1a38df7c3ed50a151476174d2c0bc71d2c2fc33c3f00a5c36d73bc0f90e1bc4dc10b785ecbf8c60ee8c7613ce5ded": 551378781, 95 | "55576f98faa2bbb19831c0ebb190a8432c9c98c209378413369c443679f78ed9ea326aa19e83c9395406ecbbfea0ae5e": 1063916306, 96 | "33e731c52a001ed25b615e5e8437a1a3e7017f2506a4a8d35f6d034ac91595ede652450f5c13bf324ccf42216adbd0ce4a17": 3769933849, 97 | "9e2f38de8f3e5d65bdfcd30946cc707832f29146d4424cd937618668849d35ffb2fef6bead73b6d1fead023578c6fb": 1333161355, 98 | fa639edcb9d1425ce25fa62db8f5e11fc7d35647d5b2cabe3cc047264017617165316e21ff5846e1bc9adb4dcd88: 2041694487, 99 | "09cce26da46fb4804898dfa0f8891c1bcbfe35c78687c0206e8d758b3ad67df1": 2301722839, 100 | a73e5a5ea9bc02b1b125881fb84d57a07f104f97c092820bc2ff16f92845711aeeec514bc1bd21413316d5613700b53f1eebbac5: 3757845119, 101 | "59f315bb4e9baaa509896bb36e31ccfc0cdec1ee0e64aabc21d3c149d7db3c8aaced94f93c8f7321779b2a8c6aadc9cee943110df9d6": 185184649, 102 | f448599a7bda60ce2e4ce007809cd3bf7d6ba077723a1d517309f35f93c92f91d6cd9fb0e3f4f74716ccfb408aa548311683: 2793236018, 103 | a2585facba2a072771e7003b641c4e31b3fc22dc89735860a63eddc7cdc0c1490f368d397620e586dbcd8560159197233b74: 1467531561, 104 | "34f3a60a2efffe067b4c4059b025b72b2e8f15ba92179b01bcedf84a14a7ba8c592b8c55692c5e3978196a399b73d4": 2773683197, 105 | "5e65699325ba021d57a54d4007b3f2d492b300859961c20230dc4278ad62654818bdecae3d26669f286efdc3de1d31c42795a6e773": 4250245515, 106 | a0fda3666249788a70f53f05274ac743966b4d615d2b63fe2413aa7d5117bb7ed5c91cee0bd6c31d269efe: 1605353275, 107 | } 108 | o.spec("crc32", function () { 109 | o("empty array is 0", function () { 110 | const buf = makeByteBuffer() 111 | o(Crc32.calculate(buf)).equals(0) 112 | }) 113 | o("buffer with offset > limit is 0", function () { 114 | const buf = new ByteBuffer(10, ByteBuffer.LITTLE_ENDIAN) 115 | buf.limit = 5 116 | buf.offset = 6 117 | o(Crc32.calculate(buf)).equals(0) 118 | }) 119 | o("buffer with offset == limit is 0", function () { 120 | const buf = new ByteBuffer(10, ByteBuffer.LITTLE_ENDIAN) 121 | buf.limit = 5 122 | buf.offset = 5 123 | o(Crc32.calculate(buf)).equals(0) 124 | }) 125 | o("simple buffer", function () { 126 | const buf = ByteBuffer.fromHex("010203040506", ByteBuffer.LITTLE_ENDIAN) 127 | o(Crc32.calculate(buf)).equals(808769159) 128 | o(buf.offset).equals(0) 129 | }) 130 | Object.keys(TEST_VECTORS).forEach(k => { 131 | o(k, function () { 132 | const buf = ByteBuffer.fromHex(k, ByteBuffer.LITTLE_ENDIAN) 133 | o(Crc32.calculate(buf)).equals(TEST_VECTORS[k]) 134 | }) 135 | }) 136 | }) -------------------------------------------------------------------------------- /lib/mime/decode/rfc2231decoder.ts: -------------------------------------------------------------------------------- 1 | import {splitAtUnquoted, unquote} from "../../utils/utils.js" 2 | import {decode as decodeRfc2047} from "../header/rfc2047.js" 3 | 4 | export type KeyValueList = Array<{key: string, value: string}> 5 | 6 | /** 7 | * This class is responsible for decoding parameters that has been encoded with: 8 | * Continuation: 9 | * This is where a single parameter has such a long value that it could 10 | * be wrapped while in transit. Instead multiple parameters is used on each line. 11 | * 12 | * Example: 13 | * From: Content-Type: text/html; boundary="someVeryLongStringHereWhichCouldBeWrappedInTransit" 14 | * To: 15 | * Content-Type: text/html; boundary*0="someVeryLongStringHere" boundary*1="WhichCouldBeWrappedInTransit" 16 | * Encoding: 17 | * Sometimes other characters then ASCII characters are needed in parameters. 18 | * The parameter is then given a different name to specify that it is encoded. 19 | * Example: 20 | * From: Content-Disposition attachment; filename="specialCharsÆØÅ" 21 | * To: Content-Disposition attachment; filename*="ISO-8859-1'en-us'specialCharsC6D8C0" 22 | * This encoding is almost the same as EncodedWord encoding, and is used to decode the value. 23 | * 24 | * Continuation and Encoding: 25 | * Both Continuation and Encoding can be used on the same time. 26 | * Example: 27 | * From: Content-Disposition attachment; filename="specialCharsÆØÅWhichIsSoLong" 28 | * To: 29 | * Content-Disposition attachment; filename*0*="ISO-8859-1'en-us'specialCharsC6D8C0"; 30 | * filename*1*="WhichIsSoLong" 31 | * This could also be encoded as: 32 | * To: 33 | * Content-Disposition attachment; filename*0*="ISO-8859-1'en-us'specialCharsC6D8C0"; 34 | * filename*1="WhichIsSoLong" 35 | * Notice that filename*1 does not have an * after it - denoting it IS NOT encoded. 36 | * There are some rules about this: 37 | * 1. The encoding must be mentioned in the first part (filename*0*), which has to be encoded. 38 | * 2. No other part must specify an encoding, but if encoded it uses the encoding mentioned in the 39 | * first part. 40 | * 3. Parts may be encoded or not in any order. 41 | * 42 | * More information and the specification is available in RFC 2231 (http://tools.ietf.org/html/rfc2231) 43 | */ 44 | export class Rfc2231Decoder { 45 | /** 46 | * Decodes a string of the form: 47 | * 48 | * value0; key1=value1; key2=value2; key3=value3 49 | * 50 | * The returned List of key value pairs will have the key as key and the decoded value as value. 51 | * The first value0 will have a key of "" 52 | * 53 | * If continuation is used, then multiple keys will be merged into one key with the different values 54 | * decoded into on big value for that key. 55 | * Example: 56 | * title*0=part1 57 | * title*1=part2 58 | * will have key and value of: 59 | * 60 | * title=decode(part1)decode(part2) 61 | * 62 | * @param input {string} the string to decode 63 | * @returns {Array<{key: string, value: string}>} a list of decoded key-value-pairs 64 | */ 65 | static decode(input: string): KeyValueList { 66 | if (input == null) throw new Error("input must not be null!") 67 | // Normalize the input to take account for missing semicolons after parameters. 68 | // Example 69 | // text/plain; charset="iso-8859-1" name="somefile.txt" or 70 | // text/plain; charset="iso-8859-1" name="somefile.txt" 71 | // is normalized to 72 | // text/plain; charset="iso-8859-1"; name="somefile.txt" 73 | // Only works for parameters inside quotes 74 | // \s = matches whitespace 75 | const normalizeRegexp = /=\s*"(?[^"]*)"\s/g 76 | input = input.replace(normalizeRegexp, (match, grp) => `=\"${grp}\"; `) 77 | // Normalize 78 | // Since the above only works for parameters inside quotes, we need to normalize 79 | // the special case with the first parameter. 80 | // Example: 81 | // attachment filename="foo" 82 | // is normalized to 83 | // attachment; filename="foo" 84 | // ^ = matches start of line (when not inside square bracets []) 85 | const normalizeFirstRegexp = /^(?[^;s]+)s(?[^;s]+)/ 86 | input = input.replace(normalizeFirstRegexp, (match, first, second) => `${first}; ${second}`) 87 | // Split by semicolon, but only if not inside quotes 88 | const parts = splitAtUnquoted(input.trim(), ";").map(part => part.trim()) 89 | const collection: KeyValueList = [] 90 | for (let part of parts) { 91 | // Empty strings should not be processed 92 | if (part.length === 0) continue 93 | const eqIdx = part.indexOf("=") 94 | if (eqIdx === -1) 95 | collection.push({ 96 | key: "", 97 | value: part, 98 | }) 99 | else 100 | collection.push({ 101 | key: part.slice(0, eqIdx), 102 | value: part.slice(eqIdx), 103 | }) 104 | } 105 | return Rfc2231Decoder.decodePairs(collection) 106 | } 107 | 108 | /** 109 | * Decodes the list of key value pairs into a decoded list of key value pairs.
110 | * There may be less keys in the decoded list, but then the values for the lost keys will have been appended 111 | * to the new key. 112 | * @param pairs {Array<{key: string, value: string}>} the pairs to decode 113 | * @returns {Array<{key: string, value: string}>} the decoded pairs 114 | */ 115 | static decodePairs(pairs: KeyValueList): KeyValueList { 116 | if (pairs == null) throw new Error("pairs must not be null!") 117 | const resultPairs: KeyValueList = [] 118 | const pairsCount = pairs.length 119 | 120 | for (let i = 0; i < pairsCount; i++) { 121 | const currentPair = pairs[i] 122 | let key = currentPair.key 123 | let value = unquote(currentPair.value) 124 | 125 | // Is it a continuation parameter? (encoded or not) 126 | if (key.endsWith("*0") || key.endsWith("*0*")) { 127 | // This encoding will not be used if we get into the if which tells us 128 | // that the whole continuation is not encoded 129 | let encoding 130 | 131 | // Now lets find out if it is encoded too. 132 | if (key.endsWith("*0*")) { 133 | // It is encoded. 134 | // Fetch out the encoding for later use and decode the value 135 | // If the value was not encoded as the email specified 136 | // encoding will be set to null. This will be used later. 137 | encoding = Rfc2231Decoder.detectEncoding(value) 138 | value = Rfc2231Decoder.decodeSingleValue(value, encoding) 139 | // Find the right key to use to store the full value 140 | // Remove the start *0 which tells is it is a continuation, and the first one 141 | // And remove the * afterwards which tells us it is encoded 142 | key = key.replace("*0*", "") 143 | } else { 144 | // It is not encoded, and no parts of the continuation is encoded either 145 | // Find the right key to use to store the full value 146 | // Remove the start *0 which tells is it is a continuation, and the first one 147 | key = key.replace("*0", "") 148 | } 149 | 150 | // The StringBuilder will hold the full decoded value from all continuation parts 151 | const builder: string[] = [] 152 | // Append the decoded value 153 | builder.push(value) 154 | 155 | // Now go trough the next keys to see if they are part of the continuation 156 | for (let j = i + 1, continuationCount = 1; j < pairsCount; j++, continuationCount++) { 157 | const jKey = pairs[j].key 158 | let valueJKey = unquote(pairs[j].value) 159 | 160 | if (jKey === key + "*" + continuationCount) { 161 | // This value part of the continuation is not encoded 162 | // Therefore remove qoutes if any and add to our stringbuilder 163 | builder.push(valueJKey) 164 | // Remember to increment i, as we have now treated one more KeyValuePair 165 | i++ 166 | } else if (jKey === key + "*" + continuationCount + "*") { 167 | // We will not get into this part if the first part was not encoded 168 | // Therefore the encoding will only be used if and only if the 169 | // first part was encoded, in which case we have remembered the encoding used 170 | // Sometimes an email creator says that a string was encoded, but it really 171 | // `was not. This is to catch that problem. 172 | if (encoding != null) valueJKey = Rfc2231Decoder.decodeSingleValue(valueJKey, encoding) 173 | builder.push(valueJKey) 174 | // Remember to increment i, as we have now treated one more KeyValuePair 175 | i++ 176 | } else { 177 | // No more keys for this continuation 178 | break 179 | } 180 | } 181 | 182 | // Add the key and the full value as a pair 183 | value = builder.join("") 184 | resultPairs.push({ 185 | key, 186 | value, 187 | }) 188 | } else if (key.endsWith("*")) { 189 | // This parameter is only encoded - it is not part of a continuation 190 | // We need to change the key from "*" to "" and decode the value 191 | // To get the key we want, we remove the last * that denotes 192 | // that the value hold by the key was encoded 193 | key = key.replace("*", "") 194 | // Decode the value 195 | let enc = Rfc2231Decoder.detectEncoding(value) 196 | value = Rfc2231Decoder.decodeSingleValue(value, enc) 197 | // Now input the new value with the new key 198 | resultPairs.push({ 199 | key, 200 | value, 201 | }) 202 | } else { 203 | // Fully normal key - the value is not encoded 204 | // Therefore nothing to do, and we can simply pass the pair 205 | // as being decoded now 206 | resultPairs.push(currentPair) 207 | } 208 | } 209 | 210 | return resultPairs 211 | } 212 | 213 | static detectEncoding(input: string): string | null { 214 | if (input == null) throw new Error("input must not be null!") 215 | const quoteIdx = input.indexOf("'") 216 | if (quoteIdx === -1) return null 217 | return input.substring(0, quoteIdx) 218 | } 219 | 220 | /** 221 | and encodingUsed will be set to null 222 | * @param input {string} the value to decode 223 | * @returns {{decoded:string, encodingUsed: string}} decoded value and used encoding (for later use) 224 | */ 225 | 226 | /** 227 | * This will decode a single value of the form: ISO-8859-1'en-us'%3D%3DIamHere 228 | * Which is basically a EncodedWord form just using % instead of = 229 | * Notice that 'en-us' part is not used for anything. 230 | * 231 | * If the single value given is not on the correct form, it will be returned without 232 | * being decoded 233 | * 234 | * @param input {string} the value to decode 235 | * @param encoding {string} the encoding used to decode with 236 | * @returns {string} decoded value corresponding to input 237 | */ 238 | static decodeSingleValue(input: string, encoding: string | null): string { 239 | if (input == null) throw new Error("input must not be null!") 240 | if (encoding == null) return input 241 | // The encoding used is the same as QuotedPrintable, we only 242 | // need to change % to = 243 | // And otherwise make it look like the correct EncodedWord encoding 244 | input = "=?" + encoding + "?Q?" + input.replace("%", "=") + "?=" 245 | return decodeRfc2047(input) 246 | } 247 | } -------------------------------------------------------------------------------- /lib/properties.ts: -------------------------------------------------------------------------------- 1 | import {PropertyTagLiterals, PropertyTags, PropertyTagsEnum} from "./property_tags.js" 2 | import {PropertyFlag, PropertyType} from "./enums.js" 3 | import {Property} from "./property.js" 4 | import ByteBuffer from "bytebuffer" 5 | import { 6 | bigInt64ToParts, 7 | byteBufferAsUint8Array, 8 | makeByteBuffer, 9 | stringToAnsiArray, 10 | stringToUtf16LeArray, 11 | stringToUtf8Array 12 | } from "./utils/utils.js" 13 | import type {CFBStorage} from "./cfb_storage.js" 14 | import {dateToFileTime} from "./utils/time.js" 15 | 16 | const DEFAULT_FLAGS = PropertyFlag.PROPATTR_READABLE | PropertyFlag.PROPATTR_WRITABLE 17 | 18 | export class Properties extends Array { 19 | /** 20 | * add a prop it it doesn't exist, otherwise replace it 21 | */ 22 | addOrReplaceProperty(tag: PropertyTagsEnum, obj: any, flags: number = PropertyFlag.PROPATTR_READABLE | PropertyFlag.PROPATTR_WRITABLE) { 23 | const index = this.findIndex(p => p.id === tag.id) 24 | if (index >= 0) this.splice(index, 1) 25 | this.addProperty(tag, obj, flags) 26 | } 27 | 28 | _expectPropertyType(expected: PropertyType, actual: PropertyType) { 29 | if (actual !== expected) { 30 | throw new Error(`Invalid PropertyType "${PropertyType[actual]}". Expected "${PropertyType[expected]}"`) 31 | } 32 | } 33 | 34 | addDateProperty(tag: PropertyTagsEnum, value: Date, flags: number = DEFAULT_FLAGS) { 35 | this._expectPropertyType(PropertyType.PT_SYSTIME, tag.type) 36 | 37 | this._addProperty(tag, dateToFileTime(value), flags) 38 | } 39 | 40 | addBinaryProperty(tag: PropertyTagsEnum, data: Uint8Array, flags: number = DEFAULT_FLAGS) { 41 | this._expectPropertyType(PropertyType.PT_BINARY, tag.type) 42 | 43 | this._addProperty(tag, data, flags) 44 | } 45 | 46 | // TODO use this internally, replace all calls to addProperty with methods that can actually be typechecked, maybe even make this typecheckable somehow 47 | _addProperty(tag: PropertyTagsEnum, value: any, flags: number): void { 48 | return this.addProperty(tag, value, flags) 49 | } 50 | 51 | /** 52 | * @deprecated use typed addPropertyFunctions instead (or make one if it doesn't exist). replace this method with _addProperty and only use it internally 53 | * @param tag 54 | * @param value 55 | * @param flags 56 | */ 57 | addProperty(tag: PropertyTagsEnum, value: any, flags: number = DEFAULT_FLAGS): void { 58 | if (value == null) return 59 | let data = new Uint8Array(0) 60 | let view 61 | 62 | switch (tag.type) { 63 | case PropertyType.PT_APPTIME: 64 | data = new Uint8Array(8) 65 | view = new DataView(data.buffer) 66 | view.setFloat64(0, value, true) 67 | break 68 | 69 | case PropertyType.PT_SYSTIME: 70 | data = new Uint8Array(8) 71 | view = new DataView(data.buffer) 72 | const {upper, lower} = bigInt64ToParts(value) 73 | view.setInt32(0, lower, true) 74 | view.setInt32(4, upper, true) 75 | break 76 | 77 | case PropertyType.PT_SHORT: 78 | data = new Uint8Array(2) 79 | view = new DataView(data.buffer) 80 | view.setInt16(0, value, true) 81 | break 82 | 83 | case PropertyType.PT_ERROR: 84 | case PropertyType.PT_LONG: 85 | data = new Uint8Array(4) 86 | view = new DataView(data.buffer) 87 | view.setInt32(0, value, true) 88 | break 89 | 90 | case PropertyType.PT_FLOAT: 91 | data = new Uint8Array(4) 92 | view = new DataView(data.buffer) 93 | view.setFloat32(0, value, true) 94 | break 95 | 96 | case PropertyType.PT_DOUBLE: 97 | data = new Uint8Array(8) 98 | view = new DataView(data.buffer) 99 | view.setFloat64(0, value, true) 100 | break 101 | 102 | //case PropertyType.PT_CURRENCY: 103 | // data = (byte[]) obj 104 | // break 105 | case PropertyType.PT_BOOLEAN: 106 | data = Uint8Array.from([value ? 1 : 0]) 107 | break 108 | 109 | case PropertyType.PT_I8: 110 | // TODO: 111 | throw new Error("PT_I8 property type is not supported (64 bit ints)!") 112 | 113 | // data = BitConverter.GetBytes((long)obj) 114 | case PropertyType.PT_UNICODE: 115 | data = stringToUtf16LeArray(value) 116 | break 117 | 118 | case PropertyType.PT_STRING8: 119 | data = stringToAnsiArray(value) 120 | break 121 | 122 | case PropertyType.PT_CLSID: 123 | // GUIDs should be Uint8Arrays already 124 | data = value 125 | break 126 | 127 | case PropertyType.PT_BINARY: 128 | // TODO: make user convert object to Uint8Array and just assign. 129 | if (value instanceof Uint8Array) { 130 | data = value 131 | break 132 | } 133 | 134 | const objType = typeof value 135 | 136 | switch (objType) { 137 | case "boolean": 138 | data = Uint8Array.from(value) 139 | break 140 | 141 | case "string": 142 | data = stringToUtf8Array(value) 143 | break 144 | 145 | default: 146 | throw new Error(`PT_BINARY property of type '${objType}' not supported!`) 147 | } 148 | 149 | break 150 | 151 | case PropertyType.PT_NULL: 152 | break 153 | 154 | case PropertyType.PT_ACTIONS: 155 | throw new Error("PT_ACTIONS property type is not supported") 156 | 157 | case PropertyType.PT_UNSPECIFIED: 158 | throw new Error("PT_UNSPECIFIED property type is not supported") 159 | 160 | case PropertyType.PT_OBJECT: 161 | // TODO: Add support for MSG 162 | break 163 | 164 | case PropertyType.PT_SVREID: 165 | throw new Error("PT_SVREID property type is not supported") 166 | 167 | case PropertyType.PT_SRESTRICT: 168 | throw new Error("PT_SRESTRICT property type is not supported") 169 | 170 | default: 171 | throw new Error("type is out of range!") 172 | } 173 | 174 | this.push( 175 | new Property({ 176 | id: tag.id, 177 | type: tag.type, 178 | flags, 179 | data, 180 | }), 181 | ) 182 | } 183 | 184 | /** 185 | * writes the properties structure to a cfb stream in storage 186 | * @param storage 187 | * @param prefix a function that will be called with the buffer before the properties get written to it. 188 | * @param messageSize 189 | * @returns {number} 190 | */ 191 | writeProperties(storage: CFBStorage, prefix: (buffer: ByteBuffer) => void, messageSize?: number): number { 192 | const buf = makeByteBuffer() 193 | prefix(buf) 194 | let size = 0 195 | 196 | // The data inside the property stream (1) MUST be an array of 16-byte entries. The number of properties, 197 | // each represented by one entry, can be determined by first measuring the size of the property stream (1), 198 | // then subtracting the size of the header from it, and then dividing the result by the size of one entry. 199 | // The structure of each entry, representing one property, depends on whether the property is a fixed length 200 | // property or not. 201 | for (let property of this) { 202 | // property tag: A 32-bit value that contains a property type and a property ID. The low-order 16 bits 203 | // represent the property type. The high-order 16 bits represent the property ID. 204 | buf.writeUint16(property.type) // 2 bytes 205 | 206 | buf.writeUint16(property.id) // 2 bytes 207 | 208 | buf.writeUint32(property._flags) // 4 bytes 209 | 210 | switch (property.type) { 211 | //case PropertyType.PT_ACTIONS: 212 | // break 213 | case PropertyType.PT_I8: 214 | case PropertyType.PT_APPTIME: 215 | case PropertyType.PT_SYSTIME: 216 | case PropertyType.PT_DOUBLE: 217 | buf.append(property._data) 218 | break 219 | 220 | case PropertyType.PT_ERROR: 221 | case PropertyType.PT_LONG: 222 | case PropertyType.PT_FLOAT: 223 | buf.append(property._data) 224 | buf.writeUint32(0) 225 | break 226 | 227 | case PropertyType.PT_SHORT: 228 | buf.append(property._data) 229 | buf.writeUint32(0) 230 | buf.writeUint16(0) 231 | break 232 | 233 | case PropertyType.PT_BOOLEAN: 234 | buf.append(property._data) 235 | buf.append(new Uint8Array(7)) 236 | break 237 | 238 | //case PropertyType.PT_CURRENCY: 239 | // binaryWriter.Write(property.Data) 240 | // break 241 | case PropertyType.PT_UNICODE: 242 | // Write the length of the property to the propertiesstream 243 | buf.writeInt32(property._data.length + 2) 244 | buf.writeUint32(0) 245 | storage.addStream(property.name(), property._data) 246 | size += property._data.length 247 | break 248 | 249 | case PropertyType.PT_STRING8: 250 | // Write the length of the property to the propertiesstream 251 | buf.writeInt32(property._data.length + 1) 252 | buf.writeUint32(0) 253 | storage.addStream(property.name(), property._data) 254 | size += property._data.length 255 | break 256 | 257 | case PropertyType.PT_CLSID: 258 | buf.append(property._data) 259 | break 260 | 261 | //case PropertyType.PT_SVREID: 262 | // break 263 | //case PropertyType.PT_SRESTRICT: 264 | // storage.AddStream(property.Name).SetData(property.Data) 265 | // break 266 | case PropertyType.PT_BINARY: 267 | // Write the length of the property to the propertiesstream 268 | buf.writeInt32(property._data.length) 269 | buf.writeUint32(0) 270 | storage.addStream(property.name(), property._data) 271 | size += property._data.length 272 | break 273 | 274 | case PropertyType.PT_MV_SHORT: 275 | break 276 | 277 | case PropertyType.PT_MV_LONG: 278 | break 279 | 280 | case PropertyType.PT_MV_FLOAT: 281 | break 282 | 283 | case PropertyType.PT_MV_DOUBLE: 284 | break 285 | 286 | case PropertyType.PT_MV_CURRENCY: 287 | break 288 | 289 | case PropertyType.PT_MV_APPTIME: 290 | break 291 | 292 | case PropertyType.PT_MV_LONGLONG: 293 | break 294 | 295 | case PropertyType.PT_MV_UNICODE: 296 | // PropertyType.PT_MV_TSTRING 297 | break 298 | 299 | case PropertyType.PT_MV_STRING8: 300 | break 301 | 302 | case PropertyType.PT_MV_SYSTIME: 303 | break 304 | 305 | //case PropertyType.PT_MV_CLSID: 306 | // break 307 | case PropertyType.PT_MV_BINARY: 308 | break 309 | 310 | case PropertyType.PT_UNSPECIFIED: 311 | break 312 | 313 | case PropertyType.PT_NULL: 314 | break 315 | 316 | case PropertyType.PT_OBJECT: 317 | // TODO: Adding new MSG file 318 | break 319 | } 320 | } 321 | 322 | if (messageSize != null) { 323 | buf.writeUint16(PropertyTags.PR_MESSAGE_SIZE.type) // 2 bytes 324 | 325 | buf.writeUint16(PropertyTags.PR_MESSAGE_SIZE.id) // 2 bytes 326 | 327 | buf.writeUint32(PropertyFlag.PROPATTR_READABLE | PropertyFlag.PROPATTR_WRITABLE) // 4 bytes 328 | 329 | buf.writeUint64(messageSize + size + 8) 330 | buf.writeUint32(0) 331 | } 332 | 333 | // Make the properties stream 334 | size += buf.offset 335 | storage.addStream(PropertyTagLiterals.PropertiesStreamName, byteBufferAsUint8Array(buf)) 336 | // if(!storage.TryGetStream(PropertyTags.PropertiesStreamName, out var propertiesStream)) 337 | // propertiesStream = storage.AddStream(PropertyTags.PropertiesStreamName); 338 | // TODO: is this the written length? 339 | return size 340 | } 341 | } --------------------------------------------------------------------------------