|
|
@@ -12,6 +12,7 @@ import {
|
|
|
softDeleteInboundMessages,
|
|
|
verifyInboundMailboxCredential
|
|
|
} from './db.js';
|
|
|
+import { parseInboundMessage } from './inbound-mail.js';
|
|
|
|
|
|
export function startMailboxAccessServers(config) {
|
|
|
const tlsMaterial = loadTlsMaterial(config);
|
|
|
@@ -81,7 +82,7 @@ class ImapSession {
|
|
|
constructor(socket, config) {
|
|
|
this.socket = socket;
|
|
|
this.config = config;
|
|
|
- this.buffer = '';
|
|
|
+ this.buffer = Buffer.alloc(0);
|
|
|
this.authenticated = false;
|
|
|
this.user = null;
|
|
|
this.mailbox = null;
|
|
|
@@ -91,34 +92,46 @@ class ImapSession {
|
|
|
this.deletedUids = new Set();
|
|
|
this.authContinuation = null;
|
|
|
this.pendingAppend = null;
|
|
|
+ this.appendProcessing = false;
|
|
|
this.idleTag = '';
|
|
|
this.onDataBound = (chunk) => this.onData(chunk);
|
|
|
- socket.setEncoding('utf8');
|
|
|
socket.on('data', this.onDataBound);
|
|
|
socket.on('error', () => null);
|
|
|
this.write(`* OK ${config.hostname} MailHub IMAP ready`);
|
|
|
}
|
|
|
|
|
|
onData(chunk) {
|
|
|
- this.buffer += chunk;
|
|
|
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''), 'utf8');
|
|
|
+ if (incoming.length) this.buffer = Buffer.concat([this.buffer, incoming]);
|
|
|
+ if (this.appendProcessing) return;
|
|
|
while (true) {
|
|
|
if (this.pendingAppend) {
|
|
|
- const literal = takeUtf8Literal(this.buffer, this.pendingAppend.bytes);
|
|
|
+ const literal = takeLiteralBytes(this.buffer, this.pendingAppend.bytes);
|
|
|
if (!literal) return;
|
|
|
this.buffer = literal.rest;
|
|
|
- if (this.buffer.startsWith('\r\n')) this.buffer = this.buffer.slice(2);
|
|
|
- else if (this.buffer.startsWith('\n')) this.buffer = this.buffer.slice(1);
|
|
|
+ if (this.buffer[0] === 0x0d && this.buffer[1] === 0x0a) this.buffer = this.buffer.subarray(2);
|
|
|
+ else if (this.buffer[0] === 0x0a) this.buffer = this.buffer.subarray(1);
|
|
|
const pending = this.pendingAppend;
|
|
|
this.pendingAppend = null;
|
|
|
- this.finishAppend(pending, literal.value);
|
|
|
- continue;
|
|
|
+ this.appendProcessing = true;
|
|
|
+ void this.finishAppend(pending, literal.value)
|
|
|
+ .catch((error) => {
|
|
|
+ console.error(`MailHub IMAP APPEND failed: ${error.message || error}`);
|
|
|
+ this.write(`${pending.tag} NO APPEND failed`);
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ this.appendProcessing = false;
|
|
|
+ this.onData('');
|
|
|
+ });
|
|
|
+ return;
|
|
|
}
|
|
|
|
|
|
- const index = this.buffer.indexOf('\n');
|
|
|
+ const index = this.buffer.indexOf(0x0a);
|
|
|
if (index === -1) return;
|
|
|
- const line = this.buffer.slice(0, index).replace(/\r$/, '');
|
|
|
- this.buffer = this.buffer.slice(index + 1);
|
|
|
- this.onLine(line);
|
|
|
+ let line = this.buffer.subarray(0, index);
|
|
|
+ this.buffer = this.buffer.subarray(index + 1);
|
|
|
+ if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
|
|
|
+ this.onLine(line.toString('utf8'));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -284,17 +297,14 @@ class ImapSession {
|
|
|
this.write('+ Ready for literal data');
|
|
|
}
|
|
|
|
|
|
- finishAppend(pending, rawMessage) {
|
|
|
- const normalizedRaw = normalizeRawMessage({ rawMessage });
|
|
|
- const headers = parseMessageHeaders(normalizedRaw);
|
|
|
+ async finishAppend(pending, rawMessage) {
|
|
|
+ const parsedMessage = await parseInboundMessage(rawMessage);
|
|
|
+ const normalizedRaw = normalizeRawMessage(parsedMessage);
|
|
|
const message = createInboundMessage(this.mailbox, {
|
|
|
+ ...parsedMessage,
|
|
|
folder: pending.folder,
|
|
|
- sender: extractFirstEmail(headers.from) || headers.from || '',
|
|
|
- recipients: extractEmailAddresses(headers.to),
|
|
|
- subject: headers.subject || '(no subject)',
|
|
|
- messageId: headers['message-id'] || '',
|
|
|
rawMessage: normalizedRaw,
|
|
|
- textBody: bodyBlock(normalizedRaw).trim()
|
|
|
+ rawMessageBytes: rawMessage
|
|
|
});
|
|
|
if (pending.flags.has('\\SEEN')) markInboundMessageRead(this.mailbox.userId, message.id, true);
|
|
|
this.write(`${pending.tag} OK APPEND completed`);
|
|
|
@@ -341,7 +351,10 @@ class ImapSession {
|
|
|
this.write(`* ${entry.seq} FETCH (${attrs.join(' ')})`);
|
|
|
return;
|
|
|
}
|
|
|
- const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${Buffer.byteLength(literal.value, 'utf8')}}`].join(' ')}\r\n`;
|
|
|
+ const literalBytes = Buffer.isBuffer(literal.value)
|
|
|
+ ? literal.value.length
|
|
|
+ : Buffer.byteLength(literal.value, 'utf8');
|
|
|
+ const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${literalBytes}}`].join(' ')}\r\n`;
|
|
|
this.socket.write(prefix);
|
|
|
this.socket.write(literal.value);
|
|
|
this.socket.write('\r\n)\r\n');
|
|
|
@@ -406,9 +419,8 @@ class ImapSession {
|
|
|
secureContext: this.config.secureContext
|
|
|
});
|
|
|
this.socket = secureSocket;
|
|
|
- this.buffer = '';
|
|
|
+ this.buffer = Buffer.alloc(0);
|
|
|
this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
|
|
|
- secureSocket.setEncoding('utf8');
|
|
|
secureSocket.on('data', this.onDataBound);
|
|
|
secureSocket.on('error', () => null);
|
|
|
}
|
|
|
@@ -522,17 +534,17 @@ class Pop3Session {
|
|
|
|
|
|
stat() {
|
|
|
const active = this.activeMessages();
|
|
|
- this.write(`+OK ${active.length} ${active.reduce((total, item) => total + messageBytes(item.message), 0)}`);
|
|
|
+ this.write(`+OK ${active.length} ${active.reduce((total, item) => total + pop3MessageBytes(item.message), 0)}`);
|
|
|
}
|
|
|
|
|
|
list(rest) {
|
|
|
if (rest) {
|
|
|
const entry = this.messageByNumber(rest);
|
|
|
if (!entry) return this.write('-ERR No such message');
|
|
|
- return this.write(`+OK ${entry.index} ${messageBytes(entry.message)}`);
|
|
|
+ return this.write(`+OK ${entry.index} ${pop3MessageBytes(entry.message)}`);
|
|
|
}
|
|
|
this.write('+OK Message list follows');
|
|
|
- for (const entry of this.activeMessages()) this.write(`${entry.index} ${messageBytes(entry.message)}`);
|
|
|
+ for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3MessageBytes(entry.message)}`);
|
|
|
this.write('.');
|
|
|
}
|
|
|
|
|
|
@@ -550,9 +562,10 @@ class Pop3Session {
|
|
|
retr(rest) {
|
|
|
const entry = this.messageByNumber(rest);
|
|
|
if (!entry) return this.write('-ERR No such message');
|
|
|
- const rawMessage = normalizeRawMessage(entry.message);
|
|
|
- this.write(`+OK ${Buffer.byteLength(rawMessage, 'utf8')} octets`);
|
|
|
- this.socket.write(`${dotStuff(rawMessage)}\r\n.\r\n`);
|
|
|
+ const rawMessage = pop3RawMessageBytes(entry.message);
|
|
|
+ this.write(`+OK ${rawMessage.length} octets`);
|
|
|
+ this.socket.write(dotStuffBytes(rawMessage));
|
|
|
+ this.socket.write('.\r\n');
|
|
|
}
|
|
|
|
|
|
top(rest) {
|
|
|
@@ -560,9 +573,13 @@ class Pop3Session {
|
|
|
const entry = this.messageByNumber(messageNumber);
|
|
|
if (!entry) return this.write('-ERR No such message');
|
|
|
const lineCount = Math.max(0, Number(lineCountRaw || 0) || 0);
|
|
|
- const preview = topLines(normalizeRawMessage(entry.message), lineCount);
|
|
|
+ const preview = Buffer.from(
|
|
|
+ topLines(pop3RawMessageBytes(entry.message).toString('latin1'), lineCount),
|
|
|
+ 'latin1'
|
|
|
+ );
|
|
|
this.write('+OK Top of message follows');
|
|
|
- this.socket.write(`${dotStuff(preview)}\r\n.\r\n`);
|
|
|
+ this.socket.write(dotStuffBytes(ensureTrailingCrlf(preview)));
|
|
|
+ this.socket.write('.\r\n');
|
|
|
}
|
|
|
|
|
|
dele(rest) {
|
|
|
@@ -681,16 +698,13 @@ function tokenizeImap(value) {
|
|
|
return tokens;
|
|
|
}
|
|
|
|
|
|
-function takeUtf8Literal(input, byteCount) {
|
|
|
- let bytes = 0;
|
|
|
- let end = 0;
|
|
|
- for (const char of String(input || '')) {
|
|
|
- bytes += Buffer.byteLength(char, 'utf8');
|
|
|
- end += char.length;
|
|
|
- if (bytes === byteCount) return { value: input.slice(0, end), rest: input.slice(end) };
|
|
|
- if (bytes > byteCount) return null;
|
|
|
- }
|
|
|
- return byteCount === 0 ? { value: '', rest: input } : null;
|
|
|
+function takeLiteralBytes(input, byteCount) {
|
|
|
+ const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || '');
|
|
|
+ if (buffer.length < byteCount) return null;
|
|
|
+ return {
|
|
|
+ value: buffer.subarray(0, byteCount),
|
|
|
+ rest: buffer.subarray(byteCount)
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
function parseMessageHeaders(rawMessage) {
|
|
|
@@ -710,14 +724,6 @@ function parseMessageHeaders(rawMessage) {
|
|
|
return headers;
|
|
|
}
|
|
|
|
|
|
-function extractEmailAddresses(value) {
|
|
|
- return String(value || '').match(/[^\s<>,;"]+@[^\s<>,;"]+/g) || [];
|
|
|
-}
|
|
|
-
|
|
|
-function extractFirstEmail(value) {
|
|
|
- return extractEmailAddresses(value)[0] || '';
|
|
|
-}
|
|
|
-
|
|
|
function splitFirst(value) {
|
|
|
const input = String(value || '').trim();
|
|
|
const index = input.search(/\s/);
|
|
|
@@ -777,16 +783,17 @@ function resolveSetValue(value, messages, byUid) {
|
|
|
}
|
|
|
|
|
|
function resolveFetchLiteral(items, message) {
|
|
|
- const raw = normalizeRawMessage(message);
|
|
|
- if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: raw };
|
|
|
- if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: headerBlock(raw) };
|
|
|
- if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: bodyBlock(raw) };
|
|
|
+ const rawBytes = exactRawMessageBytes(message);
|
|
|
+ const raw = rawBytes.toString('latin1');
|
|
|
+ if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: rawBytes };
|
|
|
+ if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: Buffer.from(headerBlock(raw), 'latin1') };
|
|
|
+ if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: Buffer.from(bodyBlock(raw), 'latin1') };
|
|
|
const bodyMatch = String(items || '').match(/BODY(?:\.PEEK)?\[([^\]]*)\]/i);
|
|
|
if (!bodyMatch) return null;
|
|
|
const section = bodyMatch[1] || '';
|
|
|
return {
|
|
|
label: `BODY[${section}]`,
|
|
|
- value: bodySection(raw, section)
|
|
|
+ value: section ? Buffer.from(bodySection(raw, section), 'latin1') : rawBytes
|
|
|
};
|
|
|
}
|
|
|
|
|
|
@@ -807,7 +814,7 @@ function bodySection(raw, section) {
|
|
|
}
|
|
|
|
|
|
function imapBodyStructure(message) {
|
|
|
- return imapMimeNodeStructure(parseMimeNode(normalizeRawMessage(message)));
|
|
|
+ return imapMimeNodeStructure(parseMimeNode(exactRawMessageBytes(message).toString('latin1')));
|
|
|
}
|
|
|
|
|
|
function imapMimeNodeStructure(node) {
|
|
|
@@ -822,7 +829,7 @@ function imapMimeNodeStructure(node) {
|
|
|
imapNString(node.headers['content-id'] || ''),
|
|
|
imapNString(node.headers['content-description'] || ''),
|
|
|
imapNString(node.encoding.toUpperCase()),
|
|
|
- String(Buffer.byteLength(node.body, 'utf8'))
|
|
|
+ String(Buffer.byteLength(node.body, 'latin1'))
|
|
|
];
|
|
|
if (node.contentType.primary === 'text') values.push(String(imapLineCount(node.body)));
|
|
|
return `(${values.join(' ')})`;
|
|
|
@@ -982,7 +989,45 @@ function uidNext(messages) {
|
|
|
}
|
|
|
|
|
|
function messageBytes(message) {
|
|
|
- return Buffer.byteLength(normalizeRawMessage(message), 'utf8');
|
|
|
+ return exactRawMessageBytes(message).length;
|
|
|
+}
|
|
|
+
|
|
|
+function pop3MessageBytes(message) {
|
|
|
+ return pop3RawMessageBytes(message).length;
|
|
|
+}
|
|
|
+
|
|
|
+function pop3RawMessageBytes(message) {
|
|
|
+ const normalized = exactRawMessageBytes(message)
|
|
|
+ .toString('latin1')
|
|
|
+ .replace(/\r?\n/g, '\r\n');
|
|
|
+ return ensureTrailingCrlf(Buffer.from(normalized, 'latin1'));
|
|
|
+}
|
|
|
+
|
|
|
+function ensureTrailingCrlf(value) {
|
|
|
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
|
|
|
+ return buffer.length >= 2 && buffer.at(-2) === 0x0d && buffer.at(-1) === 0x0a
|
|
|
+ ? buffer
|
|
|
+ : Buffer.concat([buffer, Buffer.from('\r\n')]);
|
|
|
+}
|
|
|
+
|
|
|
+function dotStuffBytes(value) {
|
|
|
+ const input = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
|
|
|
+ const extraDots = input.reduce((count, byte, index) => (
|
|
|
+ byte === 0x2e && (index === 0 || input[index - 1] === 0x0a) ? count + 1 : count
|
|
|
+ ), 0);
|
|
|
+ const output = Buffer.alloc(input.length + extraDots);
|
|
|
+ let offset = 0;
|
|
|
+ for (let index = 0; index < input.length; index += 1) {
|
|
|
+ if (input[index] === 0x2e && (index === 0 || input[index - 1] === 0x0a)) output[offset++] = 0x2e;
|
|
|
+ output[offset++] = input[index];
|
|
|
+ }
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+function exactRawMessageBytes(message) {
|
|
|
+ if (Buffer.isBuffer(message.rawMessageBytes)) return message.rawMessageBytes;
|
|
|
+ if (message.rawMessageBytes instanceof Uint8Array) return Buffer.from(message.rawMessageBytes);
|
|
|
+ return Buffer.from(normalizeRawMessage(message), 'utf8');
|
|
|
}
|
|
|
|
|
|
function normalizeRawMessage(message) {
|
|
|
@@ -1002,15 +1047,6 @@ function fallbackRawMessage(message) {
|
|
|
].filter((line, index) => line || index >= 5).join('\r\n');
|
|
|
}
|
|
|
|
|
|
-function dotStuff(rawMessage) {
|
|
|
- return String(rawMessage || '')
|
|
|
- .replace(/\r?\n/g, '\r\n')
|
|
|
- .split('\r\n')
|
|
|
- .map((line) => line.startsWith('.') ? `.${line}` : line)
|
|
|
- .join('\r\n')
|
|
|
- .replace(/\r\n$/, '');
|
|
|
-}
|
|
|
-
|
|
|
function topLines(rawMessage, lineCount) {
|
|
|
const header = headerBlock(rawMessage).replace(/\r\n\r\n$/, '');
|
|
|
const lines = bodyBlock(rawMessage).split('\r\n').slice(0, lineCount).join('\r\n');
|