Skip to content

Commit

Permalink
feat(attachments): Added new option 'attachmentEncoding' to return at…
Browse files Browse the repository at this point in the history
…tachment content as a string, not arraybuffer
  • Loading branch information
andris9 committed Jan 5, 2025
1 parent 42b8bf3 commit 0f7e9df
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 95 deletions.
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2021-2024 Andris Reinman
Copyright (c) 2021-2025 Andris Reinman

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
186 changes: 96 additions & 90 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,51 @@
# postal-mime

Email parser for browser and serverless environments.
**postal-mime** is an email parsing library that runs in browser environments (including Web Workers) and serverless functions (like Cloudflare Email Workers). It takes in a raw email message (RFC822 format) and outputs a structured object containing headers, recipients, attachments, and more.

PostalMime can be run in the main web thread or from Web Workers. It can also be used in serverless functions like Cloudflare Email Workers.
> **Tip**
> PostalMime is developed by the makers of [EmailEngine](https://emailengine.app/?utm_source=github&utm_campaign=imapflow&utm_medium=readme-link)—a self-hosted email gateway that provides a REST API for IMAP and SMTP servers and sends webhooks whenever something changes in registered accounts.
> [!TIP]
> PostalMime is developed by the makers of **[EmailEngine](https://emailengine.app/?utm_source=github&utm_campaign=imapflow&utm_medium=readme-link)** – a self-hosted email gateway that allows making **REST requests against IMAP and SMTP servers**. EmailEngine also sends webhooks whenever something changes on the registered accounts.
## Table of Contents

- [Source](#source)
- [Demo](#demo)
- [Installation](#installation)
- [Usage](#usage)
- [Browser](#browser)
- [Node.js](#nodejs)
- [Cloudflare Email Workers](#cloudflare-email-workers)
- [API](#api)
- [PostalMime.parse()](#postalmimeparse)
- [Utility Functions](#utility-functions)
- [addressParser()](#addressparser)
- [decodeWords()](#decodewords)
- [License](#license)

---

## Source

The source code is available on [GitHub](https://github.com/postalsys/postal-mime).

## Demo

See this [example](https://kreata.ee/postal-mime/example/).

## Usage
Try out a live demo using the [example page](https://kreata.ee/postal-mime/example/).

First, install the module from npm:

```
$ npm install postal-mime
```
## Installation

Next, import the PostalMime class into your script:
Install the module from npm:

```js
import PostalMime from './node_modules/postal-mime/src/postal-mime.js';
```bash
npm install postal-mime
```

Or when using it from a Node.js app or in a serverless function:

```js
import PostalMime from 'postal-mime';
```
## Usage

### Promises
You can import the `PostalMime` class differently depending on your environment:

PostalMime methods use Promises, so you need to wait using `await` or the `then()` method to get the response.
### Browser

#### Browser
To use PostalMime in the browser (including Web Workers), import it from the `src` folder:

```js
import PostalMime from './node_modules/postal-mime/src/postal-mime.js';
Expand All @@ -49,12 +55,12 @@ Content-Type: text/html; charset=utf-8
<p>Hello world 😵‍💫</p>`);

console.log(email.subject);
console.log(email.subject); // "My awesome email 🤓"
```

#### Node.js
### Node.js

It is pretty much the same as in the browser.
In Node.js (including serverless functions), import it directly from `postal-mime`:

```js
import PostalMime from 'postal-mime';
Expand All @@ -65,12 +71,13 @@ Content-Type: text/html; charset=utf-8
<p>Hello world 😵‍💫</p>`);

// Use 'util.inspect' for pretty-printing
console.log(util.inspect(email, false, 22, true));
```

#### Cloudflare [Email Workers](https://developers.cloudflare.com/email-routing/email-workers/)
### Cloudflare Email Workers

Pretty much the same as in Node.js. Use `message.raw` as the raw message for parsing.
Use the `message.raw` as the raw email data for parsing:

```js
import PostalMime from 'postal-mime';
Expand All @@ -79,107 +86,106 @@ export default {
async email(message, env, ctx) {
const email = await PostalMime.parse(message.raw);

console.log('Subject: ', email.subject);
console.log('HTML: ', email.html);
console.log('Text: ', email.text);
console.log('Subject:', email.subject);
console.log('HTML:', email.html);
console.log('Text:', email.text);
}
};
```

#### PostalMime.parse()
---

`parse(email, options)` is a static class method used to parse emails.
## API

### PostalMime.parse()

```js
PostalMime.parse(email, options) -> Promise
PostalMime.parse(email, options) -> Promise<ParsedEmail>
```

Where:
- **email**: The RFC822 formatted email. This can be a string, an ArrayBuffer/Uint8Array, a Blob object, a Node.js Buffer, or a [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
- **options**: An optional object containing configuration options.
- **rfc822Attachments**: A boolean (defaults to `false`). If set to `true`, then treats `message/rfc822` attachments without a Content-Disposition declaration as attachments. By default, these messages are treated as inline values.
- **forceRfc822Attachments**: A boolean (defaults to `false`). If set to `true`, then treats all `message/rfc822` nodes as attachments.
This method parses an email message into a structured object with the following properties:
- **headers**: An array of headers in the order they appear in the message (topmost headers first).
- **headers[].key**: The lowercase key of the header line, e.g., `"dkim-signature"`.
- **headers[].value**: The unprocessed value of the header line.
- **from**, **sender**: Includes a processed object for the corresponding headers.
- **from.name**: The decoded name (empty string if not set).
- **from.address**: The email address.
- **deliveredTo**, **returnPath**: The email address from the corresponding header.
- **to**, **cc**, **bcc**, **replyTo**: An array of processed objects for the corresponding headers.
- **to[].name**: The decoded name (empty string if not set).
- **to[].address**: The email address.
- **subject**: The email subject line.
- **messageId**, **inReplyTo**, **references**: The value as found in the corresponding header without any processing.
- **date**: The email sending time formatted as an ISO date string (unless parsing failed, in which case the original value is used).
- **html**: The HTML content of the message as a string.
- **text**: The plaintext content of the message as a string.
- **attachments**: An array that includes the message attachments.
- **attachments[].filename**: The file name if provided.
- **attachments[].mimeType**: The MIME type of the attachment.
- **attachments[].disposition**: Either "attachment", "inline", or `null` if disposition was not provided.
- **attachments[].related**: A boolean value indicating if this attachment should be treated as an embedded image.
- **attachments[].contentId**: The ID from the Content-ID header.
- **attachments[].content**: An ArrayBuffer value that contains the attachment file.
- **email**: An RFC822 formatted email. This can be a `string`, `ArrayBuffer/Uint8Array`, `Blob` (browser only), `Buffer` (Node.js), or a [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
- **options**: Optional configuration object:
- **rfc822Attachments** (boolean, default: `false`): Treat `message/rfc822` attachments without a Content-Disposition as attachments.
- **forceRfc822Attachments** (boolean, default: `false`): Treat _all_ `message/rfc822` parts as attachments.
- **attachmentEncoding** (string): Determines how attachment content is decoded in the parsed email:
- `"base64"`
- `"utf8"`
- `"arraybuffer"` (no decoding, returns `ArrayBuffer`)

**Returns**: A Promise that resolves to a structured object with the following properties:

- **headers**: An array of header objects, each containing:
- `key`: Lowercase header name (e.g., `"dkim-signature"`).
- `value`: Unprocessed header value as a string.
- **from**, **sender**: Processed address objects:
- `name`: Decoded display name, or an empty string if not set.
- `address`: Email address.
- **deliveredTo**, **returnPath**: Single email addresses as strings.
- **to**, **cc**, **bcc**, **replyTo**: Arrays of processed address objects (same structure as `from`).
- **subject**: Subject line of the email.
- **messageId**, **inReplyTo**, **references**: Values from their corresponding headers.
- **date**: The email’s sending time in ISO 8601 format (or the original string if parsing fails).
- **html**: String containing the HTML content of the email.
- **text**: String containing the plain text content of the email.
- **attachments**: Array of attachment objects:
- `filename`
- `mimeType`
- `disposition` (e.g., `"attachment"`, `"inline"`, or `null`)
- `related` (boolean, `true` if it’s an inline image)
- `contentId`
- `content` (array buffer or string, depending on `attachmentEncoding`)
- `encoding` (e.g., `"base64"`)

---

### Utility Functions

#### addressParser
Parse email address strings.
#### addressParser()

```js
import { addressParser } from 'postal-mime';

addressParser(addressStr, opts) -> Array
```

Where:
- **addressStr**: A raw address header string.
- **opts**: Optional configuration:
- **flatten** (boolean, default: `false`): If `true`, ignores address groups and returns a flat array of addresses.

- **addressStr**: The header value for an address header.
- **opts**: An optional object containing configuration options.
- **flatten**: A boolean value. If set to `true`, it ignores address groups and returns a flat array of addresses. By default (`flatten` is `false`), the result might include nested groups.
**Returns**: An array of address objects, which can be nested if address groups are present.

The result is an array of objects:

- **name**: The name string. An empty string is used if the name value is not set.
- **address**: The email address value.
- **group**: An array of nested address objects. This is used when `flatten` is `false` (the default) and the address string contains address group syntax.
**Example**:

```js
import { addressParser } from 'postal-mime';

const addressStr = '=?utf-8?B?44Ko44Od44K544Kr44O844OJ?= <[email protected]>';
console.log(addressParser(addressStr));
// [ { name: 'エポスカード', address: '[email protected]' } ]
```

#### decodeWords

Decode MIME encoded-words.
#### decodeWords()

```js
decodeWords(encodedStr) -> String
import { decodeWords } from 'postal-mime';

decodeWords(encodedStr) -> string
```

Where:
- **encodedStr**: A string that may contain MIME encoded-words.

- **encodedStr**: A string value that _may_ include MIME encoded-words.
**Returns**: A Unicode string with all encoded-words decoded.

The result is a Unicode string.
**Example**:

```js
import { decodeWords } from 'postal-mime';

const encodedStr = 'Hello, =?utf-8?B?44Ko44Od44K544Kr44O844OJ?=';
console.log(decodeWords(encodedStr));
// Hello, エポスカード
```

---

## License

&copy; 2021-2024 Andris Reinman
&copy; 2021–2025 Andris Reinman

`postal-mime` is licensed under the **MIT No Attribution license**
`postal-mime` is licensed under the **MIT No Attribution license**.
4 changes: 2 additions & 2 deletions example/example.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,9 @@ function renderEmail(email) {
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('open-file').addEventListener('click', () => {
browseFileContents()
.then(file => PostalMime.parse(file))
.then(file => PostalMime.parse(file, { attachmentEncoding: 'utf8' }))
.then(email => {
console.log(JSON.stringify(email, false, 2));
console.log(email);
renderEmail(email);
})
.catch(err => console.error(err));
Expand Down
6 changes: 4 additions & 2 deletions postal-mime.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type Attachment = {
description?: string;
contentId?: string;
method?: string;
content: ArrayBuffer;
content: ArrayBuffer | string;
encoding?: "base64" | "utf8";
};

export type Email = {
Expand Down Expand Up @@ -54,7 +55,8 @@ declare function decodeWords (

declare type PostalMimeOptions = {
rfc822Attachments?: boolean,
forceRfc822Attachments?: boolean
forceRfc822Attachments?: boolean,
attachmentEncoding?: "base64" | "utf8"
}

declare class PostalMime {
Expand Down
69 changes: 69 additions & 0 deletions src/base64-encoder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Code from: https://gist.githubusercontent.com/jonleighton/958841/raw/fb05a8632efb75d85d43deb593df04367ce48371/base64ArrayBuffer.js

// Converts an ArrayBuffer directly to base64, without any intermediate 'convert to string then
// use window.btoa' step. According to my tests, this appears to be a faster approach:
// http://jsperf.com/encoding-xhr-image-data/5

/*
MIT LICENSE
Copyright 2011 Jon Leighton
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

export function base64ArrayBuffer(arrayBuffer) {
var base64 = '';
var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

var bytes = new Uint8Array(arrayBuffer);
var byteLength = bytes.byteLength;
var byteRemainder = byteLength % 3;
var mainLength = byteLength - byteRemainder;

var a, b, c, d;
var chunk;

// Main loop deals with bytes in chunks of 3
for (var i = 0; i < mainLength; i = i + 3) {
// Combine the three bytes into a single integer
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];

// Use bitmasks to extract 6-bit segments from the triplet
a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
d = chunk & 63; // 63 = 2^6 - 1

// Convert the raw binary segments to the appropriate ASCII encoding
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
}

// Deal with the remaining bytes and padding
if (byteRemainder == 1) {
chunk = bytes[mainLength];

a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2

// Set the 4 least significant bits to zero
b = (chunk & 3) << 4; // 3 = 2^2 - 1

base64 += encodings[a] + encodings[b] + '==';
} else if (byteRemainder == 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];

a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4

// Set the 2 least significant bits to zero
c = (chunk & 15) << 2; // 15 = 2^4 - 1

base64 += encodings[a] + encodings[b] + encodings[c] + '=';
}

return base64;
}
Loading

0 comments on commit 0f7e9df

Please sign in to comment.