@push.rocks/smartmail
One TypeScript mail package: a message model with MIME rendering and parsing, SMTP and sendmail delivery, an IMAP client and server, a JMAP client and server core, email address validation, and a JSON wire protocol. Every protocol sits behind its own entry point, so an application loads only the protocol it uses.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add @push.rocks/smartmail
The package is ESM-only and targets Node.js (Deno through npm: specifiers works as well).
Entry Points
| Import | Contents | Loads at runtime |
|---|---|---|
@push.rocks/smartmail |
Smartmail (templated messages), IMailAddress, IMailAttachment, IMailMessage, the MIME renderer (renderMailMessage, renderMimeMessage, …), the parsers (parseMailMessage, parseMimeStructure), the RFC 2047 header codec and address helpers |
postal-mime, @push.rocks/smarthbs (templating) |
@push.rocks/smartmail/smtp |
Smartsmtp: SMTP relay and sendmail transport |
the root |
@push.rocks/smartmail/imap |
ImapClient (watching) and ImapReader (read-only pulling) |
the root, imapflow |
@push.rocks/smartmail/imap/server |
ImapServer, MemoryImapBackend, IImapMailBackend |
the root |
@push.rocks/smartmail/jmap |
JmapClient, JmapServer, MemoryMailBackend, IJmapMailBackend |
the root |
@push.rocks/smartmail/validation |
EmailAddressValidator |
@push.rocks/smartdns, @push.rocks/smartrequest |
@push.rocks/smartmail/wire |
WireTarget, WireParser, wire message types |
the root, @push.rocks/smartrequest |
No entry point loads another protocol; the root loads no protocol, DNS or HTTP client. The package's test suite checks this module graph for every entry point.
The Message Model
Smartmail: templated messages
Smartmail holds a message whose subject and bodies are templates ({{placeholder}}), its recipients, headers, priority and attachments. All mutation methods return this.
import { Smartmail } from '@push.rocks/smartmail';
const mail = new Smartmail({
from: 'Billing <billing@example.com>',
to: ['customer@example.com'],
subject: 'Invoice {{number}}',
body: 'Hello {{name}}, your invoice {{number}} is attached.',
htmlBody: '<p>Hello <b>{{name}}</b>, your invoice {{number}} is attached.</p>',
creationObjectRef: { orderId: '12345' }, // any typed context, returned by getCreationObject()
})
.addRecipient('accounting@example.com', 'cc')
.addRecipients(['audit@example.com'], 'bcc')
.setReplyTo('support@example.com')
.setPriority('high')
.addHeader('X-Campaign-ID', 'autumn')
.addAttachment({ filename: 'invoice.pdf', contentType: 'application/pdf', content: pdfBytes });
mail.getSubject({ number: '42' }); // 'Invoice 42'
mail.getBody({ name: 'Jane', number: '42' }); // plain text with the data applied
mail.getHtmlBody({ name: 'Jane' }); // HTML with the data applied, or null
mail.applyVariables({ name: 'Jane' }); // fills the templates in place
// The message with the templates filled: every recipient list, reply-to, headers,
// priority, both bodies and the attachments — what the renderer and the SMTP transport take.
const message = mail.toMailMessage({ name: 'Jane', number: '42' });
Addresses are strings holding one RFC 5322 mailbox each (jane@example.com, Jane Doe <jane@example.com>, "Doe, Jane" <jane@example.com>).
toObject() / toJson() and the static fromObject() / fromJson() serialize a Smartmail with its attachments as base64; streamed attachment content cannot be serialized and throws.
Attachments
interface IMailAttachment {
filename: string;
contentType?: string; // default application/octet-stream
content: TMailByteSource; // string | Uint8Array | (Async)Iterable<Uint8Array> | ReadableStream<Uint8Array>
expectedSizeBytes?: number; // required for streamed content, enforced exactly
disposition?: 'attachment' | 'inline';
contentId?: string; // without angle brackets, for cid: references
}
Rendering MIME
renderMailMessage turns an IMailMessage into a one-shot byte stream together with its exact size and Message-ID. Streamed attachments are read once while the stream is consumed and base64-encoded in 76-character lines, so the message is never held in memory. renderMailMessageBytes renders a message whose attachments are all in memory synchronously.
import { renderMailMessage, renderMailMessageBytes } from '@push.rocks/smartmail';
const rendered = renderMailMessage({
from: { name: 'Jürgen', email: 'juergen@example.com' },
to: ['"Doe, Jane" <jane@example.com>'],
subject: 'Grüße aus Bremen',
text: 'Plain text',
html: '<p>HTML</p>',
attachments: [{ filename: 'report.pdf', contentType: 'application/pdf', content: stream, expectedSizeBytes: size }],
});
rendered.source; // AsyncIterable<Uint8Array>
rendered.sizeBytes; // exact byte count, e.g. for SMTP SIZE
rendered.messageId; // '<uuid@example.com>'
What the renderer guarantees:
- Headers: non-ASCII text in unstructured fields (Subject, custom headers) and in display names is written as RFC 2047 encoded words; a field that carries encoded words, or whose line would exceed 998 octets, is folded (RFC 5322 §2.1.1, RFC 2047 §2). CR and LF in values become spaces, so a value cannot inject a header field. Structured fields (addresses, Message-ID, MIME fields) must be ASCII; one that cannot be folded within 998 octets is refused.
MIME-Version,Content-TypeandContent-Transfer-Encodingcome from the MIME tree and cannot be set throughheaders; otherheadersoverride the rendered fields of the same name. - Bodies: line breaks become CRLF. A text part is sent
8bitwhile every line fits 998 octets andquoted-printableotherwise. - Structure: one body stays a single part; text and HTML become
multipart/alternative; attachments make amultipart/mixed. Attachment names that are not ASCII get RFC 2231name*/filename*parameters next to an ASCII fallback. - Bcc recipients are never written into the message.
For full control, build the tree yourself: arrangeMimeTree, getMimeNodeHeaders, renderMimeMessage(headers, tree) and renderMimeMessageBytes(headers, tree) take an explicit list of header fields and a TMimeNode (text, binary or multipart nodes). renderHeaderField, encodeHeaderText, encodeWords, decodeWords and formatMimeParameter are the header codec on its own.
Parsing
parseMailMessage parses a raw message (with postal-mime): bodies decoded to text, attachments to bytes, addresses to IMailAddress lists with groups flattened and display names decoded.
import { parseMailMessage } from '@push.rocks/smartmail';
const parsed = await parseMailMessage(rawBytes);
parsed.subject; // decoded
parsed.from; // IMailAddress[]: { email, name? }
parsed.to; parsed.cc; parsed.bcc; parsed.replyTo; // IMailAddress[]
parsed.sender; // IMailAddress | undefined
parsed.messageId; // '<id@host>'
parsed.inReplyTo; // string | undefined
parsed.references; // ['<a@host>', '<b@host>']
parsed.date; // Date | undefined
parsed.text; // string | undefined
parsed.html; // string | undefined
parsed.headers; // { key, name, value }[] in message order
parsed.headerLines; // { key, line }[]
parsed.attachments; // { filename?, contentType, disposition?, contentId?, content: Uint8Array, size }[]
parseMimeStructure is the byte-exact counterpart: it splits a message into its MIME tree without decoding any transfer encoding (raw, header, body, headers, contentType, children, encapsulatedMessage), which is what IMAP sections and BODYSTRUCTURE need. readMimeHeaders and parseParameterizedHeader read header blocks and parameterized values.
Addresses
import { formatAddress, parseAddressList, parseAddressListEntries, toMailAddress } from '@push.rocks/smartmail';
formatAddress({ email: 'jane@example.com', name: 'Doe, Jane' }); // '"Doe, Jane" <jane@example.com>'
parseAddressList('Jane <jane@example.com>, Team: a@example.com, b@example.com;');
// [{ email: 'jane@example.com', name: 'Jane' }, { email: 'a@example.com' }, { email: 'b@example.com' }]
parseAddressListEntries('Team: a@example.com;'); // groups kept: [{ kind: 'group', name: 'Team', mailboxes: [...] }]
toMailAddress('Jane <jane@example.com>'); // exactly one mailbox, or an Error
Email Address Validation
import { EmailAddressValidator } from '@push.rocks/smartmail/validation';
const validator = new EmailAddressValidator({
cacheDnsResults: true, // default true
cacheExpiryMs: 3600000, // default one hour
});
const result = await validator.validate('user@example.com');
// { valid, formatValid, localPartValid, domainPartValid, mxValid, mxCheck, disposable, freemail, reason }
validator.isValidEmailFormat('user@example.com'); // synchronous format check
validator.isRoleAccount('info@example.com'); // true
await validator.isDisposableEmail('x@mailinator.com');
await validator.getMxRecords('example.com');
await validator.checkMxRecordsStrict('example.com'); // { outcome: 'confirmed' | 'absent' | 'unavailable', records }
Only an authoritative "no MX records" (mxCheck: 'absent') makes an address invalid; a resolver that cannot be reached leaves a well-formed address valid with mxCheck: 'unavailable'.
Disposable and free-mail providers are classified with the domain list bundled with the package; validation never fetches anything. To use the current online list, opt in:
await validator.refreshDomainList(); // or refreshDomainList('https://your.mirror/domains.json')
refreshDomainList throws when the list cannot be fetched or is not a domain classification, and the previous list stays in place.
SMTP
import { Smartsmtp } from '@push.rocks/smartmail/smtp';
Creating a Transport
const smtp = await Smartsmtp.createSmartsmtpWithRelay({
smtpServer: 'smtp.example.com',
smtpPort: 587,
smtpTlsMode: 'starttls', // 'implicitTls' (default, port 465), 'starttls' or 'plain'
smtpUser: 'user@example.com',
smtpPassword: 'yourPassword',
// smtpTlsServername: 'smtp.example.com', // certificate name when smtpServer is an IP address
// smtpTlsCa: privateCaPem, // trust a private CA; verification stays on
});
// greeting, TLS and authentication, without sending
await smtp.verifyConnection();
TLS certificates are always verified. With smtpAccessToken instead of smtpPassword the client authenticates with SASL XOAUTH2 (Gmail, Microsoft 365); exactly one of the two must be given, and OAuth2 is refused on plain transports. Acquiring and refreshing tokens is the caller's job.
Smartsmtp.createSmartsmtpSendmail(command = '/usr/sbin/sendmail') delivers through the local sendmail binary instead.
SMARTSMTP_SECURE_RELAY_API_VERSION (1) marks the secure relay contract: TLS modes, trusted CA and server name, XOAUTH2, raw streaming and typed delivery outcomes.
Sending
sendMail renders an IMailMessage with the shared renderer and sends it to every To, Cc and Bcc recipient. to, cc and bcc take one address, a comma-separated string, or a list of addresses.
const result = await smtp.sendMail({
from: 'Me <me@example.com>',
to: ['recipient@example.com', { name: 'Jane', email: 'jane@example.com' }],
cc: 'copy@example.com',
replyTo: ['support@example.com'],
subject: 'Quarterly report',
text: 'Plain text',
html: '<p>HTML</p>',
headers: { 'X-Campaign-ID': 'q3' },
priority: 'high',
attachments: [{ filename: 'report.pdf', contentType: 'application/pdf', content: reportStream, expectedSizeBytes: reportSize }],
});
result.messageId; // '<…@example.com>'
result.accepted; // envelope recipients: ['recipient@example.com', 'jane@example.com', 'copy@example.com']
sendSmartMail(smartmail, data) sends a Smartmail with the template data applied: its To, Cc and Bcc, Reply-To, headers, priority, the plain-text and the HTML body, and the attachments.
await smtp.sendSmartMail(mail, { name: 'Jane', number: '42' });
sendRaw transports a message another component rendered, byte for byte apart from dot-stuffing and the DATA terminator, with an explicit envelope:
await smtp.sendRaw({
rawMessage: renderedMessageStream,
expectedSizeBytes: renderedMessageSize,
envelope: { mailFrom: 'bounce@example.com', rcptTo: ['actual-recipient@example.com'] },
});
When the server advertises SIZE, known message sizes are sent on MAIL FROM and messages over the limit are refused before DATA. SmartSmtpDeliveryError carries disposition and phase for safe retries: notAccepted proves the message was not accepted; uncertain means the DATA terminator write began or the final response was lost. Negative SMTP replies are SmartSmtpResponseError with code, command and response (secrets redacted).
IMAP
Watching a Mailbox (ImapClient)
import { ImapClient, type ImapClientConfig, type SmartImapMessage } from '@push.rocks/smartmail/imap';
const client = new ImapClient({
host: 'imap.example.com',
tlsMode: 'implicitTls', // or 'starttls', which is then mandatory
auth: { user: 'user@example.com', pass: 'password123' }, // or { user, accessToken } for XOAUTH2
mailbox: 'INBOX', // default
filter: { seen: false }, // imapflow search object; default unseen
});
client.on('message', (message: SmartImapMessage) => {
message.uid; message.flags; message.internalDate;
message.from[0]?.email; message.subject; message.text; message.attachments;
});
client.on('error', (error) => console.error(error));
client.on('connected', () => {});
client.on('disconnected', () => {});
await client.connect();
SmartImapMessage is the parsed message of parseMailMessage (see above) plus uid, flags and internalDate. The client sweeps the mailbox on connect, keeps IDLE running and emits every new message once; setFilter() changes the search. Operations are serialized so mailbox switches cannot race with fetching:
await client.listMailboxes(); // paths, names, delimiters, special-use flags, subscription
await client.getMailboxStatus('INBOX'); // messages, recent, uidNext, uidValidity, unseen, highestModseq
await client.updateFlags(42, ['\\Seen'], 'add', 'INBOX'); // 'add' | 'remove' | 'replace'
const moved = await client.moveMessage(42, 'Archive', 'INBOX'); // { moved, destinationUid }
await client.deleteMessage(7, 'Archive');
await client.appendMessage('Drafts', rawMessage, ['\\Draft'], new Date()); // { uid }
await client.disconnect();
There is no token refresh hook: an imapflow connection authenticates once. When an access token expires, create a new ImapClient with a fresh token. The legacy secure boolean is still accepted; tlsMode is preferred.
Read-Only Pulling (ImapReader)
A caller that pulls (a scanner every few minutes, a search from an app) uses ImapReader: one bounded step per call, nothing in the background, nothing changed on the server.
import { ImapReader, ImapReaderError } from '@push.rocks/smartmail/imap';
const reader = new ImapReader({
host: 'imap.example.com',
tlsMode: 'implicitTls',
auth: { user: 'invoices@example.com', pass: process.env.IMAP_PASSWORD! },
timeouts: { operationMs: 30_000 },
signal, // aborts everything and closes the reader
});
try {
await reader.connect();
const inbox = await reader.examine('INBOX'); // read-only; { uidValidity, uidNext, exists }
const uids = await reader.search('INBOX', { uidFrom: lastUid + 1 }, { uidValidity: inbox.uidValidity });
const summaries = await reader.fetchSummaries('INBOX', uids.slice(0, 100));
// summary.envelope: { date, subject, messageId, inReplyTo, from, sender, replyTo, to, cc, bcc: IMailAddress[] }
const pdf = await reader.downloadPart('INBOX', uids[0], '2', { maxBytes: 50 * 1024 * 1024 });
} catch (error) {
if (error instanceof ImapReaderError) console.log(error.code);
} finally {
await reader.close();
}
- Read-only: mailboxes are opened with EXAMINE and content is fetched with
BODY.PEEK, so reading never sets\Seen. - TLS only: implicit TLS, or STARTTLS that fails with
starttls_unavailablebefore any credential is sent. Certificates are always verified;tls.catrusts a private CA,tls.minVersionis TLSv1.2 or TLSv1.3, andtls.lookup/tls.servernameconnect to addresses the caller checked beforehand. - Search:
from,to,subject,text,body,since,beforeanduidFrom, combined with AND; UIDs come back ascending. - Summaries: flags, internal date, size, envelope and BODYSTRUCTURE of up to 1000 UIDs per call, without content. Group markers in ENVELOPE address lists carry no address and are left out.
- Content with a limit:
downloadPart/openPartdecode the transfer encoding,downloadRaw/openRawgive the stored bytes; more thanmaxBytesfails withtoo_large, content is never cut short silently. - UIDVALIDITY: pass the value of
examineasuidValidityand an operation on a recreated mailbox is refused withuidvalidity_changed. - Timeouts, abort, close:
connectMs,greetingMs,socketMsandoperationMs; a call that takes too long closes the reader withtimeout, an abort withcancelled.close()is idempotent. A reader connects once. - Errors (
ImapReaderError.code):dns_failed,connect_failed,timeout,tls_failed,certificate_invalid,starttls_unavailable,auth_failed,mailbox_missing,uidvalidity_changed,message_not_found,part_not_found,too_large,cancelled,closed,protocol_error.
IMAP Server
import { ImapServer, MemoryImapBackend, type IImapMailBackend } from '@push.rocks/smartmail/imap/server';
With no options, ImapServer serves an in-memory store, an offline server for tests:
const server = new ImapServer();
server.addUser('user@example.com', 'password123');
server.addOAuthToken('user@example.com', 'valid-access-token'); // AUTHENTICATE XOAUTH2
server.createInbox('user@example.com', 'INBOX');
const port = await server.start(0); // resolves with the bound port
await server.stop();
For production, ImapServer exposes an application mail store through a principal-scoped IImapMailBackend. A custom backend requires an authenticate callback and implicit TLS unless allowInsecureAuth is set for an isolated test; custom backends default to XOAUTH2 and OAUTHBEARER.
import { readFile } from 'node:fs/promises';
const server = new ImapServer({
backend,
hostname: 'imap.example.com',
tls: {
key: await readFile('/run/secrets/imap-tls-key.pem'),
cert: await readFile('/run/secrets/imap-tls-cert.pem'),
},
authenticate: async (request) => {
if (request.mechanism !== 'XOAUTH2' && request.mechanism !== 'OAUTHBEARER') return null;
const session = await validateAccessToken(request.username, request.secret);
return session ? { id: session.userId, username: request.username } : null;
},
maxConnections: 500,
maxLiteralBytes: 25 * 1024 * 1024,
});
await server.start(993, '0.0.0.0');
await server.stop(); // stops accepting and drains active sessions
IMAP_SERVER_BACKEND_API_VERSION (2) marks this backend contract. Every backend method receives the authenticated principal id; treat mailbox and message ids as untrusted input and enforce ownership in the backend. Persist stable, monotonically increasing UIDs per mailbox and never reuse a mailbox's uidValidity for a different UID history.
| Method | Required behavior |
|---|---|
listMailboxes / getMailboxByName |
Only principal-owned mailboxes with delimiter, attributes, subscription state, uidValidity and uidNext. |
getMessages |
The selected mailbox in stable ascending UID order; sequence numbers derive from it. |
appendMessage |
Persist the raw RFC 5322 bytes, flags, internal date and a newly allocated UID. |
updateFlags |
Add/remove/replace only on the requested principal/mailbox UIDs. |
copyMessages / moveMessages |
Allocate destination UIDs and return every source-to-destination mapping; a move also removes the source entries. |
expungeMessages |
Remove \Deleted messages, optionally restricted to UIDs, and return the removed UIDs. |
createMailbox / deleteMailbox / renameMailbox / setSubscribed |
Mailbox lifecycle and subscriptions with ownership checks. |
optional subscribeChanges |
Notify exists, flags, expunge or mailbox changes; may return an unsubscribe callback. |
Throw ImapBackendError for protocol-visible failures (ALREADYEXISTS, CANNOT, INUSE, LIMIT, NONEXISTENT, NOPERM, SERVERBUG, TRYCREATE). Operational options: authMechanisms, host, hostname, maxConnections, socketTimeoutMs, maxCommandBytes, maxLiteralBytes, maxQueuedCommands, maxAuthAttempts and onError.
JMAP
@push.rocks/smartmail/jmap ships two sides of the protocol (RFC 8620/8621):
JmapClientconnects to any JMAP server (Fastmail, Stalwart, Cyrus, ...), watches a mailbox for new mail in an event-driven way, and exposes the common mail operations: querying, reading, flagging, sending, uploads, and blob downloads.JmapServeris a JMAP server core: it implements the RFC 8620 request layer and the RFC 8621 mail method surface, and dispatches all storage into a pluggableIJmapMailBackend. With no options it runs on a bundled in-memory backend, which makes it an offline test server; with your own backend it exposes a real mail store to JMAP clients.
JMAP Client
Importing the Required Modules
import { JmapClient, type IJmapClientConfig } from '@push.rocks/smartmail/jmap';
Configuration Object
Point the client at the JMAP session resource — most servers expose it at the autodiscovery path https://host/.well-known/jmap. All URLs inside the session (API endpoint, upload/download URLs, event source) are resolved relative to this URL automatically.
const jmapConfig: IJmapClientConfig = {
sessionUrl: 'https://jmap.example.com/.well-known/jmap',
auth: {
accessToken: 'my-api-token',
},
mailbox: 'INBOX', // default: resolved via Mailbox role 'inbox', falling back to a name match
pollIntervalMs: 30000, // fallback polling cadence when the event stream is unavailable
// optional:
// fetch: guardedFetch, // every request goes through it; default globalThis.fetch
// credentialOrigins: ['https://download.example.com'], // strict mode: only these origins besides the session's
// timeoutMs: 30000, // default deadline per request; none when unset
};
When the session is loaded, every URL it advertises (apiUrl, downloadUrl, uploadUrl, eventSourceUrl) is resolved against the session URL and checked; the same check runs again before each request that carries the credentials:
- By default (RFC 8620 §2: the session belongs to the server you configured), the credentials go to the session URL's own origin and to any https origin the session advertises, so a provider with a separate download host works as is. Plain http is accepted only on the session URL's own origin.
- With
credentialOriginsset, strict mode applies: only the session URL's origin and the listed origins ([]means the session origin alone).
A URL outside the rule makes connect() report, and open() reject with, a JmapError of code untrustedUrl. Which addresses may be reached at all is the injected fetch's job, e.g. one that only connects to public addresses. Redirects follow the fetch in use: the standard fetch drops the Authorization header when a redirect leaves the origin.
Authentication: Bearer and Basic
The auth option accepts exactly one of two shapes:
import { JmapClient } from '@push.rocks/smartmail/jmap';
// Bearer token (OAuth2 access token or server API token)
const bearerClient = new JmapClient({
sessionUrl: 'https://jmap.example.com/.well-known/jmap',
auth: {
accessToken: 'my-api-token',
},
});
// Basic auth (username + password)
const basicClient = new JmapClient({
sessionUrl: 'https://jmap.example.com/.well-known/jmap',
auth: {
user: 'user@example.com',
pass: 'password123',
},
});
Passing both shapes at once (or neither) throws at construction time.
Connecting and Handling Events
connect() fetches and validates the JMAP session (the urn:ietf:params:jmap:core and urn:ietf:params:jmap:mail capabilities are required), resolves the target mailbox, and emits connected. It then emits a message event for every email currently in the mailbox and keeps watching for new mail: it prefers the JMAP event source (Server-Sent Events, RFC 8620 §7.3) and falls back to polling Email/changes on pollIntervalMs when the event stream is unavailable.
import { JmapClient, type ISmartJmapMessage } from '@push.rocks/smartmail/jmap';
const jmapClient = new JmapClient({
sessionUrl: 'https://jmap.example.com/.well-known/jmap',
auth: { accessToken: 'my-api-token' },
});
jmapClient.on('connected', () => {
console.log('Connected to the JMAP server');
});
jmapClient.on('message', (message: ISmartJmapMessage) => {
console.log('From:', message.from[0]?.email);
console.log('Subject:', message.subject);
console.log('Text:', message.textBody);
});
jmapClient.on('error', (error: Error) => {
console.error('JMAP error:', error);
});
jmapClient.on('disconnected', () => {
console.log('Disconnected');
});
await jmapClient.connect();
The Message Shape
Every message event (and the query/get helpers) delivers an ISmartJmapMessage with resolved bodies:
jmapClient.on('message', (message: ISmartJmapMessage) => {
message.id; // JMAP Email id
message.blobId; // blob id of the raw RFC 5322 message
message.threadId; // JMAP Thread id
message.mailboxIds; // { [mailboxId]: true }
message.keywords; // { '$seen': true, ... }
message.from; // IJmapEmailAddress[]
message.to; // IJmapEmailAddress[] (cc, bcc, replyTo likewise)
message.subject; // string | undefined
message.receivedAt; // ISO date string
message.textBody; // plain-text body, resolved from bodyValues
message.htmlBody; // HTML body, undefined when the email has no text/html part
message.attachments; // IJmapAttachment[]: { blobId, name, type, size }
message.raw; // the full raw JMAP Email object
});
Querying and Reading Mail
// All mailboxes of the account
const mailboxes = await jmapClient.getMailboxes();
// Emails in the watched mailbox, newest first (default filter)
const messages = await jmapClient.queryEmails();
// Custom JMAP filter (RFC 8621 §4.4.1) and limit
const invoices = await jmapClient.queryEmails({ subject: 'Invoice' }, 10);
// Single email with all body values fetched (fetchAllBodyValues)
const message = await jmapClient.getEmail(messages[0].id);
Flags and Keywords
// Convenience: set the $seen keyword
await jmapClient.markSeen(message.id);
// Mark unread, toggle flagged, move, or permanently destroy
await jmapClient.markUnseen(message.id);
await jmapClient.setFlagged(message.id, true);
await jmapClient.setMailboxes(message.id, ['archive-mailbox-id']);
// Replace the full keywords object
await jmapClient.setKeywords(message.id, { $seen: true, $flagged: true });
// Permanently destroy the email after any other mutations are complete
await jmapClient.destroyEmail(message.id);
Sending Mail
sendEmail looks up the account's identities via Identity/get, creates the email via Email/set (stored in the mailbox with role sent, falling back to drafts, then the watched mailbox), and submits it via EmailSubmission/set:
const result = await jmapClient.sendEmail({
to: [{ email: 'friend@example.com' }],
subject: 'Hello from JMAP',
textBody: 'Plain-text content',
htmlBody: '<p>HTML content</p>', // optional
});
console.log(result.emailId, result.submissionId);
When from is omitted, the first identity of the account is used.
Uploads and Attachments
uploadBlob POSTs raw bytes to the session's upload URL (RFC 8620 §6.1) and returns the stored blob's id, type, and size. Reference the blob id in sendEmail to attach it:
const upload = await jmapClient.uploadBlob(
new TextEncoder().encode('quarterly report data'),
'text/plain'
);
console.log(upload.blobId, upload.size);
await jmapClient.sendEmail({
to: [{ email: 'friend@example.com' }],
subject: 'Report attached',
textBody: 'Please find the report attached.',
attachments: [{ blobId: upload.blobId, type: 'text/plain', name: 'report.txt' }],
});
Downloading Blobs
Raw messages and attachments are blobs; download them via the session's download URL template:
// the raw RFC 5322 message
const rawBytes = await jmapClient.downloadBlob(message.blobId, 'message/rfc822', 'message.eml');
// an attachment
for (const attachment of message.attachments) {
const bytes = await jmapClient.downloadBlob(attachment.blobId, attachment.type, attachment.name);
console.log(attachment.name, bytes.length);
}
Raw JMAP Method Calls
For anything not covered by the helpers, request sends raw JMAP method calls (RFC 8620 §3.2) and returns the method responses:
const methodResponses = await jmapClient.request([
['Mailbox/query', { accountId: 'account-id', filter: { role: 'archive' } }, 'q0'],
]);
Disconnecting
await jmapClient.disconnect();
disconnect() tears down the event stream and any polling timer — no handles are left open — and emits disconnected.
Pull-Based Reading
For scanning and searching a mailbox, open() replaces connect(): it loads and validates the session and does nothing else. No mailbox is resolved, no mail is read, no event stream or timer is started, and no events are emitted. It rejects on failure (a JmapError with httpStatus, or a code such as missingCapability, noPrimaryAccount or untrustedUrl) and then holds nothing open. Every call below takes an optional signal and timeoutMs; a deadline rejects with code timeout, an abort rejects with the signal's reason, and disconnect() aborts whatever is in flight.
const reader = new JmapClient({
sessionUrl: 'https://jmap.example.com/jmap/session',
auth: { user: 'books@example.com', pass: 'app-password' },
fetch: guardedFetch, // e.g. a fetch that only connects to public addresses
});
const { accountId } = await reader.open({ timeoutMs: 15000 });
const mailboxes = await reader.getMailboxes();
const inbox = mailboxes.find((mailbox) => mailbox.role === 'inbox')!;
// one bounded page of ids, newest first
const page = await reader.queryEmailIds(
{ inMailbox: inbox.id, from: 'billing@', hasAttachment: true, after: '2026-09-01T00:00:00Z' },
{ limit: 50, position: 0 }
);
// only the chosen properties; no body values unless asked for (and then bounded)
const { list, state } = await reader.getEmails(page.ids, {
properties: ['messageId', 'from', 'subject', 'receivedAt', 'size', 'attachments', 'bodyStructure'],
});
// attachments with a hard byte limit: fails with code 'tooLarge', never truncates
const bytes = await reader.downloadBlob(blobId, 'application/pdf', 'invoice.pdf', {
maxBytes: 50 * 1024 * 1024,
});
const { stream } = await reader.openBlob(blobId, { maxBytes: 50 * 1024 * 1024 });
await reader.disconnect();
getEmailChanges(sinceState, { maxChanges }) runs one Email/changes call and returns { status: 'changes', oldState, newState, hasMoreChanges, created, updated, destroyed }. A message moved into a mailbox arrives in updated, so check mailboxIds of both created and updated. Continue from newState while hasMoreChanges is true. An unknown or expired state yields { status: 'cannotCalculateChanges', sinceState }, which means: re-read from a fresh state. Persisting the state is the caller's job; getEmails([], { properties: ['id'] }) returns the current one.
The limits and deadlines:
queryEmailIdsrequires a positivelimit;getEmailChangesrequires a positivemaxChanges.getEmailsfetches body values only withfetchTextBodyValues/fetchHTMLBodyValuesand a positivemaxBodyValueBytes.downloadBlobwithmaxBytesrefuses a larger declaredContent-Lengthon an unencoded response before reading, and otherwise counts the bytes as they arrive. When the response has aContent-Encodingother thanidentity(for example gzip), the header counts the compressed bytes while fetch delivers the decoded body, so the header is not used: the running count of decoded bytes is the only limit, andopenBlobomitscontentLength.openBlobdoes the same and errors the stream past the limit; its deadline covers the whole transfer, and cancelling the stream releases it.
JMAP Server
JmapServer handles the protocol; storage is behind the IJmapMailBackend interface. Endpoints served: the session resource (/.well-known/jmap and /jmap/session), the API (/jmap/api), uploads (/jmap/upload/{accountId}), downloads (/jmap/download/{accountId}/{blobId}/{name}?type=), and StateChange pushes via SSE (/jmap/eventsource, RFC 8620 §7.3 with types, closeafter=state, and ping keepalive support).
Production consumers that rely on bounded streaming uploads can verify the server surface before starting a listener:
import { JMAP_SERVER_STREAMING_API_VERSION } from '@push.rocks/smartmail/jmap';
if (JMAP_SERVER_STREAMING_API_VERSION !== 1) {
throw new Error('This application requires the JMAP streaming server API v1.');
}
The Offline Test Server
With no options, JmapServer uses a fresh MemoryMailBackend plus a static credential registry — an offline JMAP server for tests. Seed users on the server (auth) and mail on the backend (storage):
import { JmapClient, JmapServer, MemoryMailBackend } from '@push.rocks/smartmail/jmap';
const backend = new MemoryMailBackend();
const jmapServer = new JmapServer({ backend });
jmapServer.addUser('testuser', 'testpass');
jmapServer.addBearerToken('testuser', 'test-token');
backend.createMailbox('testuser', 'INBOX', 'inbox');
backend.addEmail('testuser', 'INBOX', {
from: { email: 'alice@example.com' },
to: { email: 'testuser@example.com' },
subject: 'Welcome',
textBody: 'Hello from the test server!',
});
const port = await jmapServer.start(0); // node:http wrapper; resolves with the bound port
const client = new JmapClient({
sessionUrl: `http://127.0.0.1:${port}/.well-known/jmap`,
auth: { accessToken: 'test-token' },
});
client.on('message', (message) => console.log(message.subject));
await client.connect();
// Backend mutations outside a JMAP request (e.g. seeding, an IMAP bridge)
// flow through the change feed and are pushed to clients via SSE:
backend.addEmail('testuser', 'INBOX', {
from: { email: 'bob@example.com' },
to: { email: 'testuser@example.com' },
subject: 'Live push',
textBody: 'Delivered through the event stream.',
});
await client.disconnect();
await jmapServer.stop(); // closes event streams, timers, and open connections
An attachment seeded through addEmail is an attachment part by default. Give it disposition: 'inline' for a picture pasted into the mail, and cid (the Content-ID without angle brackets) for a part the HTML body refers to as cid:; both appear in the body parts and in the raw message:
backend.addEmail('testuser', 'INBOX', {
from: { email: 'billing@example.com' },
to: { email: 'testuser@example.com' },
subject: 'Invoice with logo',
htmlBody: '<p>See the invoice.</p><img src="cid:logo-1@example.com">',
attachments: [
{ name: 'logo.png', type: 'image/png', content: logoBytes, disposition: 'inline', cid: 'logo-1@example.com' },
{ name: 'invoice.pdf', type: 'application/pdf', content: pdfBytes },
],
});
To test a client that insists on https:, start the wrapper as a node:https server with a private key and certificate chain in PEM, for example issued by a test-only CA the client is told to trust:
const securePort = await jmapServer.start(0, {
tls: { key: fs.readFileSync('server-key.pem', 'utf8'), cert: fs.readFileSync('server-cert.pem', 'utf8') },
});
// the session is at https://127.0.0.1:${securePort}/.well-known/jmap
Mounting fetchHandler (Deno, Bun, anywhere)
fetchHandler(request: Request): Promise<Response> is the primary API and handles every endpoint, including the SSE event source (served as a ReadableStream response). start(port) is only a node:http (or, with { tls }, node:https) adapter around it. In a Deno app:
// deno run --allow-net server.ts
import { JmapServer, MemoryMailBackend } from '@push.rocks/smartmail/jmap';
const backend = new MemoryMailBackend();
const jmapServer = new JmapServer({ backend, baseUrl: 'http://localhost:8080' });
jmapServer.addUser('demo', 'demopass');
backend.createMailbox('demo', 'INBOX', 'inbox');
Deno.serve({ port: 8080 }, (request) => jmapServer.fetchHandler(request));
baseUrl makes the URLs advertised in the session object absolute; without it they are relative and JMAP clients resolve them against the session URL.
Custom Authentication
The authenticate option replaces the built-in Basic/Bearer registry entirely. It receives the raw Request and returns a principal (or null for 401); the backend then maps the principal to an account via resolveAccount:
import { JmapServer } from '@push.rocks/smartmail/jmap';
const jmapServer = new JmapServer({
authenticationChallenges: ['Bearer realm="jmap"'],
authenticate: async (request: Request) => {
const match = request.headers.get('authorization')?.match(/^Bearer\s+(.+)$/i);
const token = match?.[1];
return token === 'sesame' ? { username: 'appuser' } : null;
},
});
Set authenticationChallenges to the exact schemes accepted by a custom
authenticator. The built-in registry advertises both Bearer and Basic by
default.
Implementing a Real Backend
Implement IJmapMailBackend against your own store to serve real mail. All state strings are backend-owned opaque strings. The contract (all methods async except subscribeToChanges, which returns its unsubscribe function synchronously):
| Method | Serves |
|---|---|
resolveAccount(principal) |
principal → account mapping for the session |
getMailboxes(accountId, ids) / getMailboxChanges(accountId, sinceState, maxChanges?) |
Mailbox/get, Mailbox/query, Mailbox/changes |
getEmails(accountId, ids, properties?) |
Email/get (the server applies projection, bodyProperties and body-value fetch flags; give each body part its headers, name and Raw value in message order, for the headers and header: body properties) |
queryEmails(accountId, options) |
Email/query (filter subset, receivedAt sort, position/limit/total) |
getEmailChanges(accountId, sinceState, maxChanges?) |
Email/changes (return null for cannotCalculateChanges) |
setEmails(accountId, request) |
Email/set (creates, normalized keyword/mailbox updates, destroys) |
getThreads(accountId, ids) |
Thread/get |
uploadBlob(accountId, data, type) / optional uploadBlobStream(accountId, upload) / getBlob(accountId, blobId) |
buffered or streaming upload, download endpoints, and raw-message access |
getIdentities(accountId) |
Identity/get |
submitEmail(accountId, submission) |
EmailSubmission/set — receives the resolved envelope plus the raw RFC 5322 payload; your backend performs the actual sending |
subscribeToChanges(listener) |
StateChange pushes — the server's only push feed, so notify on all mutations, including those made through JMAP requests, not just out-of-band changes |
optional features |
declares emailFilters: ['hasAttachment'] and emailProperties: ['bodyStructure'] when the backend implements them; undeclared ones are answered with unsupportedFilter / invalidArguments |
import { JmapServer, type IJmapMailBackend } from '@push.rocks/smartmail/jmap';
declare const myBackend: IJmapMailBackend; // your implementation
const jmapServer = new JmapServer({
backend: myBackend,
baseUrl: 'https://mail.example.com',
});
MemoryMailBackend is the reference implementation — a readable starting point for the expected semantics.
When uploadBlobStream is implemented, the server authenticates and admits the
request before reading, requires Content-Length, and passes stream, exact
size, media type, and an abort signal. The backend must consume exactly
size bytes, reserve quota before reading the stream, reject short or oversized
input, honor cancellation, and remove partial storage on failure.
What the Server Advertises (and What It Doesn't)
The session object advertises urn:ietf:params:jmap:core, urn:ietf:params:jmap:mail, and urn:ietf:params:jmap:submission. The core limits are enforced, not just advertised (exported as JMAP_SERVER_LIMITS): maxSizeRequest 10 MB, maxCallsInRequest 16, maxObjectsInGet/maxObjectsInSet 500, maxSizeUpload 50 MB, maxConcurrentRequests 4, and per-principal maxConcurrentUpload 4 — violations produce request-level limit errors, requestTooLarge method errors, or upload problem details. Constructor limits override advertised limits; maxConcurrentUploadServer adds a process-wide upload ceiling, and mapUploadError maps storage failures to application-specific RFC 7807 responses. maxDelayedSend is 0 (no delayed send) and mayCreateTopLevelMailbox is false (no Mailbox/set).
The request layer implements using validation (unknownCapability), #-prefixed back-references with full ResultReference { resultOf, name, path } JSON-pointer resolution including /* array expansion, client-provided createdIds maps, and unknownMethod/invalidArguments/serverFail error responses.
Dispatched methods: Core/echo, Mailbox/get, Mailbox/query, Mailbox/changes, Thread/get, Email/get (property projection, bodyProperties for the parts in bodyStructure/textBody/htmlBody/attachments with RFC 8621's default list when omitted, bodyStructure always the full tree with its parts projected alike, subParts: null on a non-multipart part when asked for, headers and the header:{name}[:as{form}][:all] properties in every RFC 8621 §4.1.2 form (from the part's headers as the backend holds them, null without), and unknown body properties or forbidden forms refused with invalidArguments, bodyStructure when the backend declares it, fetchTextBodyValues/fetchHTMLBodyValues/fetchAllBodyValues, maxBodyValueBytes truncation), Email/query (filters: inMailbox, text, subject, from, to, before, after, hasKeyword, notKeyword, and hasAttachment when the backend declares it; receivedAt sort; position/limit/calculateTotal), Email/changes, Email/set (create/update/destroy with RFC 8620 §5.3 JSON-pointer patches for keywords/* and mailboxIds/*, plus ifInState), Identity/get, and EmailSubmission/set (with onSuccessUpdateEmail/onSuccessDestroyEmail and the implicit Email/set response).
Not implemented in this phase: Email/queryChanges (answered with an explicit cannotCalculateChanges error), Mailbox/set, Email/copy/Email/import/Email/parse, SearchSnippet/*, VacationResponse/*, PushSubscription (RFC 8620 §7.2 — the §7.3 event source is the push channel), FilterOperator trees (AND/OR/NOT), and anchor pagination.
JMAP Error Handling
connect() never throws; failures (unreachable host, wrong credentials, missing capabilities) are emitted as error events, and the instance holds no open sockets or timers afterwards. open() rejects instead. HTTP problem details (RFC 7807) and JMAP method-level errors are surfaced as JmapError with httpStatus, problemType, problemDetail, jmapErrorType, and jmapErrorDescription fields. Client-side failures carry code: timeout, tooLarge, untrustedUrl, missingCapability, noPrimaryAccount or invalidResponse.
Because JMAP is stateless HTTP, the same client instance can reconnect — call connect() again after a failure. Already-emitted messages are deduplicated, so a reconnect does not re-emit mail the client has already seen. While connected, the client recovers on its own: if the event stream drops, it silently falls back to Email/changes polling, and a cannotCalculateChanges response triggers a full re-query.
import { JmapClient, JmapError } from '@push.rocks/smartmail/jmap';
const client = new JmapClient({
sessionUrl: 'https://jmap.example.com/.well-known/jmap',
auth: { accessToken: 'my-api-token' },
});
client.on('error', (error: Error) => {
if (error instanceof JmapError && error.httpStatus === 401) {
console.error('Credentials rejected — refresh the token before reconnecting.');
return;
}
console.error('JMAP error, retrying in 10s:', error.message);
setTimeout(() => {
client.connect().catch(console.error);
}, 10000);
});
await client.connect();
Wire Protocol
@push.rocks/smartmail/wire connects an application (the SaaS side) with a mail service over JSON: WireTarget sends requests, WireParser handles them on the service side. Messages carry a Smartmail as ISmartmailJson (attachments base64).
import { Smartmail } from '@push.rocks/smartmail';
import { WireTarget, WireParser, createMessageId, createTimestamp } from '@push.rocks/smartmail/wire';
const target = new WireTarget({ endpoint: 'https://mail-service.example.com/api/wire', authToken: 'secret' });
await target.updateSettings({ defaultFrom: 'noreply@example.com', customSetting: 'value' });
const response = await target.sendEmail(
new Smartmail({ from: 'sender@example.com', to: ['user@example.com'], subject: 'Hello', body: 'Welcome!' }),
);
const status = await target.getStatus(response.deliveryId!);
const inbox = await target.listMailbox('INBOX', { limit: 10, offset: 0 });
const fetched = await target.fetchEmail('INBOX', 'email-id-123'); // Smartmail | null
const parser = new WireParser({
async onMailSend(email) {
await smtp.sendSmartMail(email);
return { type: 'mail.send.response', messageId: createMessageId(), timestamp: createTimestamp(), success: true, deliveryId: 'd-1' };
},
// onSettingsUpdate, onMailboxList, onMailFetch, onMailStatus
});
const responseJson = await parser.parseAndHandle(requestJson);
The message types (IWireMessage, IMailSendRequest/Response, IMailboxListRequest/Response, IMailFetchRequest/Response, IMailStatusRequest/Response, ISettingsUpdateRequest/Response, IWireSettings, ISmtpSettings, TWireMessage) are exported from the same entry point. A request without a handler is answered with success: false.
Migration
From @push.rocks/smartmail 2.x
| 2.x | 3.0 |
|---|---|
import { EmailAddressValidator } from '@push.rocks/smartmail' |
import { EmailAddressValidator } from '@push.rocks/smartmail/validation' |
new EmailAddressValidator({ skipOnlineDomainFetch: true }) |
new EmailAddressValidator(): the bundled list is the default and nothing is fetched |
| online domain list fetched on first validation, silently falling back to the bundled list | await validator.refreshDomainList(url?), opt-in; failures throw |
validator.fetchDomains() |
removed; the bundled list loads on first use, refreshDomainList() replaces it |
validator.domainMap always set after validation |
domainMap?: TEmailDomainMap, undefined until first use |
import { WireTarget, WireParser, createMessageId, createTimestamp, IMailSendRequest, … } from '@push.rocks/smartmail' |
the same names from '@push.rocks/smartmail/wire' |
smartmail.sendTo(wireTarget) |
wireTarget.sendEmail(smartmail) |
smartmail.addAttachment(smartfile) (a SmartFile) |
smartmail.addAttachment({ filename, contentType, content }) (IMailAttachment); attachments is IMailAttachment[] |
attachment.contentBuffer (after fromObject) |
attachment.content (a Uint8Array) |
await smartmail.toMimeFormat(data) (nodemailer-shaped object) |
smartmail.toMailMessage(data) (IMailMessage), rendered with renderMailMessage() or sent with Smartsmtp.sendMail() |
validateEmails option and validateAllEmails() |
removed from Smartmail; validate addresses with EmailAddressValidator |
IMimeAttachment |
removed; IMailAttachment |
getCreationObject(): T |
getCreationObject(): T | undefined |
From @push.rocks/smartsmtp
| smartsmtp 4.x | smartmail 3.0 |
|---|---|
import { Smartsmtp, … } from '@push.rocks/smartsmtp' |
import { Smartsmtp, … } from '@push.rocks/smartmail/smtp' |
sendSmartMail(smartmail, toArg, data): sent the text body as HTML, only to toArg, without Cc, Bcc, Reply-To, headers or priority |
sendSmartMail(smartmail, data): the Smartmail's own To, Cc, Bcc, Reply-To, headers, priority, both bodies and attachments. Put the recipient on the Smartmail (addRecipient(toArg)) |
sendMail result accepted: the to/cc/bcc strings as given ('Jane <jane@example.com>') |
accepted: the envelope addresses ('jane@example.com') |
ISmartSmtpSendMailOptions.to/cc/bcc: string | string[] |
TSmartSmtpRecipients: an address string, a comma-separated string, IMailAddress, or a list of them; plus replyTo, priority, messageId, date |
ISmartSmtpAttachment, TSmartSmtpByteSource, TSmartSmtpRawMessage |
IMailAttachment, TMailByteSource from @push.rocks/smartmail |
| non-ASCII subjects and display names sent as raw UTF-8; text lines over 998 octets sent as they were | RFC 2047 encoded words and folded headers; such bodies are sent quoted-printable |
attachment name*/filename* always written |
written only for names that are not ASCII |
Unchanged: createSmartsmtpWithRelay, createSmartsmtpSendmail, verifyConnection, sendRaw, ISmartSmtpSendRawOptions, ISmartSmtpSendMailResult, TSmartSmtpRelayOptions, SmartSmtpDeliveryError, SmartSmtpResponseError, SMARTSMTP_SECURE_RELAY_API_VERSION.
From @push.rocks/smartimap
| smartimap 2.x | smartmail 3.0 |
|---|---|
ImapClient, ImapReader, ImapReaderError, ImapClientConfig, SmartImapMessage, IImapReader* from '@push.rocks/smartimap' |
the same names from '@push.rocks/smartmail/imap' |
ImapServer, MemoryImapBackend, IImapMailBackend, ImapBackendError, IMAP_SERVER_BACKEND_API_VERSION, IImap* backend types from '@push.rocks/smartimap' |
the same names from '@push.rocks/smartmail/imap/server' |
IImapReaderAddress { name?, address? } in IImapReaderEnvelope |
IMailAddress { email, name? }; ENVELOPE group markers (no address) are left out |
SmartImapMessage = ParsedMail & { uid, flags, internalDate } (mailparser) |
SmartImapMessage = IParsedMailMessage & { uid, flags, internalDate } (postal-mime) |
SmartImapMessage fields, old (mailparser ParsedMail) → new (IParsedMailMessage):
| mailparser | smartmail 3.0 |
|---|---|
from?: AddressObject ({ value: [{ address, name }], text, html }) |
from: IMailAddress[] ([{ email, name? }]) |
to, cc, bcc: AddressObject | AddressObject[] | undefined |
IMailAddress[], groups flattened, empty when absent |
replyTo?: AddressObject |
replyTo: IMailAddress[] |
sender? (not parsed) |
sender?: IMailAddress |
headers: Map<string, HeaderValue> (structured values) |
headers: { key, name, value }[] (unfolded raw values, message order) |
headerLines: { key, line }[] |
headerLines: { key, line }[] (unchanged) |
subject?: string |
subject?: string (unchanged) |
messageId?: string |
messageId?: string (unchanged, with angle brackets) |
inReplyTo?: string |
inReplyTo?: string (unchanged) |
references?: string | string[] |
references: string[] ('<id>' each, empty when absent) |
date?: Date |
date?: Date (unchanged) |
text?: string |
text?: string; no text is derived from the HTML when the message has no text part |
html: string | false |
html?: string |
textAsHtml?: string |
removed |
priority?: 'normal' | 'low' | 'high' |
removed; read X-Priority / Importance from headers |
attachments: Attachment[] (filename, contentType, content: Buffer, size, contentId, cid, contentDisposition, related, checksum, headers) |
attachments: { filename?, contentType, disposition?, contentId?, content: Uint8Array, size }[]; contentId without angle brackets; cid, related, checksum, headers removed |
The package no longer depends on mailparser; parse raw messages with parseMailMessage from @push.rocks/smartmail.
From @push.rocks/smartjmap
| smartjmap 3.x | smartmail 3.0 |
|---|---|
every export of '@push.rocks/smartjmap' |
the same names from '@push.rocks/smartmail/jmap' |
MemoryMailBackend raw messages |
rendered through the shared MIME renderer and byte-identical to smartjmap 3.2.0, except: subjects that are not ASCII become RFC 2047 encoded words, line breaks in multipart text parts become CRLF, text parts with lines over 998 octets become quoted-printable, quotes in attachment names are escaped rather than dropped, and names that are not ASCII gain RFC 2231 parameters |
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.