VulpesOS/gaia/apps/email/js/ext/imap/imapchew.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

610 lines
20 KiB
JavaScript
Executable file

/**
*
**/
define(
[
'mimefuncs',
'../db/mail_rep',
'../mailchew',
'mimeparser',
'exports'
],
function(
mimefuncs,
mailRep,
$mailchew,
MimeParser,
exports
) {
function parseRfc2231CharsetEncoding(s) {
// charset'lang'url-encoded-ish
var match = /^([^']*)'([^']*)'(.+)$/.exec(s);
if (match) {
// we can convert the dumb encoding into quoted printable.
return mimefuncs.mimeWordsDecode(
'=?' + (match[1] || 'us-ascii') + '?Q?' +
match[3].replace(/%/g, '=') + '?=');
}
return null;
}
// Given an array or string, strip any surrounding angle brackets.
function stripArrows(s) {
if (Array.isArray(s)) {
return s.map(stripArrows);
} else if (s && s[0] === '<') {
return s.slice(1, -1);
} else {
return s;
}
}
function firstHeader(msg, headerName) {
return msg.headers[headerName] && msg.headers[headerName][0] || null;
}
/**
* Process the headers and bodystructure of a message to build preliminary state
* and determine what body parts to fetch. The list of body parts will be used
* to issue another fetch request, and those results will be passed to
* `chewBodyParts`.
*
* For now, our stop-gap heuristics for content bodies are:
* - pick text/plain in multipart/alternative
* - recurse into other multipart types looking for an alterntive that has
* text.
* - do not recurse into message/rfc822
* - ignore/fail-out messages that lack a text part, skipping to the next
* task. (This should not happen once we support HTML, as there are cases
* where there are attachments without any body part.)
* - Append text body parts together; there is no benefit in separating a
* mailing list footer from its content.
*
* For attachments, our heuristics are:
* - only like them if they have filenames. We will find this as "name" on
* the "content-type" or "filename" on the "content-disposition", quite
* possibly on both even.
* - ignore crypto signatures, even though they are named. S/MIME gives us
* "smime.p7s" as an application/pkcs7-signature under a multipart/signed
* (that the server tells us is "signed"). PGP in MIME mode gives us
* application/pgp-signature "signature.asc" under a multipart/signed.
*
* The next step in the plan is to get an HTML sanitizer exposed so we can
* support text/html. That will also imply grabbing multipart/related
* attachments.
*
* @typedef[ChewRep @dict[
* @key[bodyReps @listof[ImapJsPart]]
* @key[attachments @listof[AttachmentInfo]]
* @key[relatedParts @listof[RelatedPartInfo]]
* ]]
* @return[ChewRep]
*/
function chewStructure(msg) {
var attachments = [], bodyReps = [], unnamedPartCounter = 0,
relatedParts = [];
/**
* Sizes are the size of the encoded string, not the decoded value.
*/
function estimatePartSizeInBytes(partInfo) {
var encoding = partInfo.encoding.toLowerCase();
// Base64 encodes 3 bytes in 4 characters with padding that always
// causes the encoding to take 4 characters. The max encoded line length
// (ignoring CRLF) is 76 bytes, with 72 bytes also fairly common.
// As such, a 78=19*4+2 character line encodes 57=19*3 payload bytes and
// we can use that as a rough estimate.
if (encoding === 'base64') {
return Math.floor(partInfo.size * 57 / 78);
}
// Quoted printable is hard to predict since only certain things need
// to be encoded. It could be perfectly efficient if the source text
// has a bunch of newlines built-in.
else if (encoding === 'quoted-printable') {
// Let's just provide an upper-bound of perfectly efficient.
return partInfo.size;
}
// No clue; upper bound.
return partInfo.size;
}
function chewNode(partInfo, parentMultipartSubtype) {
var i, filename, disposition;
var type = partInfo.type.split('/')[0];
var subtype = partInfo.type.split('/')[1];
if (type === 'multipart') {
switch (subtype) {
// For alternative, scan from the back to find the first part we like.
// XXX I believe in Thunderbird we observed some ridiculous misuse of
// alternative that we'll probably want to handle.
case 'alternative':
for (i = partInfo.childNodes.length - 1; i >= 0; i--) {
var subPartInfo = partInfo.childNodes[i];
var childType = subPartInfo.type.split('/')[0];
var childSubtype = subPartInfo.type.split('/')[1];
switch(childType) {
case 'text':
// fall out for subtype checking
break;
case 'multipart':
// this is probably HTML with attachments, let's give it a try
if (chewNode(subPartInfo)) {
return true;
}
break;
default:
// no good, keep going
continue;
}
switch (childSubtype) {
case 'html':
case 'plain':
// (returns true if successfully handled)
if (chewNode(subPartInfo), subtype) {
return true;
}
}
}
// (If we are here, we failed to find a valid choice.)
return false;
// multipart that we should recurse into
case 'mixed':
case 'signed':
case 'related':
for (i = 0; i < partInfo.childNodes.length; i++) {
chewNode(partInfo.childNodes[i], subtype);
}
return true;
default:
console.warn('Ignoring multipart type:', subtype);
return false;
}
}
// Otherwise, this is a leaf node:
else {
// Detect named parts; they could be attachments.
// filename via content-type 'name' parameter
if (partInfo.parameters && partInfo.parameters.name) {
filename = mimefuncs.mimeWordsDecode(partInfo.parameters.name);
}
// filename via content-type 'name' with charset/lang info
else if (partInfo.parameters && partInfo.parameters['name*']) {
filename = parseRfc2231CharsetEncoding(partInfo.parameters['name*']);
}
// rfc 2231 stuff:
// filename via content-disposition filename without charset/lang info
else if (partInfo.dispositionParameters &&
partInfo.dispositionParameters.filename) {
filename = mimefuncs.mimeWordsDecode(
partInfo.dispositionParameters.filename);
}
// filename via content-disposition filename with charset/lang info
else if (partInfo.dispositionParameters &&
partInfo.dispositionParameters['filename*']) {
filename = parseRfc2231CharsetEncoding(
partInfo.dispositionParameters['filename*']);
}
else {
filename = null;
}
// Determining disposition:
// First, check whether an explict one exists
if (partInfo.disposition) {
// If it exists, keep it the same, except in the case of inline
// disposition without a content id.
if (partInfo.disposition.toLowerCase() == 'inline') {
// Displaying text-parts inline is not a problem for us, but we need a
// content id for other embedded content. (Currently only images are
// supported, but that is enforced in a subsequent check.)
if (type === 'text' || partInfo.id) {
disposition = 'inline';
} else {
disposition = 'attachment';
}
}
else if (partInfo.disposition.toLowerCase() == 'attachment') {
disposition = 'attachment';
}
// This case should never trigger, but it's here for safety's sake
else {
disposition = 'inline';
}
// Inline image attachments that belong to a multipart/related
// may lack a disposition but have a content-id.
// XXX Ensure 100% correctness in the future by fixing up
// mis-guesses during sanitization as part of
// https://bugzil.la/1024685
} else if (parentMultipartSubtype === 'related' && partInfo.id &&
type === 'image') {
disposition = "inline";
} else if (filename || type !== 'text') {
disposition = 'attachment';
} else {
disposition = 'inline';
}
// Some clients want us to display things inline that we simply can't
// display (historically and currently, PDF) or that our usage profile
// does not want to automatically download (in the future, PDF, because
// they can get big.)
if (type !== 'text' && type !== 'image') {
disposition = 'attachment';
}
// - But we don't care if they are signatures...
if ((type === 'application') &&
(subtype === 'pgp-signature' || subtype === 'pkcs7-signature')) {
return true;
}
var makePart = function(partInfo, filename) {
return mailRep.makeAttachmentPart({
name: filename || 'unnamed-' + (++unnamedPartCounter),
contentId: partInfo.id ? stripArrows(partInfo.id) : null,
type: partInfo.type.toLowerCase(),
part: partInfo.part,
encoding: partInfo.encoding && partInfo.encoding.toLowerCase(),
sizeEstimate: estimatePartSizeInBytes(partInfo),
file: null
});
}
var makeTextPart = function(partInfo) {
return mailRep.makeBodyPart({
type: subtype,
part: partInfo.part || '1',
sizeEstimate: partInfo.size,
amountDownloaded: 0,
// its important to know that sizeEstimate and amountDownloaded
// do _not_ determine if the bodyRep is fully downloaded; the
// estimated amount is not reliable
// Zero-byte bodies are assumed to be accurate and we treat the file
// as already downloaded.
isDownloaded: partInfo.size === 0,
// full internal IMAP representation
// it would also be entirely appropriate to move
// the information on the bodyRep directly?
_partInfo: partInfo.size ? {
partID: partInfo.part,
type: type,
subtype: subtype,
params: valuesOnly(partInfo.parameters),
encoding: partInfo.encoding && partInfo.encoding.toLowerCase()
} : null,
content: ''
});
}
if (disposition === 'attachment') {
attachments.push(makePart(partInfo, filename));
return true;
}
// - We must be an inline part or structure
switch (type) {
// - related image
case 'image':
relatedParts.push(makePart(partInfo, filename));
return true;
break;
// - content
case 'text':
if (subtype === 'plain' || subtype === 'html') {
bodyReps.push(makeTextPart(partInfo));
return true;
}
break;
}
return false;
}
}
chewNode(msg.bodystructure);
return {
bodyReps: bodyReps,
attachments: attachments,
relatedParts: relatedParts
};
};
/**
* Transform a browserbox representation of an item that has a value
* (i.e. { value: foo }) into a pure value, recursively.
*
* [{ value: 1 } ] -> [1]
* { value: 1 } -> 1
* undefined -> null
*/
function valuesOnly(item) {
if (Array.isArray(item)) {
return item.map(valuesOnly);
} else if (item && typeof item === 'object') {
if ('value' in item) {
return item.value;
} else {
var result = {};
for (var key in item) {
result[key] = valuesOnly(item[key]);
}
return result;
}
} else if (item && typeof item === 'object') {
return item;
} else if (item !== undefined) {
return item;
} else {
return null;
}
}
exports.chewHeaderAndBodyStructure = function(msg, folderId, newMsgId) {
// begin by splitting up the raw imap message
var parts = chewStructure(msg);
msg.date = msg.internaldate && parseImapDateTime(msg.internaldate);
msg.headers = {};
for (var key in msg) {
// We test the key using a regex here because the key name isn't
// normalized to a form we can rely on. The browserbox docs in
// particular indicate that the full key name may be dependent on
// the ordering of the fields as returned by the mail server (i.e.
// the key name includes every header requested). One thing we can
// rely on instead: grabbing the right key based upon just this
// regex.
if (/header\.fields/.test(key)) {
var headerParser = new MimeParser();
headerParser.write(msg[key] + '\r\n');
headerParser.end();
msg.headers = headerParser.node.headers;
break;
}
}
var fromArray = valuesOnly(firstHeader(msg, 'from'));
var references = valuesOnly(firstHeader(msg, 'references'));
return {
header: mailRep.makeHeaderInfo({
// the FolderStorage issued id for this message (which differs from the
// IMAP-server-issued UID so we can do speculative offline operations like
// moves).
id: newMsgId,
srvid: msg.uid,
// The sufficiently unique id is a concatenation of the UID onto the
// folder id.
suid: folderId + '/' + newMsgId,
// The message-id header value; as GUID as get for now; on gmail we can
// use their unique value, or if we could convince dovecot to tell us.
guid: stripArrows(valuesOnly(firstHeader(msg, 'message-id'))),
// mimeparser models from as an array; we do not.
author: fromArray && fromArray[0] ||
// we require a sender e-mail; let's choose an illegal default as
// a stopgap so we don't die.
{ address: 'missing-address@example.com' },
to: valuesOnly(firstHeader(msg, 'to')),
cc: valuesOnly(firstHeader(msg, 'cc')),
bcc: valuesOnly(firstHeader(msg, 'bcc')),
replyTo: valuesOnly(firstHeader(msg, 'reply-to')),
date: msg.date,
flags: msg.flags || [],
hasAttachments: parts.attachments.length > 0,
subject: valuesOnly(firstHeader(msg, 'subject')),
// we lazily fetch the snippet later on
snippet: null
}),
bodyInfo: mailRep.makeBodyInfo({
date: msg.date,
size: 0,
attachments: parts.attachments,
relatedParts: parts.relatedParts,
references: references ? stripArrows(references.split(/\s+/)) : null,
bodyReps: parts.bodyReps
})
};
};
/**
* Fill a given body rep with the content from fetching
* part or the entire body of the message...
*
* var body = ...;
* var header = ...;
* var content = (some fetched content)..
*
* $imapchew.updateMessageWithFetch(
* header,
* bodyInfo,
* {
* bodyRepIndex: 0,
* text: '',
* buffer: Uint8Array|Null,
* bytesFetched: n,
* bytesRequested: n
* }
* );
*
* // what just happend?
* // 1. the body.bodyReps[n].content is now the value of content.
* //
* // 2. we update .amountDownloaded with the second argument
* // (number of bytes downloaded).
* //
* // 3. if snippet has not bee set on the header we create the snippet
* // and set its value.
*
*/
exports.updateMessageWithFetch = function(header, body, req, res) {
var bodyRep = body.bodyReps[req.bodyRepIndex];
// check if the request was unbounded or we got back less bytes then we
// requested in which case the download of this bodyRep is complete.
if (!req.bytes || res.bytesFetched < req.bytes[1]) {
bodyRep.isDownloaded = true;
// clear private space for maintaining parser state.
bodyRep._partInfo = null;
}
if (!bodyRep.isDownloaded && res.buffer) {
bodyRep._partInfo.pendingBuffer = res.buffer;
}
bodyRep.amountDownloaded += res.bytesFetched;
var data = $mailchew.processMessageContent(
res.text, bodyRep.type, bodyRep.isDownloaded, req.createSnippet);
if (req.createSnippet) {
header.snippet = data.snippet;
}
if (bodyRep.isDownloaded)
bodyRep.content = data.content;
};
/**
* Selects a desirable snippet body rep if the given header has no snippet.
*/
exports.selectSnippetBodyRep = function(header, body) {
if (header.snippet)
return -1;
var bodyReps = body.bodyReps;
var len = bodyReps.length;
for (var i = 0; i < len; i++) {
if (exports.canBodyRepFillSnippet(bodyReps[i])) {
return i;
}
}
return -1;
};
/**
* Determines if a given body rep can be converted into a snippet. Useful for
* determining which body rep to use when downloading partial bodies.
*
*
* var bodyInfo;
* $imapchew.canBodyRepFillSnippet(bodyInfo.bodyReps[0]) // true/false
*
*/
exports.canBodyRepFillSnippet = function(bodyRep) {
return (
bodyRep &&
bodyRep.type === 'plain' ||
bodyRep.type === 'html'
);
};
/**
* Calculates and returns the correct estimate for the number of
* bytes to download before we can display the body. For IMAP, that
* includes the bodyReps and related parts. (POP3 is different.)
*/
exports.calculateBytesToDownloadForImapBodyDisplay = function(body) {
var bytesLeft = 0;
body.bodyReps.forEach(function(rep) {
if (!rep.isDownloaded) {
bytesLeft += rep.sizeEstimate - rep.amountDownloaded;
}
});
body.relatedParts.forEach(function(part) {
if (!part.file) {
bytesLeft += part.sizeEstimate;
}
});
return bytesLeft;
}
// parseImapDateTime and formatImapDateTime functions from node-imap;
// MIT licensed, (c) Brian White.
// ( ?\d|\d{2}) = day number; technically it's either "SP DIGIT" or "2DIGIT"
// but there's no harm in us accepting a single digit without whitespace;
// it's conceivable the caller might have trimmed whitespace.
//
// The timezone can, as unfortunately demonstrated by net-c.com/netc.fr, be
// omitted. So we allow it to be optional and assume its value was zero if
// omitted.
var reDateTime =
/^( ?\d|\d{2})-(.{3})-(\d{4}) (\d{2}):(\d{2}):(\d{2})(?: ([+-]\d{4}))?$/;
var HOUR_MILLIS = 60 * 60 * 1000;
var MINUTE_MILLIS = 60 * 1000;
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep',
'Oct', 'Nov', 'Dec'];
/**
* Parses IMAP "date-time" instances into UTC timestamps whose quotes have
* already been stripped.
*
* http://tools.ietf.org/html/rfc3501#page-84
*
* date-day = 1*2DIGIT
* ; Day of month
* date-day-fixed = (SP DIGIT) / 2DIGIT
* ; Fixed-format version of date-day
* date-month = "Jan" / "Feb" / "Mar" / "Apr" / "May" / "Jun" /
* "Jul" / "Aug" / "Sep" / "Oct" / "Nov" / "Dec"
* date-year = 4DIGIT
* time = 2DIGIT ":" 2DIGIT ":" 2DIGIT
* ; Hours minutes seconds
* zone = ("+" / "-") 4DIGIT
* date-time = DQUOTE date-day-fixed "-" date-month "-" date-year
* SP time SP zone DQUOTE
*/
var parseImapDateTime = exports.parseImapDateTime = function(dstr) {
var match = reDateTime.exec(dstr);
if (!match)
throw new Error('Not a good IMAP date-time: ' + dstr);
var day = parseInt(match[1], 10),
zeroMonth = MONTHS.indexOf(match[2]),
year = parseInt(match[3], 10),
hours = parseInt(match[4], 10),
minutes = parseInt(match[5], 10),
seconds = parseInt(match[6], 10),
// figure the timestamp before the zone stuff. We don't
timestamp = Date.UTC(year, zeroMonth, day, hours, minutes, seconds),
// to reduce string garbage creation, we use one string. (we have to
// play math games no matter what, anyways.)
zoneDelta = match[7] ? parseInt(match[7], 10) : 0,
zoneHourDelta = Math.floor(zoneDelta / 100),
// (the negative sign sticks around through the mod operation)
zoneMinuteDelta = zoneDelta % 100;
// ex: GMT-0700 means 7 hours behind, so we need to add 7 hours, aka
// subtract negative 7 hours.
timestamp -= zoneHourDelta * HOUR_MILLIS + zoneMinuteDelta * MINUTE_MILLIS;
return timestamp;
};
exports.formatImapDateTime = function(date) {
var s;
s = ((date.getDate() < 10) ? ' ' : '') + date.getDate() + '-' +
MONTHS[date.getMonth()] + '-' +
date.getFullYear() + ' ' +
('0'+date.getHours()).slice(-2) + ':' +
('0'+date.getMinutes()).slice(-2) + ':' +
('0'+date.getSeconds()).slice(-2) +
((date.getTimezoneOffset() > 0) ? ' -' : ' +' ) +
('0'+(Math.abs(date.getTimezoneOffset()) / 60)).slice(-2) +
('0'+(Math.abs(date.getTimezoneOffset()) % 60)).slice(-2);
return s;
};
}); // end define