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.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
926
gaia/apps/email/js/ext/imap/account.js
Executable file
926
gaia/apps/email/js/ext/imap/account.js
Executable file
|
|
@ -0,0 +1,926 @@
|
|||
define(
|
||||
[
|
||||
'logic',
|
||||
'../a64',
|
||||
'../accountmixins',
|
||||
'../allback',
|
||||
'../errbackoff',
|
||||
'../mailslice',
|
||||
'../searchfilter',
|
||||
'../syncbase',
|
||||
'../util',
|
||||
'../composite/incoming',
|
||||
'./folder',
|
||||
'./jobs',
|
||||
'./client',
|
||||
'../errorutils',
|
||||
'../disaster-recovery',
|
||||
'module',
|
||||
'require',
|
||||
'exports'
|
||||
],
|
||||
function(
|
||||
logic,
|
||||
$a64,
|
||||
$acctmixins,
|
||||
$allback,
|
||||
$errbackoff,
|
||||
$mailslice,
|
||||
$searchfilter,
|
||||
$syncbase,
|
||||
$util,
|
||||
incoming,
|
||||
$imapfolder,
|
||||
$imapjobs,
|
||||
$imapclient,
|
||||
errorutils,
|
||||
DisasterRecovery,
|
||||
$module,
|
||||
require,
|
||||
exports
|
||||
) {
|
||||
var bsearchForInsert = $util.bsearchForInsert;
|
||||
var allbackMaker = $allback.allbackMaker;
|
||||
var CompositeIncomingAccount = incoming.CompositeIncomingAccount;
|
||||
|
||||
function cmpFolderPubPath(a, b) {
|
||||
return a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Account object, root of all interaction with servers.
|
||||
*
|
||||
* Passwords are currently held in cleartext with the rest of the data. Ideally
|
||||
* we would like them to be stored in some type of keyring coupled to the TCP
|
||||
* API in such a way that we never know the API. Se a vida e.
|
||||
*
|
||||
*/
|
||||
function ImapAccount(universe, compositeAccount, accountId, credentials,
|
||||
connInfo, folderInfos,
|
||||
dbConn, existingProtoConn) {
|
||||
|
||||
// Using the generic 'Account' here, as current tests don't
|
||||
// distinguish between events on ImapAccount vs. CompositeAccount.
|
||||
logic.defineScope(this, 'Account', { accountId: accountId,
|
||||
accountType: 'imap' });
|
||||
|
||||
CompositeIncomingAccount.apply(
|
||||
this, [$imapfolder.ImapFolderSyncer].concat(Array.slice(arguments)));
|
||||
|
||||
/**
|
||||
* The maximum number of connections we are allowed to have alive at once. We
|
||||
* want to limit this both because we generally aren't sophisticated enough
|
||||
* to need to use many connections at once (unless we have bugs), and because
|
||||
* servers may enforce a per-account connection limit which can affect both
|
||||
* us and other clients on other devices.
|
||||
*
|
||||
* Thunderbird's default for this is 5.
|
||||
*
|
||||
* gmail currently claims to have a limit of 15 connections per account:
|
||||
* http://support.google.com/mail/bin/answer.py?hl=en&answer=97150
|
||||
*
|
||||
* I am picking 3 right now because it should cover the "I just sent a
|
||||
* messages from the folder I was in and then switched to another folder",
|
||||
* where we could have stuff to do in the old folder, new folder, and sent
|
||||
* mail folder. I have also seem claims of connection limits of 3 for some
|
||||
* accounts out there, so this avoids us needing logic to infer a need to
|
||||
* lower our connection limit.
|
||||
*/
|
||||
this._maxConnsAllowed = 3;
|
||||
/**
|
||||
* The `ImapConnection` we are attempting to open, if any. We only try to
|
||||
* open one connection at a time.
|
||||
*/
|
||||
this._pendingConn = null;
|
||||
this._ownedConns = [];
|
||||
/**
|
||||
* @listof[@dict[
|
||||
* @key[folderId]
|
||||
* @key[callback]
|
||||
* ]]{
|
||||
* The list of requested connections that have not yet been serviced. An
|
||||
* }
|
||||
*/
|
||||
this._demandedConns = [];
|
||||
this._backoffEndpoint = $errbackoff.createEndpoint('imap:' + this.id, this);
|
||||
|
||||
if (existingProtoConn)
|
||||
this._reuseConnection(existingProtoConn);
|
||||
|
||||
this._jobDriver = new $imapjobs.ImapJobDriver(
|
||||
this, this._folderInfos.$mutationState);
|
||||
|
||||
/**
|
||||
* Flag to allow us to avoid calling closeBox to close a folder. This avoids
|
||||
* expunging deleted messages.
|
||||
*/
|
||||
this._TEST_doNotCloseFolder = false;
|
||||
|
||||
// Immediately ensure that we have any required local-only folders,
|
||||
// as those can be created even while offline.
|
||||
this.ensureEssentialOfflineFolders();
|
||||
}
|
||||
|
||||
exports.Account = exports.ImapAccount = ImapAccount;
|
||||
ImapAccount.prototype = Object.create(CompositeIncomingAccount.prototype);
|
||||
var properties = {
|
||||
type: 'imap',
|
||||
supportsServerFolders: true,
|
||||
toString: function() {
|
||||
return '[ImapAccount: ' + this.id + ']';
|
||||
},
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Server type indicators for quirks and heuristics like sent mail
|
||||
|
||||
/**
|
||||
* Is this server gmail? Not something that just looks like gmail, but IS
|
||||
* gmail.
|
||||
*
|
||||
* Gmail self-identifies via the nonstandard but documented X-GM-EXT-1
|
||||
* capability. Documentation is at
|
||||
* https://developers.google.com/gmail/imap_extensions
|
||||
*/
|
||||
get isGmail() {
|
||||
return this.meta.capability.indexOf('X-GM-EXT-1') !== -1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Is this a CoreMail server, as used by 126.com/163.com/others?
|
||||
*
|
||||
* CoreMail servers self-identify via the apparently cargo-culted
|
||||
* X-CM-EXT-1 capability.
|
||||
*/
|
||||
get isCoreMailServer() {
|
||||
return this.meta.capability.indexOf('X-CM-EXT-1') !== -1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Do messages sent via the corresponding SMTP account automatically show up
|
||||
* in the sent folder? Both Gmail and CoreMail do this. (It's a good thing
|
||||
* to do, it just sucks that there's no explicit IMAP capability, etc. to
|
||||
* indicate this without us having to infer from the server type. Although
|
||||
* we could probe this if we wanted...)
|
||||
*/
|
||||
get sentMessagesAutomaticallyAppearInSentFolder() {
|
||||
return this.isGmail || this.isCoreMailServer;
|
||||
},
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Connection Pool-ish stuff
|
||||
|
||||
get numActiveConns() {
|
||||
return this._ownedConns.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mechanism for an `ImapFolderConn` to request an IMAP protocol connection.
|
||||
* This is to potentially support some type of (bounded) connection pooling
|
||||
* like Thunderbird uses. The rationale is that many servers cap the number
|
||||
* of connections we are allowed to maintain, plus it's hard to justify
|
||||
* locally tying up those resources. (Thunderbird has more need of watching
|
||||
* multiple folders than ourselves, but we may still want to synchronize a
|
||||
* bunch of folders in parallel for latency reasons.)
|
||||
*
|
||||
* The provided connection will *not* be in the requested folder; it's up to
|
||||
* the folder connection to enter the folder.
|
||||
*
|
||||
* @args[
|
||||
* @param[folderId #:optional FolderId]{
|
||||
* The folder id of the folder that will be using the connection. If
|
||||
* it's not a folder but some task, then pass null (and ideally provide
|
||||
* a useful `label`).
|
||||
* }
|
||||
* @param[label #:optional String]{
|
||||
* A human readable explanation of the activity for debugging purposes.
|
||||
* }
|
||||
* @param[callback @func[@args[@param[conn]]]]{
|
||||
* The callback to invoke once the connection has been established. If
|
||||
* there is a connection present in the reuse pool, this may be invoked
|
||||
* immediately.
|
||||
* }
|
||||
* @param[deathback Function]{
|
||||
* A callback to invoke if the connection dies or we feel compelled to
|
||||
* reclaim it.
|
||||
* }
|
||||
* @param[dieOnConnectFailure #:optional Boolean]{
|
||||
* Should we invoke the deathback for this request if we fail to establish
|
||||
* a connection in a timely manner? This will be immediately invoked if
|
||||
* we are offline or if we exhaust our retries for establishing
|
||||
* connections with the server.
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
__folderDemandsConnection: function(folderId, label, callback, deathback,
|
||||
dieOnConnectFailure) {
|
||||
// If we are offline, invoke the deathback soon and don't bother trying to
|
||||
// get a connection.
|
||||
if (dieOnConnectFailure && !this.universe.online) {
|
||||
window.setZeroTimeout(deathback);
|
||||
return;
|
||||
}
|
||||
|
||||
var demand = {
|
||||
folderId: folderId,
|
||||
label: label,
|
||||
callback: callback,
|
||||
deathback: deathback,
|
||||
dieOnConnectFailure: Boolean(dieOnConnectFailure)
|
||||
};
|
||||
this._demandedConns.push(demand);
|
||||
|
||||
// No line-cutting; bail if there was someone ahead of us.
|
||||
if (this._demandedConns.length > 1)
|
||||
return;
|
||||
|
||||
// - try and reuse an existing connection
|
||||
if (this._allocateExistingConnection())
|
||||
return;
|
||||
|
||||
// - we need to wait for a new conn or one to free up
|
||||
this._makeConnectionIfPossible();
|
||||
|
||||
return;
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger the deathbacks for all connection demands where dieOnConnectFailure
|
||||
* is true.
|
||||
*/
|
||||
_killDieOnConnectFailureDemands: function() {
|
||||
for (var i = 0; i < this._demandedConns.length; i++) {
|
||||
var demand = this._demandedConns[i];
|
||||
if (demand.dieOnConnectFailure) {
|
||||
demand.deathback.call(null);
|
||||
this._demandedConns.splice(i--, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Try and find an available connection and assign it to the first connection
|
||||
* demand.
|
||||
*
|
||||
* @return[Boolean]{
|
||||
* True if we allocated a demand to a conncetion, false if we did not.
|
||||
* }
|
||||
*/
|
||||
_allocateExistingConnection: function() {
|
||||
if (!this._demandedConns.length)
|
||||
return false;
|
||||
var demandInfo = this._demandedConns[0];
|
||||
|
||||
var reusableConnInfo = null;
|
||||
for (var i = 0; i < this._ownedConns.length; i++) {
|
||||
var connInfo = this._ownedConns[i];
|
||||
// It's concerning if the folder already has a connection...
|
||||
if (demandInfo.folderId && connInfo.folderId === demandInfo.folderId)
|
||||
logic(this, 'folderAlreadyHasConn', { folderId: demandInfo.folderId });
|
||||
|
||||
if (connInfo.inUseBy)
|
||||
continue;
|
||||
|
||||
connInfo.inUseBy = demandInfo;
|
||||
this._demandedConns.shift();
|
||||
logic(this, 'reuseConnection',
|
||||
{ folderId: demandInfo.folderId, label: demandInfo.label });
|
||||
demandInfo.callback(connInfo.conn);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* All our operations completed; let's think about closing any connections
|
||||
* they may have established that we don't need anymore.
|
||||
*/
|
||||
allOperationsCompleted: function() {
|
||||
this.maybeCloseUnusedConnections();
|
||||
},
|
||||
|
||||
/**
|
||||
* Using great wisdom, potentially close some/all connections.
|
||||
*/
|
||||
maybeCloseUnusedConnections: function() {
|
||||
// XXX: We are closing unused connections in an effort to stem
|
||||
// problems associated with unreliable cell connections; they
|
||||
// tend to be dropped unceremoniously when left idle for a
|
||||
// long time, particularly on cell networks. NB: This will
|
||||
// close the connection we just used, unless someone else is
|
||||
// waiting for a connection.
|
||||
if ($syncbase.KILL_CONNECTIONS_WHEN_JOBLESS &&
|
||||
!this._demandedConns.length &&
|
||||
!this.universe.areServerJobsWaiting(this)) {
|
||||
this.closeUnusedConnections();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close all connections that aren't currently in use.
|
||||
*/
|
||||
closeUnusedConnections: function() {
|
||||
for (var i = this._ownedConns.length - 1; i >= 0; i--) {
|
||||
var connInfo = this._ownedConns[i];
|
||||
if (connInfo.inUseBy)
|
||||
continue;
|
||||
console.log('Killing unused IMAP connection.');
|
||||
// this eats all future notifications, so we need to splice...
|
||||
this._ownedConns.splice(i, 1);
|
||||
connInfo.conn.client.close();
|
||||
logic(this, 'deadConnection', { reason: 'unused' });
|
||||
}
|
||||
},
|
||||
|
||||
_makeConnectionIfPossible: function() {
|
||||
if (this._ownedConns.length >= this._maxConnsAllowed) {
|
||||
logic(this, 'maximumConnsNoNew');
|
||||
return;
|
||||
}
|
||||
if (this._pendingConn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingConn = true;
|
||||
var boundMakeConnection = this._makeConnection.bind(this);
|
||||
this._backoffEndpoint.scheduleConnectAttempt(boundMakeConnection);
|
||||
},
|
||||
|
||||
_makeConnection: function(callback, whyFolderId, whyLabel) {
|
||||
// Mark a pending connection synchronously; the require call will not return
|
||||
// until at least the next turn of the event loop.
|
||||
this._pendingConn = true;
|
||||
// Dynamically load the probe/imap code to speed up startup.
|
||||
require(['./client'], function ($imapclient) {
|
||||
logic(this, 'createConnection', {
|
||||
folderId: whyFolderId,
|
||||
label: whyLabel
|
||||
});
|
||||
|
||||
$imapclient.createImapConnection(
|
||||
this._credentials,
|
||||
this._connInfo,
|
||||
function onCredentialsUpdated() {
|
||||
return new Promise(function(resolve) {
|
||||
// Note: Since we update the credentials object in-place,
|
||||
// there's no need to explicitly assign the changes here;
|
||||
// just save the account information.
|
||||
this.universe.saveAccountDef(
|
||||
this.compositeAccount.accountDef,
|
||||
/* folderInfo: */ null,
|
||||
/* callback: */ resolve);
|
||||
}.bind(this));
|
||||
}.bind(this)
|
||||
).then(function(conn) {
|
||||
DisasterRecovery.associateSocketWithAccount(conn.client.socket, this);
|
||||
|
||||
this._pendingConn = null;
|
||||
this._bindConnectionDeathHandlers(conn);
|
||||
this._backoffEndpoint.noteConnectSuccess();
|
||||
this._ownedConns.push({
|
||||
conn: conn,
|
||||
inUseBy: null
|
||||
});
|
||||
this._allocateExistingConnection();
|
||||
|
||||
// If more connections are needed, keep connecting.
|
||||
if (this._demandedConns.length) {
|
||||
this._makeConnectionIfPossible();
|
||||
}
|
||||
|
||||
callback && callback(null);
|
||||
}.bind(this))
|
||||
.catch(function(err) {
|
||||
logic(this, 'deadConnection', {
|
||||
reason: 'connect-error',
|
||||
folderId: whyFolderId
|
||||
});
|
||||
|
||||
if (errorutils.shouldReportProblem(err)) {
|
||||
this.universe.__reportAccountProblem(
|
||||
this.compositeAccount,
|
||||
err,
|
||||
'incoming');
|
||||
}
|
||||
|
||||
this._pendingConn = null;
|
||||
callback && callback(err);
|
||||
|
||||
// Track this failure for backoff purposes.
|
||||
if (errorutils.shouldRetry(err)) {
|
||||
if (this._backoffEndpoint.noteConnectFailureMaybeRetry(
|
||||
errorutils.wasErrorFromReachableState(err))) {
|
||||
this._makeConnectionIfPossible();
|
||||
} else {
|
||||
this._killDieOnConnectFailureDemands();
|
||||
}
|
||||
} else {
|
||||
this._backoffEndpoint.noteBrokenConnection();
|
||||
this._killDieOnConnectFailureDemands();
|
||||
}
|
||||
}.bind(this));
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Treat a connection that came from the IMAP prober as a connection we
|
||||
* created ourselves.
|
||||
*/
|
||||
_reuseConnection: function(existingProtoConn) {
|
||||
DisasterRecovery.associateSocketWithAccount(
|
||||
existingProtoConn.client.socket, this);
|
||||
this._ownedConns.push({
|
||||
conn: existingProtoConn,
|
||||
inUseBy: null
|
||||
});
|
||||
this._bindConnectionDeathHandlers(existingProtoConn);
|
||||
},
|
||||
|
||||
_bindConnectionDeathHandlers: function(conn) {
|
||||
|
||||
conn.breakIdle(function() {
|
||||
conn.client.TIMEOUT_ENTER_IDLE = $syncbase.STALE_CONNECTION_TIMEOUT_MS;
|
||||
conn.client.onidle = function() {
|
||||
console.warn('Killing stale IMAP connection.');
|
||||
conn.client.close();
|
||||
};
|
||||
|
||||
// Reenter the IDLE state here so that we properly time out if
|
||||
// we never send any further requests (which would normally
|
||||
// cause _enterIdle to be called when the request queue has been
|
||||
// emptied).
|
||||
conn.client._enterIdle();
|
||||
});
|
||||
|
||||
conn.onclose = function() {
|
||||
for (var i = 0; i < this._ownedConns.length; i++) {
|
||||
var connInfo = this._ownedConns[i];
|
||||
if (connInfo.conn === conn) {
|
||||
logic(this, 'deadConnection', {
|
||||
reason: 'closed',
|
||||
folderId: connInfo.inUseBy &&
|
||||
connInfo.inUseBy.folderId
|
||||
});
|
||||
if (connInfo.inUseBy && connInfo.inUseBy.deathback)
|
||||
connInfo.inUseBy.deathback(conn);
|
||||
connInfo.inUseBy = null;
|
||||
this._ownedConns.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}.bind(this);
|
||||
|
||||
conn.onerror = function(err) {
|
||||
err = $imapclient.normalizeImapError(conn, err);
|
||||
logic(this, 'connectionError', { error: err });
|
||||
console.error('imap:onerror', JSON.stringify({
|
||||
error: err,
|
||||
host: this._connInfo.hostname,
|
||||
port: this._connInfo.port
|
||||
}));
|
||||
}.bind(this);
|
||||
},
|
||||
|
||||
__folderDoneWithConnection: function(conn, closeFolder, resourceProblem) {
|
||||
for (var i = 0; i < this._ownedConns.length; i++) {
|
||||
var connInfo = this._ownedConns[i];
|
||||
if (connInfo.conn === conn) {
|
||||
if (resourceProblem)
|
||||
this._backoffEndpoint(connInfo.inUseBy.folderId);
|
||||
logic(this, 'releaseConnection', {
|
||||
folderId: connInfo.inUseBy.folderId,
|
||||
label: connInfo.inUseBy.label
|
||||
});
|
||||
connInfo.inUseBy = null;
|
||||
|
||||
// We just freed up a connection, it may be appropriate to close it.
|
||||
this.maybeCloseUnusedConnections();
|
||||
return;
|
||||
}
|
||||
}
|
||||
logic(this, 'connectionMismatch');
|
||||
},
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Folder synchronization
|
||||
|
||||
/**
|
||||
* Helper in conjunction with `_syncFolderComputeDeltas` for use by the
|
||||
* syncFolderList operation/job. The op is on the hook for the connection's
|
||||
* lifecycle.
|
||||
*/
|
||||
_syncFolderList: function(conn, callback) {
|
||||
conn.listMailboxes(
|
||||
this._syncFolderComputeDeltas.bind(this, conn, callback));
|
||||
},
|
||||
|
||||
_determineFolderType: function(box, path) {
|
||||
var attribs = (box.flags || []).map(function(flag) {
|
||||
return flag.substr(1).toUpperCase(); // Map "\\Noselect" => "NOSELECT"
|
||||
});
|
||||
|
||||
var type = null;
|
||||
// NoSelect trumps everything.
|
||||
if (attribs.indexOf('NOSELECT') !== -1) {
|
||||
type = 'nomail';
|
||||
}
|
||||
else {
|
||||
// Standards-ish:
|
||||
// - special-use: http://tools.ietf.org/html/rfc6154
|
||||
// IANA registrations:
|
||||
// http://www.iana.org/assignments/imap4-list-extended
|
||||
// - xlist:
|
||||
// https://developers.google.com/google-apps/gmail/imap_extensions
|
||||
|
||||
// Process the attribs for goodness.
|
||||
for (var i = 0; i < attribs.length; i++) {
|
||||
switch (attribs[i]) {
|
||||
// TODO: split the 'all' cases into their own type!
|
||||
case 'ALL': // special-use
|
||||
case 'ALLMAIL': // xlist
|
||||
case 'ARCHIVE': // special-use
|
||||
type = 'archive';
|
||||
break;
|
||||
case 'DRAFTS': // special-use xlist
|
||||
type = 'drafts';
|
||||
break;
|
||||
case 'FLAGGED': // special-use
|
||||
type = 'starred';
|
||||
break;
|
||||
case 'IMPORTANT': // (undocumented) xlist
|
||||
type = 'important';
|
||||
break;
|
||||
case 'INBOX': // xlist
|
||||
type = 'inbox';
|
||||
break;
|
||||
case 'JUNK': // special-use
|
||||
type = 'junk';
|
||||
break;
|
||||
case 'SENT': // special-use xlist
|
||||
type = 'sent';
|
||||
break;
|
||||
case 'SPAM': // xlist
|
||||
type = 'junk';
|
||||
break;
|
||||
case 'STARRED': // xlist
|
||||
type = 'starred';
|
||||
break;
|
||||
|
||||
case 'TRASH': // special-use xlist
|
||||
type = 'trash';
|
||||
break;
|
||||
|
||||
case 'HASCHILDREN': // 3348
|
||||
case 'HASNOCHILDREN': // 3348
|
||||
|
||||
// - standard bits we don't care about
|
||||
case 'MARKED': // 3501
|
||||
case 'UNMARKED': // 3501
|
||||
case 'NOINFERIORS': // 3501
|
||||
// XXX use noinferiors to prohibit folder creation under it.
|
||||
// NOSELECT
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// heuristic based type assignment based on the name
|
||||
if (!type) {
|
||||
// ensure that we treat folders at the root, see bug 854128
|
||||
var prefix = this._namespaces.personal[0] &&
|
||||
this._namespaces.personal[0].prefix;
|
||||
var isAtNamespaceRoot = path === (prefix + box.name);
|
||||
// If our name is our path, we are at the absolute root of the tree.
|
||||
// This will be the case for INBOX even if there is a namespace.
|
||||
if (isAtNamespaceRoot || path === box.name) {
|
||||
switch (box.name.toUpperCase()) {
|
||||
case 'DRAFT':
|
||||
case 'DRAFTS':
|
||||
type = 'drafts';
|
||||
break;
|
||||
case 'INBOX':
|
||||
// Inbox is special; the path needs to case-insensitively match.
|
||||
if (path.toUpperCase() === 'INBOX')
|
||||
type = 'inbox';
|
||||
break;
|
||||
// Yahoo provides "Bulk Mail" for yahoo.fr.
|
||||
case 'BULK MAIL':
|
||||
case 'JUNK':
|
||||
case 'SPAM':
|
||||
type = 'junk';
|
||||
break;
|
||||
case 'SENT':
|
||||
type = 'sent';
|
||||
break;
|
||||
case 'TRASH':
|
||||
type = 'trash';
|
||||
break;
|
||||
// This currently only exists for consistency with Thunderbird, but
|
||||
// may become useful in the future when we need an outbox.
|
||||
case 'UNSENT MESSAGES':
|
||||
type = 'queue';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!type)
|
||||
type = 'normal';
|
||||
}
|
||||
return type;
|
||||
},
|
||||
|
||||
/**
|
||||
* A map of namespaces: { personal: { prefix: '', delimiter: '' }, ... }.
|
||||
* Populated in _syncFolderComputeDeltas.
|
||||
*/
|
||||
_namespaces: {
|
||||
personal: { prefix: '', delimiter: '/' },
|
||||
provisional: true
|
||||
},
|
||||
|
||||
_syncFolderComputeDeltas: function(conn, callback, err, boxesRoot) {
|
||||
var self = this;
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Before we walk the boxes, get namespace information.
|
||||
// In the failure case, assume no relevant namespaces.
|
||||
if (self._namespaces.provisional) {
|
||||
conn.listNamespaces(function(err, namespaces) {
|
||||
if (!err && namespaces) {
|
||||
self._namespaces = namespaces;
|
||||
}
|
||||
|
||||
self._namespaces.provisional = false;
|
||||
|
||||
logic(self, 'list-namespaces', {
|
||||
namespaces: namespaces
|
||||
});
|
||||
|
||||
self._syncFolderComputeDeltas(conn, callback, err, boxesRoot);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// - build a map of known existing folders
|
||||
var folderPubsByPath = {};
|
||||
var folderPub;
|
||||
for (var iFolder = 0; iFolder < this.folders.length; iFolder++) {
|
||||
folderPub = this.folders[iFolder];
|
||||
folderPubsByPath[folderPub.path] = folderPub;
|
||||
}
|
||||
|
||||
var syncScope = logic.scope('ImapFolderSync');
|
||||
|
||||
// - walk the boxes
|
||||
function walkBoxes(boxLevel, pathDepth, parentId) {
|
||||
boxLevel.forEach(function(box) {
|
||||
var boxName = box.name, meta,
|
||||
folderId;
|
||||
|
||||
var delim = box.delimiter || '/';
|
||||
|
||||
if (box.path.indexOf(delim) === 0) {
|
||||
box.path = box.path.slice(delim.length);
|
||||
}
|
||||
|
||||
var path = box.path;
|
||||
|
||||
// - normalize jerk-moves
|
||||
var type = self._determineFolderType(box, path);
|
||||
|
||||
// gmail finds it amusing to give us the localized name/path of its
|
||||
// inbox, but still expects us to ask for it as INBOX.
|
||||
if (type === 'inbox')
|
||||
path = 'INBOX';
|
||||
|
||||
// - already known folder
|
||||
if (folderPubsByPath.hasOwnProperty(path)) {
|
||||
// Because we speculatively create the Inbox, both its display name
|
||||
// and delimiter may be incorrect and need to be updated.
|
||||
meta = folderPubsByPath[path];
|
||||
meta.name = box.name;
|
||||
meta.delim = delim;
|
||||
|
||||
logic(syncScope, 'folder-sync:existing', {
|
||||
type: type,
|
||||
name: box.name,
|
||||
path: path,
|
||||
delim: delim
|
||||
});
|
||||
|
||||
// mark it with true to show that we've seen it.
|
||||
folderPubsByPath[path] = true;
|
||||
}
|
||||
// - new to us!
|
||||
else {
|
||||
logic(syncScope, 'folder-sync:add', {
|
||||
type: type,
|
||||
name: box.name,
|
||||
path: path,
|
||||
delim: delim
|
||||
});
|
||||
meta = self._learnAboutFolder(box.name, path, parentId, type,
|
||||
delim, pathDepth);
|
||||
}
|
||||
|
||||
if (box.children)
|
||||
walkBoxes(box.children, pathDepth + 1, meta.id);
|
||||
});
|
||||
}
|
||||
|
||||
walkBoxes(boxesRoot.children, 0, null);
|
||||
|
||||
// - detect deleted folders
|
||||
// track dead folder id's so we can issue a
|
||||
var deadFolderIds = [];
|
||||
for (var folderPath in folderPubsByPath) {
|
||||
folderPub = folderPubsByPath[folderPath];
|
||||
// (skip those we found above)
|
||||
if (folderPub === true)
|
||||
continue;
|
||||
// Never delete our localdrafts or outbox folder.
|
||||
if ($mailslice.FolderStorage.isTypeLocalOnly(folderPub.type))
|
||||
continue;
|
||||
logic(syncScope, 'delete-dead-folder', {
|
||||
folderType: folderPub.type,
|
||||
folderId: folderPub.id
|
||||
});
|
||||
// It must have gotten deleted!
|
||||
this._forgetFolder(folderPub.id);
|
||||
}
|
||||
|
||||
// Once we've synchonized the folder list, kick off another job to
|
||||
// check that we have all essential online folders. Once that
|
||||
// completes, we'll check to make sure our offline-only folders
|
||||
// (localdrafts, outbox) are in the right place according to where
|
||||
// this server stores other built-in folders.
|
||||
this.ensureEssentialOnlineFolders();
|
||||
this.normalizeFolderHierarchy();
|
||||
|
||||
callback(null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure that local-only folders exist. This runs synchronously
|
||||
* before we sync the folder list with the server. Ideally, these
|
||||
* folders should reside in a proper place in the folder hierarchy,
|
||||
* which may differ between servers depending on whether the
|
||||
* account's other folders live underneath the inbox or as
|
||||
* top-level-folders. But since moving folders is easy and doesn't
|
||||
* really affect the backend, we'll just ensure they exist here, and
|
||||
* fix up their hierarchical location when syncing the folder list.
|
||||
*/
|
||||
ensureEssentialOfflineFolders: function() {
|
||||
[ 'outbox', 'localdrafts' ].forEach(function(folderType) {
|
||||
if (!this.getFirstFolderWithType(folderType)) {
|
||||
this._learnAboutFolder(
|
||||
/* name: */ folderType,
|
||||
/* path: */ folderType,
|
||||
/* parentId: */ null,
|
||||
/* type: */ folderType,
|
||||
/* delim: */ '',
|
||||
/* depth: */ 0,
|
||||
/* suppressNotification: */ true);
|
||||
}
|
||||
}, this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Kick off jobs to create essential folders (sent, trash) if
|
||||
* necessary. These folders should be created on both the client and
|
||||
* the server; contrast with `ensureEssentialOfflineFolders`.
|
||||
*
|
||||
* TODO: Support localizing all automatically named e-mail folders
|
||||
* regardless of the origin locale.
|
||||
* Relevant bugs: <https://bugzil.la/905869>, <https://bugzil.la/905878>.
|
||||
*
|
||||
* @param {function} callback
|
||||
* Called when all ops have run.
|
||||
*/
|
||||
ensureEssentialOnlineFolders: function(callback) {
|
||||
var essentialFolders = { 'trash': 'Trash', 'sent': 'Sent' };
|
||||
var latch = $allback.latch();
|
||||
|
||||
for (var type in essentialFolders) {
|
||||
if (!this.getFirstFolderWithType(type)) {
|
||||
this.universe.createFolder(
|
||||
this.id, null, essentialFolders[type], type, false, latch.defer());
|
||||
}
|
||||
}
|
||||
|
||||
latch.then(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure that local-only folders live in a reasonable place in the
|
||||
* folder hierarchy by moving them if necessary.
|
||||
*
|
||||
* We proactively create local-only folders at the root level before
|
||||
* we synchronize with the server; if possible, we want these
|
||||
* folders to reside as siblings to other system-level folders on
|
||||
* the account. This is called at the end of syncFolderList, after
|
||||
* we have learned about all existing server folders.
|
||||
*/
|
||||
normalizeFolderHierarchy: $acctmixins.normalizeFolderHierarchy,
|
||||
|
||||
/**
|
||||
* Asynchronously save the sent message to the sent folder, if applicable.
|
||||
* This should only be called once the SMTP send has completed.
|
||||
*
|
||||
* If non-gmail, append a bcc-including version of the message into the sent
|
||||
* folder. For gmail, the SMTP server automatically copies the message into
|
||||
* the sent folder so we don't need to do this.
|
||||
*
|
||||
* There are several notable limitations with the current implementation:
|
||||
* - We do not write a copy of the message into the sent folder locally, so
|
||||
* the message must be downloaded/synchronized for the user to see it.
|
||||
* - The operation to append the message does not get persisted to disk, so
|
||||
* in the event the app crashes or is closed, a copy of the message will
|
||||
* not end up in the sent folder. This has always been the emergent
|
||||
* phenomenon for IMAP, except previously we would persist the operation
|
||||
* and then mark it moot at 'check' time. Our new technique of not saving
|
||||
* the operation is preferable for disk space reasons. (NB: We could
|
||||
* persist it, but the composite Blob we build would be flattened which
|
||||
* could generate an I/O storm, cause temporary double-storage use, etc.)
|
||||
*/
|
||||
saveSentMessage: function(composer) {
|
||||
if (this.sentMessagesAutomaticallyAppearInSentFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
composer.withMessageBlob({ includeBcc: true }, function(blob) {
|
||||
var message = {
|
||||
messageText: blob,
|
||||
// do not specify date; let the server use its own timestamping
|
||||
// since we want the approximate value of 'now' anyways.
|
||||
flags: ['\\Seen'],
|
||||
};
|
||||
|
||||
var sentFolder = this.getFirstFolderWithType('sent');
|
||||
if (sentFolder) {
|
||||
this.universe.appendMessages(sentFolder.id,
|
||||
[message]);
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
shutdown: function(callback) {
|
||||
CompositeIncomingAccount.prototype.shutdownFolders.call(this);
|
||||
|
||||
this._backoffEndpoint.shutdown();
|
||||
|
||||
// - close all connections
|
||||
var liveConns = this._ownedConns.length;
|
||||
function connDead() {
|
||||
if (--liveConns === 0)
|
||||
callback();
|
||||
}
|
||||
for (var i = 0; i < this._ownedConns.length; i++) {
|
||||
var connInfo = this._ownedConns[i];
|
||||
if (callback) {
|
||||
connInfo.inUseBy = { deathback: connDead };
|
||||
try {
|
||||
connInfo.conn.client.close();
|
||||
}
|
||||
catch (ex) {
|
||||
liveConns--;
|
||||
}
|
||||
}
|
||||
else {
|
||||
connInfo.conn.client.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!liveConns && callback)
|
||||
callback();
|
||||
},
|
||||
|
||||
checkAccount: function(listener) {
|
||||
logic(this, 'checkAccount_begin');
|
||||
this._makeConnection(function(err) {
|
||||
logic(this, 'checkAccount_end', { error: err });
|
||||
listener(err);
|
||||
}.bind(this), null, 'check');
|
||||
},
|
||||
|
||||
accountDeleted: function() {
|
||||
this._alive = false;
|
||||
this.shutdown();
|
||||
},
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
};
|
||||
|
||||
// XXX: Use mix.js when it lands in the streaming patch.
|
||||
for (var k in properties) {
|
||||
Object.defineProperty(ImapAccount.prototype, k,
|
||||
Object.getOwnPropertyDescriptor(properties, k));
|
||||
}
|
||||
|
||||
}); // end define
|
||||
298
gaia/apps/email/js/ext/imap/client.js
Executable file
298
gaia/apps/email/js/ext/imap/client.js
Executable file
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* imap/client.js: Wraps IMAP connection creation, to avoid redundancy
|
||||
* between connection-related setup in imap/account.js and
|
||||
* imap/probe.js.
|
||||
*/
|
||||
define(function(require, exports) {
|
||||
|
||||
var BrowserBox = require('browserbox');
|
||||
var ImapClient = require('browserbox-imap');
|
||||
var imapHandler = require('imap-handler');
|
||||
var logic = require('logic');
|
||||
var syncbase = require('../syncbase');
|
||||
var errorutils = require('../errorutils');
|
||||
var oauth = require('../oauth');
|
||||
|
||||
var setTimeout = window.setTimeout;
|
||||
var clearTimeout = window.clearTimeout;
|
||||
|
||||
exports.setTimeoutFunctions = function(setFn, clearFn) {
|
||||
setTimeout = setFn;
|
||||
clearTimeout = clearFn;
|
||||
};
|
||||
|
||||
function noop() {
|
||||
// nothing
|
||||
}
|
||||
|
||||
var scope = logic.scope('ImapClient');
|
||||
|
||||
/**
|
||||
* Open a connection to an IMAP server.
|
||||
*
|
||||
* @param {object} credentials
|
||||
* keys: username, password, [oauth2]
|
||||
* @param {object} connInfo
|
||||
* keys: hostname, port, crypto
|
||||
* @param {function(credentials)} credsUpdatedCallback
|
||||
* Callback, called if the credentials have been updated and
|
||||
* should be stored to disk. Not called if the credentials are
|
||||
* already up-to-date.
|
||||
* @return {Promise}
|
||||
* resolve => {BrowserBox} conn
|
||||
* reject => {String} normalized String error
|
||||
*/
|
||||
|
||||
exports.createImapConnection = function(credentials, connInfo,
|
||||
credsUpdatedCallback) {
|
||||
var conn;
|
||||
|
||||
return oauth.ensureUpdatedCredentials(credentials, credsUpdatedCallback)
|
||||
.then(function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
conn = new BrowserBox(
|
||||
connInfo.hostname,
|
||||
connInfo.port, {
|
||||
auth: {
|
||||
user: credentials.username,
|
||||
pass: credentials.password,
|
||||
xoauth2: credentials.oauth2 ?
|
||||
credentials.oauth2.accessToken : null
|
||||
},
|
||||
id: {
|
||||
vendor: 'Mozilla',
|
||||
name: 'GaiaMail',
|
||||
version: '0.2',
|
||||
'support-url': 'http://mzl.la/file-gaia-email-bug'
|
||||
},
|
||||
useSecureTransport: (connInfo.crypto === 'ssl' ||
|
||||
connInfo.crypto === true),
|
||||
requireTLS: connInfo.crypto === 'starttls',
|
||||
// In the case no encryption is explicitly requested (either for
|
||||
// testing or because a user regrettably chose to disable it via
|
||||
// manual config), we want to avoid opportunistic encryption
|
||||
// since in the latter case the user may have done this because
|
||||
// the server's certificates are invalid.
|
||||
ignoreTLS: connInfo.crypto === 'plain'
|
||||
});
|
||||
|
||||
var connectTimeout = setTimeout(function() {
|
||||
conn.onerror('unresponsive-server');
|
||||
conn.close();
|
||||
}, syncbase.CONNECT_TIMEOUT_MS);
|
||||
|
||||
conn.onauth = function() {
|
||||
clearTimeout(connectTimeout);
|
||||
logic(scope, 'connected', { connInfo: connInfo });
|
||||
conn.onauth = conn.onerror = noop;
|
||||
resolve(conn);
|
||||
};
|
||||
conn.onerror = function(err) {
|
||||
clearTimeout(connectTimeout);
|
||||
// XXX: if error is just expired access token, try to refresh one time
|
||||
reject(err);
|
||||
};
|
||||
|
||||
conn.connect();
|
||||
});
|
||||
}).catch(function(errorObject) {
|
||||
var errorString = normalizeImapError(conn, errorObject);
|
||||
if (conn) {
|
||||
conn.close();
|
||||
}
|
||||
|
||||
// Could hit an oauth reauth case due to date skews, so give a token
|
||||
// review a shot before really bailing.
|
||||
if (errorString === 'needs-oauth-reauth' &&
|
||||
oauth.isRenewPossible(credentials)) {
|
||||
return oauth.ensureUpdatedCredentials(credentials,
|
||||
credsUpdatedCallback, true)
|
||||
.then(function() {
|
||||
return exports.createImapConnection(credentials, connInfo,
|
||||
credsUpdatedCallback);
|
||||
});
|
||||
} else {
|
||||
logic(scope, 'connect-error', {
|
||||
error: errorString
|
||||
});
|
||||
throw errorString;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//****************************************************************
|
||||
// UNFORTUNATE IMAP WORKAROUNDS & SHIMS BEGIN HERE
|
||||
//----------------------------------------------------------------
|
||||
|
||||
// ImapClient (from BrowserBox) doesn't pass along any useful error
|
||||
// information other than a human-readable string, and even then,
|
||||
// not always reliably. When we receive an IMAP protocol error
|
||||
// response form the server, all of the data we care about is in the
|
||||
// most recent 'NO' or 'BAD' response line. Until we can add
|
||||
// improved error handling upstream, cache the most recent error
|
||||
// response from the IMAP server so that we can extract detailed
|
||||
// error codes when we handle error events from the IMAP library.
|
||||
var processResponse = ImapClient.prototype._processResponse;
|
||||
ImapClient.prototype._processResponse = function(response) {
|
||||
processResponse.apply(this, arguments);
|
||||
|
||||
var cmd = (response && response.command || '').toString()
|
||||
.toUpperCase().trim();
|
||||
|
||||
if (['NO', 'BAD'].indexOf(cmd) !== -1) {
|
||||
logic(scope, 'protocol-error', {
|
||||
humanReadable: response.humanReadable,
|
||||
responseCode: response.code,
|
||||
// Include the command structure
|
||||
commandData: this._currentCommand && this._currentCommand.request &&
|
||||
imapHandler.compiler(this._currentCommand.request)
|
||||
});
|
||||
this._lastImapError = {
|
||||
// To most accurately report STARTTLS issues, latch the active command
|
||||
// at the time of the failure rather than just the response. (An evil
|
||||
// attacker could say "NO SUCKER" instead of "NO STARTTLS" or
|
||||
// something.)
|
||||
command: this._currentCommand,
|
||||
response: response
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ImapClient passes data directly into the `new Error()`
|
||||
// constructor, which causes err.message to equal "[Object object]"
|
||||
// rather than the actual error object with details. This is just a
|
||||
// copy of that function, with the `new Error` constructor stripped
|
||||
// out so that the error details pass through to onerror.
|
||||
ImapClient.prototype._onError = function(evt) {
|
||||
if (this.isError(evt)) {
|
||||
this.onerror(evt);
|
||||
} else if (evt && this.isError(evt.data)) {
|
||||
this.onerror(evt.data);
|
||||
} else {
|
||||
this.onerror(evt && evt.data && evt.data.message ||
|
||||
evt.data || evt || 'Error');
|
||||
}
|
||||
|
||||
this.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an error possibly generated by the IMAP client, analyze it
|
||||
* and convert it to a normalized string if possible. Otherwise,
|
||||
* return null.
|
||||
*/
|
||||
function analyzeLastImapError(lastErrInfo, conn) {
|
||||
// Make sure it's an error we know how to analyze:
|
||||
if (!lastErrInfo || !lastErrInfo.response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the most recent command was to initiate STARTTLS, then this is a
|
||||
// security error.
|
||||
if (lastErrInfo.command && lastErrInfo.command.request &&
|
||||
lastErrInfo.command.request.command === 'STARTTLS') {
|
||||
return 'bad-security';
|
||||
}
|
||||
|
||||
var wasOauth = conn && !!conn.options.auth.xoauth2;
|
||||
|
||||
// Structure of an IMAP error response:
|
||||
// { "tag":"W2",
|
||||
// "command": "NO",
|
||||
// "code": "AUTHENTICATIONFAILED",
|
||||
// "attributes": [
|
||||
// {"type":"TEXT","value":"invalid password"}
|
||||
// ],
|
||||
// "humanReadable": "invalid password" }
|
||||
|
||||
// Dovecot says after a delay and does not terminate the connection:
|
||||
// NO [AUTHENTICATIONFAILED] Authentication failed.
|
||||
// Zimbra 7.2.x says after a delay and DOES terminate the connection:
|
||||
// NO LOGIN failed
|
||||
// * BYE Zimbra IMAP server terminating connection
|
||||
// Yahoo says after a delay and does not terminate the connection:
|
||||
// NO [AUTHENTICATIONFAILED] Incorrect username or password.
|
||||
|
||||
var err = lastErrInfo.response;
|
||||
var str = (err.code || '') + (err.humanReadable || '');
|
||||
|
||||
if (/Your account is not enabled for IMAP use/.test(str) ||
|
||||
/IMAP access is disabled for your domain/.test(str)) {
|
||||
return 'imap-disabled';
|
||||
} else if (/UNAVAILABLE/.test(str)) {
|
||||
return 'server-maintenance';
|
||||
// If login failed and LOGINDISABLED was claimed, then it's
|
||||
// server-maintenance. We used to be more aggressive about
|
||||
// LOGINDISABLED and would just give up if we saw it, but we had a
|
||||
// bad regression, so the balance has tipped here. Additionally, it
|
||||
// makes a lot of sense to only report the error if we actually failed
|
||||
// to login!
|
||||
} else if (conn.capability.indexOf('LOGINDISABLED') != -1 &&
|
||||
!conn.authenticated) {
|
||||
return 'server-maintenance';
|
||||
// The invalid-credentials case goes last, because we
|
||||
// optimistically assume that any other not-authenticated failure
|
||||
// is caused by the user's invalid credentials.
|
||||
} else if (/AUTHENTICATIONFAILED/.test(str) ||
|
||||
/Invalid credentials/i.test(str) || // Gmail bad access token
|
||||
/login failed/i.test(str) ||
|
||||
/password/.test(str) ||
|
||||
// We can't trust state since it gets updated on close, but
|
||||
// authenticated latches to true and stays that way.
|
||||
!conn.authenticated) {
|
||||
// If we got a protocol-level error but we weren't authenticated
|
||||
// yet, it's likely an authentication problem, as authenticating
|
||||
// is the first thing we do. Any other socket-level connection
|
||||
// problems (including STARTTLS, since we pass that along as an
|
||||
// exception) will be surfaced before hitting this conditional.
|
||||
|
||||
if (wasOauth) {
|
||||
// Gmail returns "NO [ALERT] Invalid credentials (Failure)" in
|
||||
// the case of a failed OAUTH password.
|
||||
return 'needs-oauth-reauth';
|
||||
} else {
|
||||
return 'bad-user-or-pass';
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Here's where the cascades come in: This function accepts an error
|
||||
* of potentially unknown origin. Maybe it's an exception; maybe
|
||||
* it's an IMAP library error with details; maybe it has no useful
|
||||
* details, in which case we can see if the IMAP connection reported
|
||||
* any error responses. No matter what, we take an IMAP-related
|
||||
* error, analyze it, and convert it to our normalized string error
|
||||
* representation.
|
||||
*
|
||||
* @param {BrowserBox} conn
|
||||
* The IMAP connection; necessary if you expect us to analyze
|
||||
* protocol errors
|
||||
* @param {object} err
|
||||
* The exception or error that started all the trouble.
|
||||
*/
|
||||
var normalizeImapError = exports.normalizeImapError = function(conn, err) {
|
||||
var socketLevelError = errorutils.analyzeException(err);
|
||||
var protocolLevelError =
|
||||
conn && analyzeLastImapError(conn.client._lastImapError, conn);
|
||||
|
||||
var reportAs = (socketLevelError ||
|
||||
protocolLevelError ||
|
||||
'unknown');
|
||||
|
||||
logic(scope, 'normalized-error', {
|
||||
error: err,
|
||||
errorName: err && err.name,
|
||||
errorMessage: err && err.message,
|
||||
errorStack: err && err.stack,
|
||||
socketLevelError: socketLevelError,
|
||||
protocolLevelError: protocolLevelError,
|
||||
reportAs: reportAs
|
||||
});
|
||||
|
||||
return reportAs;
|
||||
};
|
||||
|
||||
});
|
||||
1548
gaia/apps/email/js/ext/imap/folder.js
Executable file
1548
gaia/apps/email/js/ext/imap/folder.js
Executable file
File diff suppressed because it is too large
Load diff
610
gaia/apps/email/js/ext/imap/imapchew.js
Executable file
610
gaia/apps/email/js/ext/imap/imapchew.js
Executable file
|
|
@ -0,0 +1,610 @@
|
|||
/**
|
||||
*
|
||||
**/
|
||||
|
||||
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
|
||||
1205
gaia/apps/email/js/ext/imap/jobs.js
Executable file
1205
gaia/apps/email/js/ext/imap/jobs.js
Executable file
File diff suppressed because it is too large
Load diff
60
gaia/apps/email/js/ext/imap/probe.js
Executable file
60
gaia/apps/email/js/ext/imap/probe.js
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Validates connection information for an account and verifies that
|
||||
* the server on the other end is something we are capable of
|
||||
* sustaining an account with.
|
||||
*/
|
||||
|
||||
define([
|
||||
'browserbox',
|
||||
'logic',
|
||||
'./client',
|
||||
'../syncbase',
|
||||
'exports'
|
||||
], function(BrowserBox, logic, imapclient, syncbase, exports) {
|
||||
|
||||
/**
|
||||
* Log in to test credentials, passing the established connection
|
||||
* onward if successful for later reuse.
|
||||
*
|
||||
* @param {object} credentials
|
||||
* keys: hostname, port, crypto
|
||||
* @param {object} connInfo
|
||||
* keys: username, password, xoauth2 (if OAUTH)
|
||||
* @return {Promise}
|
||||
* resolve => { conn: conn }
|
||||
* reject => String (normalized)
|
||||
*/
|
||||
exports.probeAccount = function(credentials, connInfo) {
|
||||
|
||||
var scope = logic.scope('ImapProber');
|
||||
|
||||
logic(scope, 'connecting', { connInfo: connInfo });
|
||||
|
||||
var conn;
|
||||
return imapclient.createImapConnection(
|
||||
credentials,
|
||||
connInfo,
|
||||
function onCredentialsUpdated() {
|
||||
// Normally we shouldn't see a request to update credentials
|
||||
// here, as the caller should have already passed a valid
|
||||
// accessToken during account setup. This might indicate a
|
||||
// problem with our OAUTH handling, so log it just in case.
|
||||
logic(scope, 'credentials-updated');
|
||||
}
|
||||
).then(function(newConn) {
|
||||
conn = newConn;
|
||||
logic(scope, 'success');
|
||||
return { conn: conn };
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Normalize the error before passing it on.
|
||||
err = imapclient.normalizeImapError(conn, err);
|
||||
logic(scope, 'error', { error: err });
|
||||
if (conn) {
|
||||
conn.close();
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
}); // end define
|
||||
86
gaia/apps/email/js/ext/imap/protocol/bodyfetcher.js
Executable file
86
gaia/apps/email/js/ext/imap/protocol/bodyfetcher.js
Executable file
|
|
@ -0,0 +1,86 @@
|
|||
define(function() {
|
||||
|
||||
/**
|
||||
* Convenience class and wrapper around building multiple fetching operations or
|
||||
* the aggregation of many fetching operations into a single unit of
|
||||
* operation...
|
||||
*
|
||||
*
|
||||
* var fetcher = new $bodyfetcher.BodyFetcher(
|
||||
* connection,
|
||||
* BodyParser (or any other kind of parser),
|
||||
* [
|
||||
* { uid: X, partInfo: {}, bytes: [A, B] }
|
||||
* ]
|
||||
* );
|
||||
*
|
||||
* // in all examples item is a single element in the
|
||||
* // array (third argument).
|
||||
*
|
||||
* fetcher.onparsed = function(parsed, item) {}
|
||||
* fetcher.onend = function(err) {}
|
||||
*
|
||||
*/
|
||||
function BodyFetcher(connection, parserClass, list) {
|
||||
this.connection = connection;
|
||||
this.parserClass = parserClass;
|
||||
this.list = list;
|
||||
|
||||
this.pending = list.length;
|
||||
|
||||
this.onparsed = null;
|
||||
this.onend = null;
|
||||
|
||||
list.forEach(this._fetch, this);
|
||||
}
|
||||
|
||||
BodyFetcher.prototype = {
|
||||
_fetch: function(request) {
|
||||
this.connection.listMessages(
|
||||
request.uid,
|
||||
[
|
||||
'BODY.PEEK[' + (request.partInfo.partID || '1') + ']' +
|
||||
(request.bytes ?
|
||||
'<' + request.bytes[0] + '.' + request.bytes[1] + '>' :
|
||||
'')
|
||||
],
|
||||
{ byUid: true },
|
||||
function (err, messages) {
|
||||
if (err) {
|
||||
this._resolve(err, request, null);
|
||||
} else {
|
||||
var parser = new this.parserClass(request.partInfo);
|
||||
var msg = messages[0];
|
||||
var body = null;
|
||||
for (var key in msg) {
|
||||
if (/^body/.test(key)) {
|
||||
body = msg[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
this.resolve('no body', request);
|
||||
} else {
|
||||
parser.parse(body);
|
||||
this._resolve(null, request, parser.complete());
|
||||
}
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
_resolve: function(err, req, result) {
|
||||
if (this.onparsed) {
|
||||
this.onparsed(err, req, result);
|
||||
}
|
||||
if (!--this.pending && this.onend) {
|
||||
this.onend();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
BodyFetcher: BodyFetcher
|
||||
};
|
||||
|
||||
});
|
||||
45
gaia/apps/email/js/ext/imap/protocol/snippetparser.js
Executable file
45
gaia/apps/email/js/ext/imap/protocol/snippetparser.js
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
define(['./textparser'], function($textparser) {
|
||||
|
||||
var TextParser = $textparser.TextParser;
|
||||
|
||||
function bufferAppend(buffer1, buffer2) {
|
||||
var tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
|
||||
tmp.set(new Uint8Array(buffer1), 0);
|
||||
tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
|
||||
return tmp.buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the textparser, accumulates buffer content and returns it as
|
||||
* part of the .complete step.
|
||||
*/
|
||||
function SnippetParser(partDef) {
|
||||
TextParser.apply(this, arguments);
|
||||
}
|
||||
|
||||
SnippetParser.prototype = {
|
||||
parse: function(buffer) {
|
||||
if (!this._buffer) {
|
||||
this._buffer = buffer;
|
||||
} else {
|
||||
this._buffer = bufferAppend(this._buffer, buffer);
|
||||
}
|
||||
|
||||
// do some magic parsing
|
||||
TextParser.prototype.parse.apply(this, arguments);
|
||||
},
|
||||
|
||||
complete: function() {
|
||||
var content =
|
||||
TextParser.prototype.complete.apply(this, arguments);
|
||||
|
||||
content.buffer = this._buffer;
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
SnippetParser: SnippetParser
|
||||
};
|
||||
|
||||
});
|
||||
280
gaia/apps/email/js/ext/imap/protocol/sync.js
Executable file
280
gaia/apps/email/js/ext/imap/protocol/sync.js
Executable file
|
|
@ -0,0 +1,280 @@
|
|||
define(
|
||||
[
|
||||
'../imapchew',
|
||||
'../../allback',
|
||||
'exports'
|
||||
],
|
||||
function(
|
||||
$imapchew,
|
||||
allback,
|
||||
exports
|
||||
) {
|
||||
|
||||
/**
|
||||
* Fetch parameters to get the headers / bodystructure; exists to reuse the
|
||||
* object since every fetch is the same.
|
||||
*
|
||||
* We are intentionally not using ENVELOPE because Thunderbird intentionally
|
||||
* defaults to to not using ENVELOPE. Per bienvenu in
|
||||
* https://bugzilla.mozilla.org/show_bug.cgi?id=402594#c33 "We stopped using it
|
||||
* by default because servers often had issues with it so it was more trouble
|
||||
* than it was worth."
|
||||
*/
|
||||
var INITIAL_FETCH_PARAMS = [
|
||||
'BODYSTRUCTURE',
|
||||
'INTERNALDATE',
|
||||
'FLAGS',
|
||||
'BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC SUBJECT REPLY-TO MESSAGE-ID REFERENCES)]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetch parameters to just get the flags.
|
||||
*/
|
||||
var FLAG_FETCH_PARAMS = ['FLAGS'];
|
||||
|
||||
// Number of bytes to fetch for snippet.
|
||||
var SNIPPET_BYTES = 256;
|
||||
|
||||
// See the `ImapFolderConn` block comment for rationale.
|
||||
var KNOWN_HEADERS_AGGR_COST = 20,
|
||||
KNOWN_HEADERS_PER_COST = 1,
|
||||
NEW_HEADERS_AGGR_COST = 20,
|
||||
NEW_HEADERS_PER_COST = 5,
|
||||
NEW_BODIES_PER_COST = 30;
|
||||
|
||||
/**
|
||||
* Given a list of new-to-us UIDs and known-to-us UIDs and their corresponding
|
||||
* headers, synchronize the flags for the known UIDs' headers and fetch and
|
||||
* create the header and body objects for the new UIDS.
|
||||
*
|
||||
* First we fetch the headers/bodystructures for the new UIDs all in one go;
|
||||
* all of these headers are going to end up in-memory at the same time, so
|
||||
* batching won't let us reduce the overhead right now. We process them
|
||||
* to determine the body parts we should fetch as the results come in. Once
|
||||
* we have them all, we sort them by date, endTS-to-startTS for the third
|
||||
* step and start issuing/pipelining the requests.
|
||||
*
|
||||
* Second, we issue the flag update requests for the known-to-us UIDs. This
|
||||
* is done second so it can help avoid wasting the latency of the round-trip
|
||||
* that would otherwise result between steps one and three. (Although we
|
||||
* could also mitigate that by issuing some step three requests even as
|
||||
* the step one requests are coming in; our sorting doesn't have to be
|
||||
* perfect and may already be reasonably well ordered if UIDs correlate
|
||||
* with internal date well.)
|
||||
*
|
||||
* Third, we fetch the body parts in our newest-to-startTS order, adding
|
||||
* finalized headers and bodies as we go.
|
||||
*
|
||||
* == Usage
|
||||
*
|
||||
* var sync = new ImapUidSync({
|
||||
* initialProgress: 0.25,
|
||||
* connection: (ImapConnection),
|
||||
*
|
||||
* storage: (FolderStorage),
|
||||
*
|
||||
* knownHeaders: [],
|
||||
*
|
||||
* knownUIDs: [],
|
||||
* newUIDs: []
|
||||
* });
|
||||
*
|
||||
*/
|
||||
function Sync(options) {
|
||||
// storage and connections
|
||||
this.storage = options.storage;
|
||||
this.connection = options.connection;
|
||||
|
||||
this.knownHeaders = options.knownHeaders || [];
|
||||
|
||||
this.knownUIDs = options.knownUIDs || [];
|
||||
this.newUIDs = options.newUIDs || [];
|
||||
|
||||
this._progress = options.initialProgress || 0.25;
|
||||
|
||||
this._progressCost =
|
||||
(this.knownUIDs.length ? KNOWN_HEADERS_AGGR_COST : 0) +
|
||||
KNOWN_HEADERS_PER_COST * this.knownUIDs.length +
|
||||
(this.newUIDs.length ? NEW_HEADERS_AGGR_COST : 0) +
|
||||
NEW_HEADERS_PER_COST * this.newUIDs.length;
|
||||
|
||||
// events
|
||||
this.onprogress = null;
|
||||
this.oncomplete = null;
|
||||
|
||||
this._beginSync();
|
||||
}
|
||||
|
||||
Sync.prototype = {
|
||||
_updateProgress: function(newProgress) {
|
||||
this._progress += newProgress;
|
||||
|
||||
if (this.onprogress) {
|
||||
this.onprogress(
|
||||
0.25 + 0.75 * (this._progress / this._progressCost)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
_beginSync: function() {
|
||||
var latch = allback.latch();
|
||||
|
||||
if (this.newUIDs.length) {
|
||||
this._handleNewUids(latch.defer('new'));
|
||||
}
|
||||
|
||||
if (this.knownUIDs.length) {
|
||||
this._handleKnownUids(latch.defer('known'));
|
||||
}
|
||||
|
||||
latch.then(function() {
|
||||
if (!this.oncomplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.oncomplete(this.newUIDs.length, this.knownUIDs.length);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
_handleNewUids: function(callback) {
|
||||
var pendingSnippets = [];
|
||||
var self = this;
|
||||
|
||||
this.connection.listMessages(
|
||||
this.newUIDs,
|
||||
INITIAL_FETCH_PARAMS,
|
||||
{ byUid: true },
|
||||
function (err, messages) {
|
||||
if (err) {
|
||||
// XXX the UID might have disappeared already? we might need to have
|
||||
// our initiating command re-do whatever it's up to. Alternatively,
|
||||
// we could drop back from a bulk fetch to a one-by-one fetch.
|
||||
console.warn('New UIDs fetch error, ideally harmless:', err);
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
var latch = allback.latch();
|
||||
messages.forEach(function(msg) {
|
||||
// Filter out the \Recent flag; our old imap library didn't
|
||||
// pass it along (complaining about it being useless), so we
|
||||
// don't either.
|
||||
var recentIdx = msg.flags.indexOf('\\Recent');
|
||||
if (recentIdx !== -1) {
|
||||
msg.flags.splice(recentIdx, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
// at this point we can build the first batch of header/body
|
||||
// information.
|
||||
var chewRep = $imapchew.chewHeaderAndBodyStructure(
|
||||
msg,
|
||||
self.storage.folderId,
|
||||
self.storage._issueNewHeaderId()
|
||||
);
|
||||
|
||||
chewRep.header.bytesToDownloadForBodyDisplay =
|
||||
$imapchew.calculateBytesToDownloadForImapBodyDisplay(
|
||||
chewRep.bodyInfo);
|
||||
|
||||
pendingSnippets.push(chewRep);
|
||||
|
||||
// flush our body/header information ? should we do some sorting,
|
||||
// etc.. here or just let the UI update ASAP?
|
||||
self.storage.addMessageHeader(
|
||||
chewRep.header, chewRep.bodyInfo, latch.defer());
|
||||
self.storage.addMessageBody(
|
||||
chewRep.header, chewRep.bodyInfo, latch.defer());
|
||||
}
|
||||
catch (ex) {
|
||||
// it's fine for us to not add bad messages to the database
|
||||
// XXX do plumb the logger through here eventually
|
||||
console.warn('message problem, skipping message', ex, '\n',
|
||||
ex.stack);
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
latch.then(callback); // our caller doesn't care about its args
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
_handleKnownUids: function(callback) {
|
||||
var self = this;
|
||||
this.connection.listMessages(
|
||||
self.knownUIDs,
|
||||
FLAG_FETCH_PARAMS,
|
||||
{ byUid: true },
|
||||
function(err, messages) {
|
||||
if (err) {
|
||||
console.warn('Known UIDs fetch error, ideally harmless:', err);
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
var latch = allback.latch();
|
||||
messages.forEach(function(msg, i) {
|
||||
console.log('FETCHED', i, 'known id', self.knownHeaders[i].id,
|
||||
'known srvid', self.knownHeaders[i].srvid,
|
||||
'actual id', msg.uid);
|
||||
|
||||
// Filter out the \Recent flag; our old imap library didn't
|
||||
// pass it along (complaining about it being useless), so we
|
||||
// don't either.
|
||||
var recentIdx = msg.flags.indexOf('\\Recent');
|
||||
if (recentIdx !== -1) {
|
||||
msg.flags.splice(recentIdx, 1);
|
||||
}
|
||||
|
||||
// RFC 3501 doesn't require that we get results in the order we
|
||||
// request them, so use indexOf if things don't line up. (In fact,
|
||||
// dovecot sorts them, so we might just want to sort ours too.)
|
||||
if (self.knownHeaders[i].srvid !== msg.uid) {
|
||||
i = self.knownUIDs.indexOf(msg.uid);
|
||||
// If it's telling us about a message we don't know about, run away.
|
||||
if (i === -1) {
|
||||
console.warn("Server fetch reports unexpected message:", msg.uid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
var header = self.knownHeaders[i];
|
||||
|
||||
// msg.flags is not sorted in browserbox; sort here before comparing.
|
||||
var sortedExistingFlags = header.flags.slice();
|
||||
sortedExistingFlags.sort();
|
||||
msg.flags.sort();
|
||||
|
||||
// Update number of unread messages
|
||||
if (header.flags.indexOf('\\Seen') === -1 &&
|
||||
msg.flags.indexOf('\\Seen') !== -1) {
|
||||
self.storage.folderMeta.unreadCount--;
|
||||
} else if (header.flags.indexOf('\\Seen') !== -1 &&
|
||||
msg.flags.indexOf('\\Seen') === -1) {
|
||||
self.storage.folderMeta.unreadCount++;
|
||||
}
|
||||
|
||||
if (header.flags.toString() !== msg.flags.toString()) {
|
||||
console.warn(' FLAGS: "' + header.flags.toString() + '" VS "' +
|
||||
msg.flags.toString() + '"');
|
||||
header.flags = msg.flags;
|
||||
self.storage.updateMessageHeader(
|
||||
header.date, header.id, true, header, /* body hint */ null,
|
||||
latch.defer());
|
||||
}
|
||||
else {
|
||||
self.storage.unchangedMessageHeader(header);
|
||||
}
|
||||
});
|
||||
|
||||
// the fetch results will be bursty, so just update all at once
|
||||
self._updateProgress(KNOWN_HEADERS_AGGR_COST +
|
||||
KNOWN_HEADERS_PER_COST * self.knownUIDs.length);
|
||||
|
||||
latch.then(callback); // our caller doesn't care about its args
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
exports.Sync = Sync;
|
||||
|
||||
});
|
||||
79
gaia/apps/email/js/ext/imap/protocol/textparser.js
Executable file
79
gaia/apps/email/js/ext/imap/protocol/textparser.js
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
define(
|
||||
[
|
||||
'mimeparser',
|
||||
'mimefuncs',
|
||||
'exports'
|
||||
],
|
||||
function(
|
||||
MimeParser,
|
||||
mimefuncs,
|
||||
exports
|
||||
) {
|
||||
|
||||
|
||||
/**
|
||||
* Simple wrapper around mimeparser hacks allows us to reuse data from the
|
||||
* BODYSTRUCT request that contained the mime type, etc....
|
||||
*
|
||||
* var parser = $textparser.TextParser(
|
||||
* bodyInfo.bodyReps[n]
|
||||
* );
|
||||
*
|
||||
* // msg is some stream thing from fetcher
|
||||
*
|
||||
* msg.on('data', parser.parse.bind(parser));
|
||||
* msg.on('end', function() {
|
||||
* var content = parser.complete();
|
||||
* });
|
||||
*
|
||||
*/
|
||||
function TextParser(partDef) {
|
||||
this._partDef = partDef;
|
||||
var parser = this._parser = new MimeParser();
|
||||
this._totalBytes = 0;
|
||||
|
||||
// TODO: escape?
|
||||
var charsetPart = '', formatPart = '';
|
||||
if (partDef.params && partDef.params.charset) {
|
||||
charsetPart = '; charset="' + partDef.params.charset.toLowerCase() + '"';
|
||||
}
|
||||
|
||||
if (partDef.params && partDef.params.format) {
|
||||
formatPart = '; format="' + partDef.params.format.toLowerCase() + '"';
|
||||
}
|
||||
parser.write('Content-Type: ' + partDef.type.toLowerCase() +
|
||||
'/' + partDef.subtype.toLowerCase() + charsetPart +
|
||||
formatPart + '\r\n');
|
||||
|
||||
if (partDef.encoding) {
|
||||
parser.write('Content-Transfer-Encoding: ' + partDef.encoding + '\r\n');
|
||||
}
|
||||
|
||||
parser.write('\r\n'); // Finish headers.
|
||||
|
||||
if (partDef.pendingBuffer) {
|
||||
this.parse(partDef.pendingBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
TextParser.prototype = {
|
||||
parse: function(buffer) {
|
||||
this._totalBytes += buffer.length;
|
||||
this._parser.write(buffer);
|
||||
},
|
||||
|
||||
complete: function() {
|
||||
this._parser.end();
|
||||
|
||||
var str = mimefuncs.charset.decode(this._parser.node.content, 'utf-8');
|
||||
|
||||
return {
|
||||
bytesFetched: this._totalBytes,
|
||||
text: str
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
exports.TextParser = TextParser;
|
||||
|
||||
});
|
||||
Loading…
Reference in a new issue