import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError, logConnectionError } from '../tools.js'; import { createImapError } from '../errors.js'; /** * Appends a message to a mailbox. * * @param connection - IMAP connection instance * @param destination - Destination mailbox path * @param content - Message content (RFC 822 format) * @param flags - Message flags to set on the appended message * @param idate - Internal date to set for the message * @returns Append result with UID info if available, or undefined if preconditions not met * @throws {Error} If the APPEND command fails or message exceeds APPENDLIMIT */ export default async function append(connection, destination, content, flags, idate) { if (!isAuthenticatedState(connection) || !destination) { // nothing to do here return; } // The literal size marker is announced from these bytes, so anything that is not // bytes (a Readable, a plain object) is refused here instead of desyncing the session let message; if (typeof content === 'string') { message = Buffer.from(content); } else if (content instanceof Uint8Array) { // a Buffer as is, any other Uint8Array as a view over the same memory, not a copy message = Buffer.isBuffer(content) ? content : Buffer.from(content.buffer, content.byteOffset, content.byteLength); } else { throw createImapError('Message content must be a Buffer, Uint8Array or string', 'InvalidMessageContent'); } // APPENDLIMIT capability (RFC 7889): server may advertise the maximum message // size it accepts. Check before sending to avoid a wasted round-trip. if (connection.capabilities.has('APPENDLIMIT')) { let appendLimit = connection.capabilities.get('APPENDLIMIT'); if (typeof appendLimit === 'number' && appendLimit < message.length) { let err = new Error('Message content too big for APPENDLIMIT=' + appendLimit); err.serverResponseCode = 'APPENDLIMIT'; throw err; } } destination = normalizePath(connection, destination); // If appending to the currently selected mailbox, we can listen for the // untagged EXISTS response to capture the new message's sequence number. let selected = getSelectedMailbox(connection); // The selected mailbox when appending to it, false otherwise const targetMailbox = selected && comparePaths(connection, selected.path, destination) ? selected : false; // Flags are only filtered when a read-write selection of the destination itself says what it // accepts: the permanentFlags of an unrelated mailbox say nothing about the destination, and a // read-only selection reports an empty PERMANENTFLAGS list (Dovecot does), which canUseFlag() // reads as deny-all. Otherwise they go to the server unfiltered and it decides (RFC 9051). const flagSource = targetMailbox && !targetMailbox.readOnly ? targetMailbox : false; const dropped = []; flags = (Array.isArray(flags) ? flags : [].concat(flags || [])) .map(flag => { const formatted = flag && formatFlag(flag.toString()); if (!formatted || !canUseFlag(flagSource, formatted)) { if (flag) { dropped.push(flag.toString()); } return false; } return formatted; }) .filter((flag) => !!flag); // The stored message simply ends up without them, and the caller is told nothing, so leave // a trail for the flags that never reached the server. if (dropped.length) { connection.log.warn({ msg: 'Dropped flags the mailbox does not accept', cid: connection.id, path: destination, dropped }); } // APPEND command format: APPEND [] [] let attributes = [{ type: 'ATOM', value: encodePath(connection, destination) }]; // Internal date: the date the server should record for this message. // Must be quoted (STRING type) per the IMAP date-time grammar. idate = idate ? formatDateTime(idate) : false; // Flags and date are optional; flags must come before date if both are present if (flags.length || idate) { attributes.push(flags.map(flag => ({ type: 'ATOM', value: flag }))); } if (idate) { attributes.push({ type: 'STRING', value: idate }); } // BINARY extension (RFC 3516): if the message content contains NUL bytes, // use literal8 syntax (~{size}\r\n) instead of regular literal ({size}\r\n). // Regular literals cannot contain NUL bytes per the IMAP grammar. let isLiteral8 = false; if (connection.capabilities.has('BINARY') && !connection.disableBinary) { isLiteral8 = message.indexOf(0) >= 0; } attributes.push({ type: 'LITERAL', value: message, isLiteral8 }); let map = { destination }; if (connection.mailbox && connection.mailbox.path) { map.path = connection.mailbox.path; } // Handler for untagged EXISTS: captures the new message count which gives // us the sequence number of the appended message (it's the latest message). const handleExistsUpdate = (untagged) => { // The count is written into the live mailbox state below, so it has to clear the // same bar untaggedExists() applies: a digit run long enough to coerce to Infinity // would make resolveRange('*') compile to the literal string "Infinity" and break // every range-based command until the next SELECT. let seq = parseUintValue(untagged.command, MAX_UINT32_DIGITS); if (seq === false) { return; } map.seq = seq; // Update the connection's mailbox state and emit 'exists' event if the // count changed (notifies listeners about the new message). if (targetMailbox) { let prevCount = targetMailbox.exists; if (map.seq !== prevCount) { targetMailbox.exists = map.seq; emitSafe(connection, 'exists', { path: targetMailbox.path, count: map.seq, prevCount }); } } }; let response; try { response = await connection.exec('APPEND', attributes, { // Only listen for EXISTS if we're appending to the currently selected mailbox untagged: targetMailbox ? { EXISTS: handleExistsUpdate } : false }); // UIDPLUS (RFC 4315): the server may include APPENDUID response code in // the tagged OK. Format: [APPENDUID ] let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section; if (section && section.length) { let first = section[0]; let responseCode = first && typeof first.value === 'string' ? first.value : ''; if (responseCode.toUpperCase() === 'APPENDUID') { // Bounded digit runs only: isNaN() also passes '1e5', which BigInt() rejects // with a throw - and this catch rethrows, so the append would reject after the // message was already stored and a retrying caller would duplicate it. let uidValidity = parseBigIntValue(section[1] && section[1].value, MAX_UINT32_DIGITS); let uid = parseUintValue(section[2] && section[2].value, MAX_UINT32_DIGITS); // UIDVALIDITY is only reported next to the UID it was sent with: kept without one, // it would end up paired with the UID the SEARCH fallback below guesses from the // sequence number, and the pair would pass for a server-confirmed identity. if (uid) { map.uid = uid; if (uidValidity) { map.uidValidity = uidValidity; } } } } response.next(); // If we didn't get an EXISTS during APPEND (some servers don't send it // until the next command), issue a NOOP to flush pending notifications. if (targetMailbox && !map.seq) { try { response = await connection.exec('NOOP', false, { untagged: { EXISTS: handleExistsUpdate }, comment: 'Sequence not found from APPEND output' }); response.next(); } catch (err) { connection.log.warn({ err, cid: connection.id }); } } // If we have a sequence number but no UID (server doesn't support UIDPLUS), // look up the UID via SEARCH to provide a consistent result to the caller. // The message is already stored, so a failed lookup only leaves the UID out: // rejecting here would make a retrying caller append it twice. if (map.seq && !map.uid) { try { let list = await connection.search({ seq: map.seq }, { uid: true }); if (Array.isArray(list) && list.length) { map.uid = list[0]; } } catch (err) { logConnectionError(connection, 'Failed to look up the UID of the appended message', err); } } return map; } catch (err) { await reportCommandError(connection, err); throw err; } }