Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
3887 lines
121 KiB
JavaScript
Executable file
3887 lines
121 KiB
JavaScript
Executable file
/**
|
|
*
|
|
**/
|
|
|
|
define(
|
|
[
|
|
'exports',
|
|
'./logic',
|
|
// Use a relative link so that consumers do not need to create
|
|
// special config to use main-frame-setup.
|
|
'./ext/addressparser'
|
|
],
|
|
function(
|
|
exports,
|
|
logic,
|
|
addressparser
|
|
) {
|
|
|
|
function objCopy(obj) {
|
|
var copy = {};
|
|
Object.keys(obj).forEach(function (key) {
|
|
copy[key] = obj[key];
|
|
});
|
|
return copy;
|
|
}
|
|
|
|
/**
|
|
* Error reporting helper; we will probably eventually want different behaviours
|
|
* under development, under unit test, when in use by QA, advanced users, and
|
|
* normal users, respectively. By funneling all errors through one spot, we
|
|
* help reduce inadvertent breakage later on.
|
|
*/
|
|
function reportError() {
|
|
var msg = null;
|
|
for (var i = 0; i < arguments.length; i++) {
|
|
if (msg)
|
|
msg += " " + arguments[i];
|
|
else
|
|
msg = "" + arguments[i];
|
|
}
|
|
// When in tests, this will fail the test; when not in tests, we just log.
|
|
logic.fail(new Error(msg));
|
|
}
|
|
var unexpectedBridgeDataError = reportError,
|
|
internalError = reportError,
|
|
reportClientCodeError = reportError;
|
|
|
|
/**
|
|
* The number of header wire messages to cache in the recvCache
|
|
*/
|
|
var HEADER_CACHE_LIMIT = 8;
|
|
|
|
/**
|
|
*
|
|
*/
|
|
function MailAccount(api, wireRep, acctsSlice) {
|
|
this._api = api;
|
|
this.id = wireRep.id;
|
|
|
|
// Hold on to wireRep for caching
|
|
this._wireRep = wireRep;
|
|
|
|
// Hold on to acctsSlice for use in determining default account.
|
|
this.acctsSlice = acctsSlice;
|
|
|
|
this.type = wireRep.type;
|
|
this.name = wireRep.name;
|
|
this.syncRange = wireRep.syncRange;
|
|
this.syncInterval = wireRep.syncInterval;
|
|
this.notifyOnNew = wireRep.notifyOnNew;
|
|
this.playSoundOnSend = wireRep.playSoundOnSend;
|
|
|
|
/**
|
|
* Is the account currently enabled, as in will we talk to the server?
|
|
* Accounts will be automatically disabled in cases where it would be
|
|
* counter-productive for us to keep trying to access the server.
|
|
*
|
|
* For example: the user's password being (apparently) bad, or gmail getting
|
|
* upset about the amount of data transfer and locking the account out for the
|
|
* rest of the day.
|
|
*/
|
|
this.enabled = wireRep.enabled;
|
|
/**
|
|
* @listof[@oneof[
|
|
* @case['bad-user-or-pass']
|
|
* @case['bad-address']
|
|
* @case['needs-oauth-reauth']
|
|
* @case['imap-disabled']
|
|
* @case['pop-server-not-great']{
|
|
* The POP3 server doesn't support IDLE and TOP, so we can't use it.
|
|
* }
|
|
* @case['connection']{
|
|
* Generic connection problem; this problem can quite possibly be present
|
|
* in conjunction with more specific problems such as a bad username /
|
|
* password.
|
|
* }
|
|
* ]]{
|
|
* A list of known problems with the account which explain why the account
|
|
* might not be `enabled`. Once a problem is believed to have been
|
|
* addressed, `clearProblems` should be called.
|
|
* }
|
|
*/
|
|
this.problems = wireRep.problems;
|
|
|
|
this.identities = [];
|
|
for (var iIdent = 0; iIdent < wireRep.identities.length; iIdent++) {
|
|
this.identities.push(new MailSenderIdentity(this._api,
|
|
wireRep.identities[iIdent]));
|
|
}
|
|
|
|
this.username = wireRep.credentials.username;
|
|
this.servers = wireRep.servers;
|
|
|
|
this.authMechanism = wireRep.credentials.oauth2 ? 'oauth2' : 'password';
|
|
|
|
// build a place for the DOM element and arbitrary data into our shape
|
|
this.element = null;
|
|
this.data = null;
|
|
}
|
|
MailAccount.prototype = {
|
|
toString: function() {
|
|
return '[MailAccount: ' + this.type + ' ' + this.id + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'MailAccount',
|
|
accountType: this.type,
|
|
id: this.id,
|
|
};
|
|
},
|
|
|
|
__update: function(wireRep) {
|
|
this.enabled = wireRep.enabled;
|
|
this.problems = wireRep.problems;
|
|
this.syncRange = wireRep.syncRange;
|
|
this.syncInterval = wireRep.syncInterval;
|
|
this.notifyOnNew = wireRep.notifyOnNew;
|
|
this.playSoundOnSend = wireRep.playSoundOnSend;
|
|
this._wireRep.defaultPriority = wireRep.defaultPriority;
|
|
|
|
for (var i = 0; i < wireRep.identities.length; i++) {
|
|
if (this.identities[i]) {
|
|
this.identities[i].__update(wireRep.identities[i]);
|
|
} else {
|
|
this.identities.push(new MailSenderIdentity(this._api,
|
|
wireRep.identities[i]));
|
|
}
|
|
}
|
|
},
|
|
|
|
__die: function() {
|
|
// currently, nothing to clean up
|
|
},
|
|
|
|
/**
|
|
* Tell the back-end to clear the list of problems with the account, re-enable
|
|
* it, and try and connect.
|
|
*/
|
|
clearProblems: function(callback) {
|
|
this._api._clearAccountProblems(this, callback);
|
|
},
|
|
|
|
/**
|
|
* @args[
|
|
* @param[mods @dict[
|
|
* @key[password String]
|
|
* @key[incomingPassword String]
|
|
* @key[outgoingPassword String]
|
|
* @key[username String]
|
|
* @key[incomingUsername String]
|
|
* @key[outgoingUsername String]
|
|
* ]]
|
|
* @param[callback function]
|
|
* ]{
|
|
* Modify properties on the account.
|
|
*
|
|
* In addition to regular account property settings,
|
|
* "setAsDefault": true can be passed to set this account as the
|
|
* default acccount.
|
|
*
|
|
* # Username and Password Setting
|
|
*
|
|
* If you want to modify the username or password of an account,
|
|
* keep in mind that IMAP/POP3 accounts might have two separate
|
|
* passwords, one for incoming mail and one for SMTP. You have a
|
|
* couple options:
|
|
*
|
|
* - If you specify "username" and/or "password", we'll change the
|
|
* incoming side, and if the SMTP side had the same
|
|
* username/password, we'll change that too.
|
|
*
|
|
* - If you specify incomingUsername, incomingPassword, etc., we
|
|
* will NOT do that magic inferring; we'll just change the side
|
|
* you specify.
|
|
*
|
|
* Practically speaking, most accounts will likely share the same
|
|
* username and password. Additionally, if we guess that the
|
|
* passwords/usernames should match when they actually should
|
|
* differ, we'll safely recover becuase we'll then ask for a
|
|
* corrected SMTP password.
|
|
* }
|
|
*/
|
|
modifyAccount: function(mods, callback) {
|
|
this._api._modifyAccount(this, mods, callback);
|
|
},
|
|
|
|
/**
|
|
* Delete the account and all its associated data. No privacy guarantees are
|
|
* provided; we just delete the data from the database, so it's up to the
|
|
* (IndexedDB) database's guarantees on that.
|
|
*/
|
|
deleteAccount: function() {
|
|
this._api._deleteAccount(this);
|
|
},
|
|
|
|
/**
|
|
* Returns true if this account is the default account, by looking at
|
|
* all accounts in the acctsSlice.
|
|
*/
|
|
get isDefault() {
|
|
if (!this.acctsSlice)
|
|
throw new Error('No account slice available');
|
|
|
|
return this.acctsSlice.defaultAccount === this;
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Sender identities define one of many possible sets of sender info and are
|
|
* associated with a single `MailAccount`.
|
|
*
|
|
* Things that can vary:
|
|
* - user's display name
|
|
* - e-mail address,
|
|
* - reply-to address
|
|
* - signature
|
|
*/
|
|
function MailSenderIdentity(api, wireRep) {
|
|
// We store the API so that we can create identities for the composer without
|
|
// needing to create an account too.
|
|
this._api = api;
|
|
this.id = wireRep.id;
|
|
|
|
this.name = wireRep.name;
|
|
this.address = wireRep.address;
|
|
this.replyTo = wireRep.replyTo;
|
|
this.signature = wireRep.signature;
|
|
this.signatureEnabled = wireRep.signatureEnabled;
|
|
|
|
}
|
|
MailSenderIdentity.prototype = {
|
|
toString: function() {
|
|
return '[MailSenderIdentity: ' + this.type + ' ' + this.id + ']';
|
|
},
|
|
toJSON: function() {
|
|
return { type: 'MailSenderIdentity' };
|
|
},
|
|
|
|
__update: function(wireRep) {
|
|
this.id = wireRep.id;
|
|
this.name = wireRep.name;
|
|
this.address = wireRep.address;
|
|
this.replyTo = wireRep.replyTo;
|
|
this.signature = wireRep.signature;
|
|
this.signatureEnabled = wireRep.signatureEnabled;
|
|
},
|
|
/**
|
|
* Modifies the identity. Applies all of the changes in mods
|
|
* and leaves all other values the same.
|
|
*
|
|
* @param {Object} mods The changes to be applied
|
|
* @param {Function} callback
|
|
*/
|
|
modifyIdentity: function(mods, callback) {
|
|
// These update signature data immediately, so that the UI
|
|
// reflects the changes properly before the backend properly
|
|
// updates the data
|
|
if (typeof mods.signature !== 'undefined') {
|
|
this.signature = mods.signature;
|
|
}
|
|
if (typeof mods.signatureEnabled !== 'undefined') {
|
|
this.signatureEnabled = mods.signatureEnabled;
|
|
}
|
|
this._api._modifyIdentity(this, mods, callback);
|
|
},
|
|
|
|
__die: function() {
|
|
// nothing to clean up currently
|
|
},
|
|
};
|
|
// For testing
|
|
exports._MailFolder = MailFolder;
|
|
|
|
function MailFolder(api, wireRep) {
|
|
this._api = api;
|
|
|
|
this.__update(wireRep);
|
|
|
|
this.onchange = null;
|
|
this.onremove = null;
|
|
|
|
// build a place for the DOM element and arbitrary data into our shape
|
|
this.element = null;
|
|
this.data = null;
|
|
}
|
|
MailFolder.prototype = {
|
|
toString: function() {
|
|
return '[MailFolder: ' + this.path + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: this.type,
|
|
path: this.path
|
|
};
|
|
},
|
|
/**
|
|
* Loads the current unread message count as reported by the FolderStorage backend.
|
|
* this.unread is the current number of unread messages that are stored within the
|
|
* FolderStorage object for this folder. Thus, it only accounts for messages
|
|
* which the user has loaded from the server.
|
|
*/
|
|
__update: function(wireRep) {
|
|
// Hold on to wireRep for caching
|
|
this._wireRep = wireRep;
|
|
|
|
this.unread = wireRep.unreadCount;
|
|
|
|
this.lastSyncedAt = wireRep.lastSyncedAt ? new Date(wireRep.lastSyncedAt)
|
|
: null;
|
|
this.path = wireRep.path;
|
|
this.id = wireRep.id;
|
|
|
|
/**
|
|
* The human-readable name of the folder. (As opposed to its path or the
|
|
* modified utf-7 encoded folder names.)
|
|
*/
|
|
this.name = wireRep.name;
|
|
/**
|
|
* The full string of the path.
|
|
*/
|
|
this.path = wireRep.path;
|
|
/**
|
|
* The hierarchical depth of this folder.
|
|
*/
|
|
this.depth = wireRep.depth;
|
|
/**
|
|
* @oneof[
|
|
* @case['account']{
|
|
* It's not really a folder at all, just an account serving as hierarchy
|
|
* }
|
|
* @case['nomail']{
|
|
* A folder that exists only to provide hierarchy but which can't
|
|
* contain messages. An artifact of various mail backends that are
|
|
* reflected in IMAP as NOSELECT.
|
|
* }
|
|
* @case['inbox']
|
|
* @case['drafts']
|
|
* @case['localdrafts']{
|
|
* Local-only folder that stores drafts composed on this device.
|
|
* }
|
|
* @case['sent']
|
|
* @case['trash']
|
|
* @case['archive']
|
|
* @case['junk']
|
|
* @case['starred']
|
|
* @case['important']
|
|
* @case['normal']{
|
|
* A traditional mail folder with nothing special about it.
|
|
* }
|
|
* ]{
|
|
* Non-localized string indicating the type of folder this is, primarily
|
|
* for styling purposes.
|
|
* }
|
|
*/
|
|
this.type = wireRep.type;
|
|
|
|
// Exchange folder name with the localized version if available
|
|
this.name = this._api.l10n_folder_name(this.name, this.type);
|
|
|
|
this.selectable = ((wireRep.type !== 'account') &&
|
|
(wireRep.type !== 'nomail'));
|
|
|
|
this.neededForHierarchy = !this.selectable;
|
|
|
|
/**
|
|
* isValidMoveTarget denotes whether this folder is a valid
|
|
* place for messages to be moved into.
|
|
*/
|
|
switch (this.type) {
|
|
case 'localdrafts':
|
|
case 'outbox':
|
|
case 'account':
|
|
case 'nomail':
|
|
this.isValidMoveTarget = false;
|
|
break;
|
|
default:
|
|
this.isValidMoveTarget = true;
|
|
}
|
|
},
|
|
|
|
__die: function() {
|
|
// currently nothing to clean up
|
|
}
|
|
};
|
|
|
|
function filterOutBuiltinFlags(flags) {
|
|
// so, we could mutate in-place if we were sure the wire rep actually came
|
|
// over the wire. Right now there is de facto rep sharing, so let's not
|
|
// mutate and screw ourselves over.
|
|
var outFlags = [];
|
|
for (var i = flags.length - 1; i >= 0; i--) {
|
|
if (flags[i][0] !== '\\')
|
|
outFlags.push(flags[i]);
|
|
}
|
|
return outFlags;
|
|
}
|
|
|
|
/**
|
|
* Extract the canonical naming attributes out of the MailHeader instance.
|
|
*/
|
|
function serializeMessageName(x) {
|
|
return {
|
|
date: x.date.valueOf(),
|
|
suid: x.id,
|
|
// NB: strictly speaking, this is redundant information. However, it is
|
|
// also fairly handy to pass around for IMAP since otherwise we might need
|
|
// to perform header lookups later on. It will likely also be useful for
|
|
// debugging. But ideally we would not include this.
|
|
guid: x.guid
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Caches contact lookups, both hits and misses, as well as updating the
|
|
* MailPeep instances returned by resolve calls.
|
|
*
|
|
* We maintain strong maps from both contact id and e-mail address to MailPeep
|
|
* instances. We hold a strong reference because BridgedViewSlices already
|
|
* require explicit lifecycle maintenance (aka call die() when done with them).
|
|
* We need the contact id and e-mail address because when a contact is changed,
|
|
* an e-mail address may be changed, and we don't get to see the old
|
|
* representation. So if the e-mail address was deleted, we need the contact id
|
|
* mapping. And if the e-mail address was added, we need the e-mail address
|
|
* mapping.
|
|
*
|
|
* If the mozContacts API is not available, we just create inert MailPeep
|
|
* instances that do not get tracked or updated.
|
|
*
|
|
* Domain notes:
|
|
*
|
|
* The contacts API does not enforce any constraints on the number of contacts
|
|
* who can use an e-mail address, but the e-mail app only allows one contact
|
|
* to correspond to an e-mail address at a time.
|
|
*/
|
|
var ContactCache = exports.ContactCache = {
|
|
/**
|
|
* Maps e-mail addresses to the mozContact rep for the object, or null if
|
|
* there was a miss.
|
|
*
|
|
* We explicitly do not want to choose an arbitrary MailPeep instance to
|
|
* (re)use because it could lead to GC memory leaks if data/element/an expando
|
|
* were set on the MailPeep and we did not zero it out when the owning slice
|
|
* was destroyed. We could, however, use the live set of peeps as a fallback
|
|
* if we don't have a contact cached.
|
|
*/
|
|
_contactCache: Object.create(null),
|
|
/** The number of entries in the cache. */
|
|
_cacheHitEntries: 0,
|
|
/** The number of stored misses in the cache. */
|
|
_cacheEmptyEntries: 0,
|
|
|
|
/**
|
|
* Maximum number of hit entries in the cache before we should clear the
|
|
* cache.
|
|
*/
|
|
MAX_CACHE_HITS: 256,
|
|
/** Maximum number of empty entries to store in the cache before clearing. */
|
|
MAX_CACHE_EMPTY: 1024,
|
|
|
|
/** Maps contact id to lists of MailPeep instances. */
|
|
_livePeepsById: Object.create(null),
|
|
/** Maps e-mail addresses to lists of MailPeep instances */
|
|
_livePeepsByEmail: Object.create(null),
|
|
|
|
pendingLookupCount: 0,
|
|
|
|
callbacks: [],
|
|
|
|
init: function() {
|
|
var contactsAPI = navigator.mozContacts;
|
|
if (!contactsAPI)
|
|
return;
|
|
|
|
contactsAPI.oncontactchange = this._onContactChange.bind(this);
|
|
},
|
|
|
|
_resetCache: function() {
|
|
this._contactCache = Object.create(null);
|
|
this._cacheHitEntries = 0;
|
|
this._cacheEmptyEntries = 0;
|
|
},
|
|
|
|
shutdown: function() {
|
|
var contactsAPI = navigator.mozContacts;
|
|
if (!contactsAPI)
|
|
return;
|
|
contactsAPI.oncontactchange = null;
|
|
},
|
|
|
|
/**
|
|
* Currently we process the updates in real-time as we get them. There's an
|
|
* inherent trade-off between chewing CPU when we're in the background and
|
|
* minimizing latency when we are displayed. We're biased towards minimizing
|
|
* latency right now.
|
|
*
|
|
* All contact changes flush our contact cache rather than try and be fancy.
|
|
* We are already fancy with the set of live peeps and our lookups could just
|
|
* leverage that. (The contact cache is just intended as a steady-state
|
|
* high-throughput thing like when displaying messages in the UI. We don't
|
|
* expect a lot of contact changes to happen during that time.)
|
|
*
|
|
* For info on the events/triggers, see:
|
|
* https://developer.mozilla.org/en-US/docs/DOM/ContactManager.oncontactchange
|
|
*/
|
|
_onContactChange: function(event) {
|
|
var contactsAPI = navigator.mozContacts;
|
|
var livePeepsById = this._livePeepsById,
|
|
livePeepsByEmail = this._livePeepsByEmail;
|
|
|
|
// clear the cache if it has anything in it (per the above doc block)
|
|
if (this._cacheHitEntries || this._cacheEmptyEntries)
|
|
this._resetCache();
|
|
|
|
// -- Contact removed OR all contacts removed!
|
|
if (event.reason === 'remove') {
|
|
function cleanOutPeeps(livePeeps) {
|
|
for (var iPeep = 0; iPeep < livePeeps.length; iPeep++) {
|
|
var peep = livePeeps[iPeep];
|
|
peep.contactId = null;
|
|
if (peep.onchange) {
|
|
try {
|
|
peep.onchange(peep);
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('peep.onchange error', ex, '\n',
|
|
ex.stack);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// - all contacts removed! (clear() called)
|
|
var livePeeps;
|
|
if (!event.contactID) {
|
|
for (var contactId in livePeepsById) {
|
|
livePeeps = livePeepsById[contactId];
|
|
cleanOutPeeps(livePeeps);
|
|
this._livePeepsById = Object.create(null);
|
|
}
|
|
}
|
|
// - just one contact removed
|
|
else {
|
|
livePeeps = livePeepsById[event.contactID];
|
|
if (livePeeps) {
|
|
cleanOutPeeps(livePeeps);
|
|
delete livePeepsById[event.contactID];
|
|
}
|
|
}
|
|
}
|
|
// -- Created or updated; we need to fetch the contact to investigate
|
|
else {
|
|
var req = contactsAPI.find({
|
|
filterBy: ['id'],
|
|
filterOp: 'equals',
|
|
filterValue: event.contactID
|
|
});
|
|
req.onsuccess = function() {
|
|
// If the contact disappeared we will hear a 'remove' event and so don't
|
|
// need to process this.
|
|
if (!req.result.length)
|
|
return;
|
|
var contact = req.result[0], livePeeps, iPeep, peep;
|
|
|
|
// - process update with apparent e-mail address removal
|
|
if (event.reason === 'update') {
|
|
livePeeps = livePeepsById[contact.id];
|
|
if (livePeeps) {
|
|
var contactEmails = contact.email ?
|
|
contact.email.map(function(e) { return e.value; }) :
|
|
[];
|
|
for (iPeep = 0; iPeep < livePeeps.length; iPeep++) {
|
|
peep = livePeeps[iPeep];
|
|
if (contactEmails.indexOf(peep.address) === -1) {
|
|
// Need to fix-up iPeep because of the splice; reverse iteration
|
|
// reorders our notifications and we don't want that, hence
|
|
// this.
|
|
livePeeps.splice(iPeep--, 1);
|
|
peep.contactId = null;
|
|
if (peep.onchange) {
|
|
try {
|
|
peep.onchange(peep);
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('peep.onchange error', ex, '\n',
|
|
ex.stack);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (livePeeps.length === 0)
|
|
delete livePeepsById[contact.id];
|
|
}
|
|
}
|
|
// - process create/update causing new coverage
|
|
if (!contact.email)
|
|
return;
|
|
for (var iEmail = 0; iEmail < contact.email.length; iEmail++) {
|
|
var email = contact.email[iEmail].value;
|
|
livePeeps = livePeepsByEmail[email];
|
|
// nothing to do if there are no peeps that use that email address
|
|
if (!livePeeps)
|
|
continue;
|
|
|
|
for (iPeep = 0; iPeep < livePeeps.length; iPeep++) {
|
|
peep = livePeeps[iPeep];
|
|
// If the peep is not yet associated with this contact or any other
|
|
// contact, then associate it.
|
|
if (!peep.contactId) {
|
|
peep.contactId = contact.id;
|
|
var idLivePeeps = livePeepsById[peep.contactId];
|
|
if (idLivePeeps === undefined)
|
|
idLivePeeps = livePeepsById[peep.contactId] = [];
|
|
idLivePeeps.push(peep);
|
|
}
|
|
// However, if it's associated with a different contact, then just
|
|
// skip the peep.
|
|
else if (peep.contactId !== contact.id) {
|
|
continue;
|
|
}
|
|
// (The peep must be associated with this contact, so update and
|
|
// fire)
|
|
|
|
if (contact.name && contact.name.length)
|
|
peep.name = contact.name[0];
|
|
if (peep.onchange) {
|
|
try {
|
|
peep.onchange(peep);
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('peep.onchange error', ex, '\n',
|
|
ex.stack);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
// We don't need to do anything about onerror; the 'remove' event will
|
|
// probably have fired in this case, making us correct.
|
|
}
|
|
},
|
|
|
|
resolvePeeps: function(addressPairs) {
|
|
if (addressPairs == null)
|
|
return null;
|
|
var resolved = [];
|
|
for (var i = 0; i < addressPairs.length; i++) {
|
|
resolved.push(this.resolvePeep(addressPairs[i]));
|
|
}
|
|
return resolved;
|
|
},
|
|
/**
|
|
* Create a MailPeep instance with the best information available and return
|
|
* it. Information from the (moz)Contacts API always trumps the passed-in
|
|
* information. If we have a cache hit (which covers both positive and
|
|
* negative evidence), we are done/all resolved immediately. Otherwise, we
|
|
* need to issue an async request. In that case, you want to check
|
|
* ContactCache.pendingLookupCount and push yourself onto
|
|
* ContactCache.callbacks if you want to be notified when the current set of
|
|
* lookups gets resolved.
|
|
*
|
|
* This is a slightly odd API, but it's based on the knowledge that for a
|
|
* single e-mail we will potentially need to perform multiple lookups and that
|
|
* e-mail addresses are also likely to come in batches so there's no need to
|
|
* generate N callbacks when 1 will do.
|
|
*/
|
|
resolvePeep: function(addressPair) {
|
|
if (!addressPair) {
|
|
console.error("NO ADDRESS PAIR?", new Error().stack);
|
|
return;
|
|
}
|
|
var emailAddress = addressPair.address;
|
|
var entry = this._contactCache[emailAddress], contact, peep;
|
|
var contactsAPI = navigator.mozContacts;
|
|
// known miss; create miss peep
|
|
// no contacts API, always a miss, skip out before extra logic happens
|
|
if (entry === null || !contactsAPI) {
|
|
peep = new MailPeep(addressPair.name || '', emailAddress, null, null);
|
|
if (!contactsAPI)
|
|
return peep;
|
|
}
|
|
// known contact; unpack contact info
|
|
else if (entry !== undefined) {
|
|
var name = addressPair.name || '';
|
|
if (entry.name && entry.name.length)
|
|
name = entry.name[0];
|
|
peep = new MailPeep(
|
|
name,
|
|
emailAddress,
|
|
entry.id,
|
|
(entry.photo && entry.photo.length) ? entry.photo[0] : null);
|
|
}
|
|
// not yet looked-up; assume it's a miss and we'll fix-up if it's a hit
|
|
else {
|
|
peep = new MailPeep(addressPair.name || '',
|
|
emailAddress, null, null);
|
|
|
|
// Place a speculative miss in the contact cache so that additional
|
|
// requests take that path. They will get fixed up when our lookup
|
|
// returns (or if a change event happens to come in before our lookup
|
|
// returns.) Note that we do not do any hit/miss counting right now; we
|
|
// wait for the result to come back.
|
|
this._contactCache[emailAddress] = null;
|
|
|
|
this.pendingLookupCount++;
|
|
|
|
// Search contacts, but use an all lower-case name, since the contacts
|
|
// API's search plumbing uses a lowercase version of the email address
|
|
// for these search comparisons. However, the actual display of the
|
|
// contact in the contact app has casing preserved. emailAddress could
|
|
// be undefined though if a group/undisclosed-recipients case, so guard
|
|
// against that (deeper normalization fix tracked in bug 1097820). Using
|
|
// empty string in the undefined emailAddress case because passing the
|
|
// value of undefined directly in the filterValue results in some contacts
|
|
// being returned. Potentially all contacts. However passing empty string
|
|
// gives back no results, even if there is a contact with no email address
|
|
// assigned to it.
|
|
var filterValue = emailAddress ? emailAddress.toLowerCase() : '';
|
|
var req = contactsAPI.find({
|
|
filterBy: ['email'],
|
|
filterOp: 'equals',
|
|
filterValue: filterValue
|
|
});
|
|
var self = this, handleResult = function() {
|
|
if (req.result && req.result.length) {
|
|
// CONSIDER TODO SOMEDAY: since the search is done witha a
|
|
// toLowerCase() call, it is conceivable that we could get multiple
|
|
// results with slightly different casing. It might be nice to try
|
|
// to find the best casing match, but the payoff for that is likely
|
|
// small, and the common case will be that the first one is good to
|
|
// use.
|
|
var contact = req.result[0];
|
|
|
|
ContactCache._contactCache[emailAddress] = contact;
|
|
if (++ContactCache._cacheHitEntries > ContactCache.MAX_CACHE_HITS)
|
|
self._resetCache();
|
|
|
|
var peepsToFixup = self._livePeepsByEmail[emailAddress];
|
|
// there might no longer be any MailPeeps alive to care; leave
|
|
if (!peepsToFixup)
|
|
return;
|
|
for (var i = 0; i < peepsToFixup.length; i++) {
|
|
var peep = peepsToFixup[i];
|
|
if (!peep.contactId) {
|
|
peep.contactId = contact.id;
|
|
var livePeeps = self._livePeepsById[peep.contactId];
|
|
if (livePeeps === undefined)
|
|
livePeeps = self._livePeepsById[peep.contactId] = [];
|
|
livePeeps.push(peep);
|
|
}
|
|
|
|
if (contact.name && contact.name.length)
|
|
peep.name = contact.name[0];
|
|
if (contact.photo && contact.photo.length)
|
|
peep._thumbnailBlob = contact.photo[0];
|
|
|
|
// If no one is waiting for our/any request to complete, generate an
|
|
// onchange notification.
|
|
if (!self.callbacks.length) {
|
|
if (peep.onchange) {
|
|
try {
|
|
peep.onchange(peep);
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('peep.onchange error', ex, '\n',
|
|
ex.stack);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
ContactCache._contactCache[emailAddress] = null;
|
|
if (++ContactCache._cacheEmptyEntries > ContactCache.MAX_CACHE_EMPTY)
|
|
self._resetCache();
|
|
}
|
|
// Only notify callbacks if all outstanding lookups have completed
|
|
if (--self.pendingLookupCount === 0) {
|
|
for (i = 0; i < ContactCache.callbacks.length; i++) {
|
|
ContactCache.callbacks[i]();
|
|
}
|
|
ContactCache.callbacks.splice(0, ContactCache.callbacks.length);
|
|
}
|
|
};
|
|
req.onsuccess = handleResult;
|
|
req.onerror = handleResult;
|
|
}
|
|
|
|
// - track the peep in our lists of live peeps
|
|
var livePeeps;
|
|
livePeeps = this._livePeepsByEmail[emailAddress];
|
|
if (livePeeps === undefined)
|
|
livePeeps = this._livePeepsByEmail[emailAddress] = [];
|
|
livePeeps.push(peep);
|
|
|
|
if (peep.contactId) {
|
|
livePeeps = this._livePeepsById[peep.contactId];
|
|
if (livePeeps === undefined)
|
|
livePeeps = this._livePeepsById[peep.contactId] = [];
|
|
livePeeps.push(peep);
|
|
}
|
|
|
|
return peep;
|
|
},
|
|
|
|
forgetPeepInstances: function() {
|
|
var livePeepsById = this._livePeepsById,
|
|
livePeepsByEmail = this._livePeepsByEmail;
|
|
for (var iArg = 0; iArg < arguments.length; iArg++) {
|
|
var peeps = arguments[iArg];
|
|
if (!peeps)
|
|
continue;
|
|
for (var iPeep = 0; iPeep < peeps.length; iPeep++) {
|
|
var peep = peeps[iPeep], livePeeps, idx;
|
|
if (peep.contactId) {
|
|
livePeeps = livePeepsById[peep.contactId];
|
|
if (livePeeps) {
|
|
idx = livePeeps.indexOf(peep);
|
|
if (idx !== -1) {
|
|
livePeeps.splice(idx, 1);
|
|
if (livePeeps.length === 0)
|
|
delete livePeepsById[peep.contactId];
|
|
}
|
|
}
|
|
}
|
|
livePeeps = livePeepsByEmail[peep.address];
|
|
if (livePeeps) {
|
|
idx = livePeeps.indexOf(peep);
|
|
if (idx !== -1) {
|
|
livePeeps.splice(idx, 1);
|
|
if (livePeeps.length === 0)
|
|
delete livePeepsByEmail[peep.address];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
function revokeImageSrc() {
|
|
// see showBlobInImg below for the rationale for useWin.
|
|
var useWin = this.ownerDocument.defaultView || window;
|
|
useWin.URL.revokeObjectURL(this.src);
|
|
}
|
|
function showBlobInImg(imgNode, blob) {
|
|
// We need to look at the image node because object URLs are scoped per
|
|
// document, and for HTML e-mails, we use an iframe that lives in a different
|
|
// document than us.
|
|
//
|
|
// the "|| window" is for our shimmed testing environment and should not
|
|
// happen in production.
|
|
var useWin = imgNode.ownerDocument.defaultView || window;
|
|
imgNode.src = useWin.URL.createObjectURL(blob);
|
|
// We can revoke the URL after we are 100% sure the image has resolved the URL
|
|
// to get at the underlying blob. Once autorevoke URLs are supported, we can
|
|
// stop doing this.
|
|
imgNode.addEventListener('load', revokeImageSrc);
|
|
}
|
|
|
|
function MailPeep(name, address, contactId, thumbnailBlob) {
|
|
this.name = name;
|
|
this.address = address;
|
|
this.contactId = contactId;
|
|
this._thumbnailBlob = thumbnailBlob;
|
|
|
|
this.element = null;
|
|
this.data = null;
|
|
// peeps are usually one of: from, to, cc, bcc
|
|
this.type = null;
|
|
|
|
this.onchange = null;
|
|
}
|
|
MailPeep.prototype = {
|
|
get isContact() {
|
|
return this.contactId !== null;
|
|
},
|
|
|
|
toString: function() {
|
|
return '[MailPeep: ' + this.address + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
name: this.name,
|
|
address: this.address,
|
|
contactId: this.contactId
|
|
};
|
|
},
|
|
toWireRep: function() {
|
|
return {
|
|
name: this.name,
|
|
address: this.address
|
|
};
|
|
},
|
|
|
|
get hasPicture() {
|
|
return this._thumbnailBlob !== null;
|
|
},
|
|
/**
|
|
* Display the contact's thumbnail on the given image node, abstracting away
|
|
* the issue of Blob URL life-cycle management.
|
|
*/
|
|
displayPictureInImageTag: function(imgNode) {
|
|
if (this._thumbnailBlob)
|
|
showBlobInImg(imgNode, this._thumbnailBlob);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Email overview information for displaying the message in the list as planned
|
|
* for the current UI. Things that we don't need (ex: to/cc/bcc) for the list
|
|
* end up on the body, currently. They will probably migrate to the header in
|
|
* the future.
|
|
*
|
|
* Events are generated if the metadata of the message changes or if the message
|
|
* is removed. The `BridgedViewSlice` instance is how the system keeps track
|
|
* of what messages are being displayed/still alive to need updates.
|
|
*/
|
|
function MailHeader(slice, wireRep) {
|
|
this._slice = slice;
|
|
|
|
// Store the wireRep so it can be used for caching.
|
|
this._wireRep = wireRep;
|
|
|
|
this.id = wireRep.suid;
|
|
this.guid = wireRep.guid;
|
|
|
|
this.author = ContactCache.resolvePeep(wireRep.author);
|
|
this.to = ContactCache.resolvePeeps(wireRep.to);
|
|
this.cc = ContactCache.resolvePeeps(wireRep.cc);
|
|
this.bcc = ContactCache.resolvePeeps(wireRep.bcc);
|
|
this.replyTo = wireRep.replyTo;
|
|
|
|
this.date = new Date(wireRep.date);
|
|
|
|
this.__update(wireRep);
|
|
this.hasAttachments = wireRep.hasAttachments;
|
|
|
|
this.subject = wireRep.subject;
|
|
this.snippet = wireRep.snippet;
|
|
|
|
this.onchange = null;
|
|
this.onremove = null;
|
|
|
|
// build a place for the DOM element and arbitrary data into our shape
|
|
this.element = null;
|
|
this.data = null;
|
|
}
|
|
MailHeader.prototype = {
|
|
toString: function() {
|
|
return '[MailHeader: ' + this.id + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'MailHeader',
|
|
id: this.id
|
|
};
|
|
},
|
|
|
|
/**
|
|
* The use-case is the message list providing the message reader with a
|
|
* header. The header really wants to get update notifications from the
|
|
* backend and therefore not be inert, but that's a little complicated and out
|
|
* of scope for the current bug.
|
|
*
|
|
* We clone at all because our MailPeep.onchange and MailPeep.element values
|
|
* were getting clobbered. All the instances are currently intended to map
|
|
* 1:1 to a single UI widget, so cloning seems like the right thing to do.
|
|
*
|
|
* A deeper issue is whether the message reader will want to have its own
|
|
* slice since the reader will soon allow forward/backward navigation. I
|
|
* assume we'll want the message list to track that movement, which suggests
|
|
* that it really doesn't want to do that. This suggests we'll either want
|
|
* non-inert clones or to just use a list-of-handlers model with us using
|
|
* closures and being careful about removing event handlers.
|
|
*/
|
|
makeCopy: function() {
|
|
return new MailHeader(this._slice, this._wireRep);
|
|
},
|
|
|
|
__update: function(wireRep) {
|
|
this._wireRep = wireRep;
|
|
if (wireRep.snippet !== null) {
|
|
this.snippet = wireRep.snippet;
|
|
}
|
|
|
|
this.isRead = wireRep.flags.indexOf('\\Seen') !== -1;
|
|
this.isStarred = wireRep.flags.indexOf('\\Flagged') !== -1;
|
|
this.isRepliedTo = wireRep.flags.indexOf('\\Answered') !== -1;
|
|
this.isForwarded = wireRep.flags.indexOf('$Forwarded') !== -1;
|
|
this.isJunk = wireRep.flags.indexOf('$Junk') !== -1;
|
|
this.tags = filterOutBuiltinFlags(wireRep.flags);
|
|
|
|
// Messages in the outbox will have `sendStatus` populated like so:
|
|
// {
|
|
// state: 'pending', 'error', 'success', 'sending', or 'syncDone'
|
|
// err: null,
|
|
// badAddresses: null,
|
|
// sendFailures: 2
|
|
// }
|
|
this.sendStatus = wireRep.sendStatus || {};
|
|
},
|
|
|
|
/**
|
|
* Release subscriptions associated with the header; currently this just means
|
|
* tell the ContactCache we no longer care about the `MailPeep` instances.
|
|
*/
|
|
__die: function() {
|
|
ContactCache.forgetPeepInstances([this.author], this.to, this.cc, this.bcc);
|
|
},
|
|
|
|
/**
|
|
* Delete this message
|
|
*/
|
|
deleteMessage: function() {
|
|
return this._slice._api.deleteMessages([this]);
|
|
},
|
|
|
|
/*
|
|
* Copy this message to another folder.
|
|
*/
|
|
/*
|
|
copyMessage: function(targetFolder) {
|
|
return this._slice._api.copyMessages([this], targetFolder);
|
|
},
|
|
*/
|
|
|
|
/**
|
|
* Move this message to another folder.
|
|
*/
|
|
moveMessage: function(targetFolder) {
|
|
return this._slice._api.moveMessages([this], targetFolder);
|
|
},
|
|
|
|
/**
|
|
* Set or clear the read status of this message.
|
|
*/
|
|
setRead: function(beRead) {
|
|
return this._slice._api.markMessagesRead([this], beRead);
|
|
},
|
|
|
|
/**
|
|
* Set or clear the starred/flagged status of this message.
|
|
*/
|
|
setStarred: function(beStarred) {
|
|
return this._slice._api.markMessagesStarred([this], beStarred);
|
|
},
|
|
|
|
/**
|
|
* Add and/or remove tags/flags from this messages.
|
|
*/
|
|
modifyTags: function(addTags, removeTags) {
|
|
return this._slice._api.modifyMessageTags([this], addTags, removeTags);
|
|
},
|
|
|
|
/**
|
|
* Request the `MailBody` instance for this message, passing it to
|
|
* the provided callback function once retrieved. If you request the
|
|
* bodyReps as part of this call, the backend guarantees that it
|
|
* will only call the "onchange" notification when the body has
|
|
* actually changed. In other words, if you end up calling getBody()
|
|
* multiple times for some reason, the backend will be smart about
|
|
* only fetching the bodyReps the first time and generating change
|
|
* notifications as one would expect.
|
|
*
|
|
* @args[
|
|
* @param[options @dict[
|
|
* @key[downloadBodyReps #:default false]{
|
|
* Asynchronously initiate download of the body reps. The body may
|
|
* be returned before the body parts are downloaded, but they will
|
|
* eventually show up. Use the 'onchange' event to hear as the body
|
|
* parts get added.
|
|
* }
|
|
* @key[withBodyReps #:default false]{
|
|
* Don't return until the body parts are fully downloaded.
|
|
* }
|
|
* ]]
|
|
* ]
|
|
*/
|
|
getBody: function(options, callback) {
|
|
if (typeof(options) === 'function') {
|
|
callback = options;
|
|
options = null;
|
|
}
|
|
this._slice._api._getBodyForMessage(this, options, callback);
|
|
},
|
|
|
|
/**
|
|
* Returns the number of bytes needed before we can display the full
|
|
* body. If this value is large, we should warn the user that they
|
|
* may be downloading a large amount of data. For IMAP, this value
|
|
* is the amount of data we need to render bodyReps and
|
|
* relatedParts; for POP3, we need the whole message.
|
|
*/
|
|
get bytesToDownloadForBodyDisplay() {
|
|
// If this is unset (old message), default to zero so that we just
|
|
// won't show any warnings (rather than prompting incorrectly).
|
|
return this._wireRep.bytesToDownloadForBodyDisplay || 0;
|
|
},
|
|
|
|
/**
|
|
* Assume this is a draft message and return a MessageComposition object
|
|
* that will be asynchronously populated. The provided callback will be
|
|
* notified once all composition state has been loaded.
|
|
*
|
|
* The underlying message will be replaced by other messages as the draft
|
|
* is updated and effectively deleted once the draft is completed. (A
|
|
* move may be performed instead.)
|
|
*/
|
|
editAsDraft: function(callback) {
|
|
var composer = this._slice._api.resumeMessageComposition(this, callback);
|
|
composer.hasDraft = true;
|
|
return composer;
|
|
},
|
|
|
|
/**
|
|
* Start composing a reply to this message.
|
|
*
|
|
* @args[
|
|
* @param[replyMode @oneof[
|
|
* @default[null]{
|
|
* To be specified...
|
|
* }
|
|
* @case['sender']{
|
|
* Reply to the author of the message.
|
|
* }
|
|
* @case['list']{
|
|
* Reply to the mailing list the message was received from. If there
|
|
* were other mailing lists copied on the message, they will not
|
|
* be included.
|
|
* }
|
|
* @case['all']{
|
|
* Reply to the sender and all listed recipients of the message.
|
|
* }
|
|
* ]]{
|
|
* The not currently used reply-mode.
|
|
* }
|
|
* ]
|
|
* @return[MessageComposition]
|
|
*/
|
|
replyToMessage: function(replyMode, callback) {
|
|
return this._slice._api.beginMessageComposition(
|
|
this, null, { replyTo: this, replyMode: replyMode }, callback);
|
|
},
|
|
|
|
/**
|
|
* Start composing a forward of this message.
|
|
*
|
|
* @args[
|
|
* @param[forwardMode @oneof[
|
|
* @case['inline']{
|
|
* Forward the message inline.
|
|
* }
|
|
* ]]
|
|
* ]
|
|
* @return[MessageComposition]
|
|
*/
|
|
forwardMessage: function(forwardMode, callback) {
|
|
return this._slice._api.beginMessageComposition(
|
|
this, null, { forwardOf: this, forwardMode: forwardMode }, callback);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Represents a mail message that matched some search criteria by providing
|
|
* both the header and information about the matches that occurred.
|
|
*/
|
|
function MailMatchedHeader(slice, wireRep) {
|
|
this.header = new MailHeader(slice, wireRep.header);
|
|
this.matches = wireRep.matches;
|
|
|
|
this.element = null;
|
|
this.data = null;
|
|
}
|
|
MailMatchedHeader.prototype = {
|
|
toString: function() {
|
|
return '[MailMatchedHeader: ' + this.header.id + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'MailMatchedHeader',
|
|
id: this.header.id
|
|
};
|
|
},
|
|
|
|
__update: function(wireRep) {
|
|
this.matches = wireRep.matches;
|
|
this.header.__update(wireRep.header);
|
|
},
|
|
|
|
__die: function() {
|
|
this.header.__die();
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Lists the attachments in a message as well as providing a way to display the
|
|
* body while (eventually) also accounting for message quoting.
|
|
*
|
|
* Mail bodies are immutable and so there are no events on them or lifetime
|
|
* management to worry about. However, you should keep the `MailHeader` alive
|
|
* and worry about its lifetime since the message can get deleted, etc.
|
|
*/
|
|
function MailBody(api, suid, wireRep, handle) {
|
|
this._api = api;
|
|
this.id = suid;
|
|
this._date = wireRep.date;
|
|
this._handle = handle;
|
|
|
|
this.attachments = null;
|
|
if (wireRep.attachments) {
|
|
this.attachments = [];
|
|
for (var iAtt = 0; iAtt < wireRep.attachments.length; iAtt++) {
|
|
this.attachments.push(
|
|
new MailAttachment(this, wireRep.attachments[iAtt]));
|
|
}
|
|
}
|
|
this._relatedParts = wireRep.relatedParts;
|
|
this.bodyReps = wireRep.bodyReps;
|
|
// references is included for debug/unit testing purposes, hence is private
|
|
this._references = wireRep.references;
|
|
|
|
this.onchange = null;
|
|
this.ondead = null;
|
|
}
|
|
MailBody.prototype = {
|
|
toString: function() {
|
|
return '[MailBody: ' + this.id + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'MailBody',
|
|
id: this.id
|
|
};
|
|
},
|
|
|
|
__update: function(wireRep, detail) {
|
|
// Related parts and bodyReps have no state we need to maintain. Just
|
|
// replace them with the new copies for simplicity.
|
|
this._relatedParts = wireRep.relatedParts;
|
|
this.bodyReps = wireRep.bodyReps;
|
|
|
|
// detaching an attachment is special since we need to splice the attachment
|
|
// out.
|
|
if (detail && detail.changeDetails &&
|
|
detail.changeDetails.detachedAttachments) {
|
|
var indices = detail.changeDetails.detachedAttachments;
|
|
for (var iSplice = 0; iSplice < indices.length; iSplice++) {
|
|
this.attachments.splice(indices[iSplice], 1);
|
|
}
|
|
}
|
|
|
|
// Attachment instances need to be updated rather than replaced.
|
|
if (wireRep.attachments) {
|
|
var i, attachment;
|
|
for (i = 0; i < this.attachments.length; i++) {
|
|
attachment = this.attachments[i];
|
|
attachment.__update(wireRep.attachments[i]);
|
|
}
|
|
// If we added new attachments, construct them now.
|
|
for (i = this.attachments.length; i < wireRep.attachments.length; i++) {
|
|
this.attachments.push(
|
|
new MailAttachment(this, wireRep.attachments[i]));
|
|
}
|
|
// We don't handle the fictional case where wireRep.attachments
|
|
// decreases in size, because that doesn't currently happen and
|
|
// probably won't ever, apart from detachedAttachments above
|
|
// which are a different thing.
|
|
}
|
|
},
|
|
|
|
/**
|
|
* true if this is an HTML document with inline images sent as part of the
|
|
* messages.
|
|
*/
|
|
get embeddedImageCount() {
|
|
if (!this._relatedParts)
|
|
return 0;
|
|
return this._relatedParts.length;
|
|
},
|
|
|
|
/**
|
|
* true if all the bodyReps are downloaded.
|
|
*/
|
|
get bodyRepsDownloaded() {
|
|
var i = 0;
|
|
var len = this.bodyReps.length;
|
|
|
|
for (; i < len; i++) {
|
|
if (!this.bodyReps[i].isDownloaded) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* true if all of the images are already downloaded.
|
|
*/
|
|
get embeddedImagesDownloaded() {
|
|
for (var i = 0; i < this._relatedParts.length; i++) {
|
|
var relatedPart = this._relatedParts[i];
|
|
if (!relatedPart.file)
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Trigger the download of any inline images sent as part of the message.
|
|
* Once the images have been downloaded, invoke the provided callback.
|
|
*/
|
|
downloadEmbeddedImages: function(callWhenDone, callOnProgress) {
|
|
var relPartIndices = [];
|
|
for (var i = 0; i < this._relatedParts.length; i++) {
|
|
var relatedPart = this._relatedParts[i];
|
|
if (relatedPart.file)
|
|
continue;
|
|
relPartIndices.push(i);
|
|
}
|
|
if (!relPartIndices.length) {
|
|
if (callWhenDone)
|
|
callWhenDone();
|
|
return;
|
|
}
|
|
this._api._downloadAttachments(this, relPartIndices, [], [],
|
|
callWhenDone, callOnProgress);
|
|
},
|
|
|
|
/**
|
|
* Synchronously trigger the display of embedded images.
|
|
*
|
|
* The loadCallback allows iframe resizing logic to fire once the size of the
|
|
* image is known since Gecko still doesn't have seamless iframes.
|
|
*/
|
|
showEmbeddedImages: function(htmlNode, loadCallback) {
|
|
var i, cidToBlob = {};
|
|
// - Generate object URLs for the attachments
|
|
for (i = 0; i < this._relatedParts.length; i++) {
|
|
var relPart = this._relatedParts[i];
|
|
// Related parts should all be stored as Blobs-in-IndexedDB
|
|
if (relPart.file && !Array.isArray(relPart.file))
|
|
cidToBlob[relPart.contentId] = relPart.file;
|
|
}
|
|
|
|
// - Transform the links
|
|
var nodes = htmlNode.querySelectorAll('.moz-embedded-image');
|
|
for (i = 0; i < nodes.length; i++) {
|
|
var node = nodes[i],
|
|
cid = node.getAttribute('cid-src');
|
|
|
|
if (!cidToBlob.hasOwnProperty(cid))
|
|
continue;
|
|
showBlobInImg(node, cidToBlob[cid]);
|
|
if (loadCallback)
|
|
node.addEventListener('load', loadCallback, false);
|
|
|
|
node.removeAttribute('cid-src');
|
|
node.classList.remove('moz-embedded-image');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @return[Boolean]{
|
|
* True if the given HTML node sub-tree contains references to externally
|
|
* hosted images. These are detected by looking for markup left in the
|
|
* image by the sanitization process. The markup is not guaranteed to be
|
|
* stable, so don't do this yourself.
|
|
* }
|
|
*/
|
|
checkForExternalImages: function(htmlNode) {
|
|
var someNode = htmlNode.querySelector('.moz-external-image');
|
|
return someNode !== null;
|
|
},
|
|
|
|
/**
|
|
* Transform previously sanitized references to external images into live
|
|
* references to images. This un-does the operations of the sanitization step
|
|
* using implementation-specific details subject to change, so don't do this
|
|
* yourself.
|
|
*/
|
|
showExternalImages: function(htmlNode, loadCallback) {
|
|
// querySelectorAll is not live, whereas getElementsByClassName is; we
|
|
// don't need/want live, especially with our manipulations.
|
|
var nodes = htmlNode.querySelectorAll('.moz-external-image');
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
var node = nodes[i];
|
|
if (loadCallback) {
|
|
node.addEventListener('load', loadCallback, false);
|
|
}
|
|
node.setAttribute('src', node.getAttribute('ext-src'));
|
|
node.removeAttribute('ext-src');
|
|
node.classList.remove('moz-external-image');
|
|
}
|
|
},
|
|
/**
|
|
* Call this method when you are done with a message body.
|
|
*/
|
|
die: function() {
|
|
// Remember to cleanup event listeners except ondead!
|
|
this.onchange = null;
|
|
|
|
this._api.__bridgeSend({
|
|
type: 'killBody',
|
|
id: this.id,
|
|
handle: this._handle
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Provides the file name, mime-type, and estimated file size of an attachment.
|
|
* In the future this will also be the means for requesting the download of
|
|
* an attachment or for attachment-forwarding semantics.
|
|
*/
|
|
function MailAttachment(_body, wireRep) {
|
|
this._body = _body;
|
|
this.partId = wireRep.part;
|
|
this.filename = wireRep.name;
|
|
this.mimetype = wireRep.type;
|
|
this.sizeEstimateInBytes = wireRep.sizeEstimate;
|
|
this._file = wireRep.file;
|
|
|
|
// build a place for the DOM element and arbitrary data into our shape
|
|
this.element = null;
|
|
this.data = null;
|
|
}
|
|
MailAttachment.prototype = {
|
|
toString: function() {
|
|
return '[MailAttachment: "' + this.filename + '"]';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'MailAttachment',
|
|
filename: this.filename
|
|
};
|
|
},
|
|
|
|
__update: function(wireRep) {
|
|
this.mimetype = wireRep.type;
|
|
this.sizeEstimateInBytes = wireRep.sizeEstimate;
|
|
this._file = wireRep.file;
|
|
},
|
|
|
|
get isDownloaded() {
|
|
return !!this._file;
|
|
},
|
|
|
|
/**
|
|
* Is this attachment something we can download? In almost all cases, the
|
|
* answer is yes, regardless of network state. The exception is that sent
|
|
* POP3 messages do not retain their attachment Blobs and there is no way to
|
|
* download them after the fact.
|
|
*/
|
|
get isDownloadable() {
|
|
return this.mimetype !== 'application/x-gelam-no-download';
|
|
},
|
|
|
|
/**
|
|
* Queue this attachment for downloading.
|
|
*
|
|
* @param {Function} callWhenDone
|
|
* A callback to be invoked when the download completes.
|
|
* @param {Function} callOnProgress
|
|
* A callback to be invoked as the download progresses. NOT HOOKED UP!
|
|
* @param {Boolean} [registerWithDownloadManager]
|
|
* Should we register the Blob with the mozDownloadManager (if it is
|
|
* present)? For the Gaia mail app this decision is based on the
|
|
* capabilities of the default gaia apps, and not a decision easily made
|
|
* by GELAM.
|
|
*/
|
|
download: function(callWhenDone, callOnProgress,
|
|
registerWithDownloadManager) {
|
|
if (this.isDownloaded) {
|
|
callWhenDone();
|
|
return;
|
|
}
|
|
this._body._api._downloadAttachments(
|
|
this._body, [], [this._body.attachments.indexOf(this)],
|
|
[registerWithDownloadManager || false],
|
|
callWhenDone, callOnProgress);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Undoable operations describe the operation that was performed for
|
|
* presentation to the user and hold onto a handle that can be used to undo
|
|
* whatever it was. While the current UI plan does not call for the ability to
|
|
* get a list of recently performed actions, the goal is to make it feasible
|
|
* in the future.
|
|
*/
|
|
function UndoableOperation(_api, operation, affectedCount,
|
|
_tempHandle, _longtermIds) {
|
|
this._api = _api;
|
|
/**
|
|
* @oneof[
|
|
* @case['read']{
|
|
* Marked message(s) as read.
|
|
* }
|
|
* @case['unread']{
|
|
* Marked message(s) as unread.
|
|
* }
|
|
* @case['star']{
|
|
* Starred message(s).
|
|
* }
|
|
* @case['unstar']{
|
|
* Unstarred message(s).
|
|
* }
|
|
* @case['addtag']{
|
|
* Added tag(s).
|
|
* }
|
|
* @case['removetag']{
|
|
* Removed tag(s).
|
|
* }
|
|
* @case['move']{
|
|
* Moved message(s).
|
|
* }
|
|
* @case['copy']{
|
|
* Copied message(s).
|
|
* }
|
|
* @case['delete']{
|
|
* Deleted message(s) by moving to trash folder.
|
|
* }
|
|
* ]
|
|
*/
|
|
this.operation = operation;
|
|
/**
|
|
* The number of messages affected by this operation.
|
|
*/
|
|
this.affectedCount = affectedCount;
|
|
|
|
/**
|
|
* The temporary handle we use to refer to the operation immediately after
|
|
* issuing it until we hear back from the mail bridge about its more permanent
|
|
* _longtermIds.
|
|
*/
|
|
this._tempHandle = _tempHandle;
|
|
/**
|
|
* The names of the per-account operations that this operation was mapped
|
|
* to.
|
|
*/
|
|
this._longtermIds = null;
|
|
|
|
this._undoRequested = false;
|
|
}
|
|
UndoableOperation.prototype = {
|
|
toString: function() {
|
|
return '[UndoableOperation]';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'UndoableOperation',
|
|
handle: this._tempHandle,
|
|
longtermIds: this._longtermIds,
|
|
};
|
|
},
|
|
|
|
undo: function() {
|
|
// We can't issue the undo until we've heard the longterm id, so just flag
|
|
// it to be processed when we do.
|
|
if (!this._longtermIds) {
|
|
this._undoRequested = true;
|
|
return;
|
|
}
|
|
this._api.__undo(this);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Ordered list collection abstraction where we may potentially only be viewing
|
|
* a subset of the actual items in the collection. This allows us to handle
|
|
* lists with lots of items as well as lists where we have to retrieve data
|
|
* from a remote server to populate the list.
|
|
*/
|
|
function BridgedViewSlice(api, ns, handle) {
|
|
this._api = api;
|
|
this._ns = ns;
|
|
this._handle = handle;
|
|
|
|
this.items = [];
|
|
|
|
/**
|
|
* @oneof[
|
|
* @case['new']{
|
|
* We were just created and have no meaningful state.
|
|
* }
|
|
* @case['synchronizing']{
|
|
* We are talking to a server to populate/expand the contents of this
|
|
* list.
|
|
* }
|
|
* @case['synced']{
|
|
* We successfully synchronized with the backing store/server. If we are
|
|
* known to be offline and did not attempt to talk to the server, then we
|
|
* will still have this status.
|
|
* }
|
|
* @case['syncfailed']{
|
|
* We tried to synchronize with the server but failed.
|
|
* }
|
|
* ]{
|
|
* Quasi-extensible indicator of whether we are synchronizing or not. The
|
|
* idea is that if we are synchronizing, a spinner indicator can be shown
|
|
* at the end of the list of messages.
|
|
* }
|
|
*/
|
|
this.status = 'new';
|
|
|
|
/**
|
|
* A value in the range [0.0, 1.0] expressing our synchronization progress.
|
|
*/
|
|
this.syncProgress = 0.0;
|
|
|
|
/**
|
|
* False if we can grow the slice in the negative direction without
|
|
* requiring user prompting.
|
|
*/
|
|
this.atTop = false;
|
|
/**
|
|
* False if we can grow the slice in the positive direction without
|
|
* requiring user prompting.
|
|
*/
|
|
this.atBottom = false;
|
|
|
|
/**
|
|
* Can we potentially grow the slice in the ngative direction if the user
|
|
* requests it? For example, triggering an IMAP sync for a part of the
|
|
* time-range we have not previously synchronized.
|
|
*
|
|
* This is only really meaningful when `atTop` is true; if we are not at the
|
|
* top, this value will be false.
|
|
*/
|
|
this.userCanGrowUpwards = false;
|
|
|
|
/**
|
|
* Can we potentially grow the slice in the positive direction if the user
|
|
* requests it? For example, triggering an IMAP sync for a part of the
|
|
* time-range we have not previously synchronized.
|
|
*
|
|
* This is only really meaningful when `atBottom` is true; if we are not at
|
|
* the bottom, this value will be false.
|
|
*/
|
|
this.userCanGrowDownwards = false;
|
|
|
|
/**
|
|
* Number of pending requests to the back-end. To be used by logic that can
|
|
* defer further requests until existing requests are complete. For example,
|
|
* infinite scrolling logic would do best to wait for the back-end to service
|
|
* its requests before issuing new ones.
|
|
*/
|
|
this.pendingRequestCount = 0;
|
|
/**
|
|
* The direction we are growing, if any (0 if not).
|
|
*/
|
|
this._growing = 0;
|
|
|
|
this.onadd = null;
|
|
this.onchange = null;
|
|
this.onsplice = null;
|
|
this.onremove = null;
|
|
this.onstatus = null;
|
|
this.oncomplete = null;
|
|
this.ondead = null;
|
|
}
|
|
BridgedViewSlice.prototype = {
|
|
toString: function() {
|
|
return '[BridgedViewSlice: ' + this._ns + ' ' + this._handle + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'BridgedViewSlice',
|
|
namespace: this._ns,
|
|
handle: this._handle
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Tell the back-end we no longer need some of the items we know about. This
|
|
* will manifest as a requested splice at some point in the future, although
|
|
* the back-end may attenuate partially or entirely.
|
|
*/
|
|
requestShrinkage: function(firstUsedIndex, lastUsedIndex) {
|
|
this.pendingRequestCount++;
|
|
if (lastUsedIndex >= this.items.length)
|
|
lastUsedIndex = this.items.length - 1;
|
|
|
|
// We send indices and suid's. The indices are used for fast-pathing;
|
|
// if the suid's don't match, a linear search is undertaken.
|
|
this._api.__bridgeSend({
|
|
type: 'shrinkSlice',
|
|
handle: this._handle,
|
|
firstIndex: firstUsedIndex,
|
|
firstSuid: this.items[firstUsedIndex].id,
|
|
lastIndex: lastUsedIndex,
|
|
lastSuid: this.items[lastUsedIndex].id
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Request additional data in the given direction, optionally specifying that
|
|
* some potentially costly growth of the data set should be performed.
|
|
*/
|
|
requestGrowth: function(dirMagnitude, userRequestsGrowth) {
|
|
if (this._growing) {
|
|
reportError('Already growing in ' + this._growing + ' dir.');
|
|
return;
|
|
}
|
|
this._growing = dirMagnitude;
|
|
this.pendingRequestCount++;
|
|
|
|
this._api.__bridgeSend({
|
|
type: 'growSlice',
|
|
dirMagnitude: dirMagnitude,
|
|
userRequestsGrowth: userRequestsGrowth,
|
|
handle: this._handle
|
|
});
|
|
},
|
|
|
|
die: function() {
|
|
// Null out all listeners except for the ondead listener. This avoids
|
|
// the callbacks from having to filter out messages from dead slices.
|
|
this.onadd = null;
|
|
this.onchange = null;
|
|
this.onsplice = null;
|
|
this.onremove = null;
|
|
this.onstatus = null;
|
|
this.oncomplete = null;
|
|
this._api.__bridgeSend({
|
|
type: 'killSlice',
|
|
handle: this._handle
|
|
});
|
|
|
|
for (var i = 0; i < this.items.length; i++) {
|
|
var item = this.items[i];
|
|
item.__die();
|
|
}
|
|
},
|
|
};
|
|
|
|
function AccountsViewSlice(api, handle) {
|
|
BridgedViewSlice.call(this, api, 'accounts', handle);
|
|
}
|
|
AccountsViewSlice.prototype = Object.create(BridgedViewSlice.prototype);
|
|
|
|
/**
|
|
* Return the account with the given ID, or null.
|
|
*/
|
|
AccountsViewSlice.prototype.getAccountById = function(id) {
|
|
for (var i = 0; i < this.items.length; i++) {
|
|
if (this.items[i]._wireRep.id === id) {
|
|
return this.items[i];
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
Object.defineProperty(AccountsViewSlice.prototype, 'defaultAccount', {
|
|
get: function () {
|
|
var defaultAccount = this.items[0];
|
|
for (var i = 1; i < this.items.length; i++) {
|
|
// For UI upgrades, the defaultPriority may not be set, so default to
|
|
// zero for comparisons
|
|
if ((this.items[i]._wireRep.defaultPriority || 0) >
|
|
(defaultAccount._wireRep.defaultPriority || 0))
|
|
defaultAccount = this.items[i];
|
|
}
|
|
|
|
return defaultAccount;
|
|
}
|
|
});
|
|
|
|
function FoldersViewSlice(api, handle) {
|
|
BridgedViewSlice.call(this, api, 'folders', handle);
|
|
}
|
|
FoldersViewSlice.prototype = Object.create(BridgedViewSlice.prototype);
|
|
|
|
FoldersViewSlice.prototype.getFirstFolderWithType = function(type, items) {
|
|
// allow an explicit list of items to be provided, specifically for use in
|
|
// onsplice handlers where the items have not yet been spliced in.
|
|
if (!items)
|
|
items = this.items;
|
|
for (var i = 0; i < items.length; i++) {
|
|
var folder = items[i];
|
|
if (folder.type === type)
|
|
return folder;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
FoldersViewSlice.prototype.getFirstFolderWithName = function(name, items) {
|
|
if (!items)
|
|
items = this.items;
|
|
for (var i = 0; i < items.length; i++) {
|
|
var folder = items[i];
|
|
if (folder.name === name)
|
|
return folder;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
FoldersViewSlice.prototype.getFirstFolderWithPath = function(path, items) {
|
|
if (!items)
|
|
items = this.items;
|
|
for (var i = 0; i < items.length; i++) {
|
|
var folder = items[i];
|
|
if (folder.path === path)
|
|
return folder;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
function HeadersViewSlice(api, handle, ns) {
|
|
BridgedViewSlice.call(this, api, ns || 'headers', handle);
|
|
|
|
this._bodiesRequestId = 1;
|
|
this._bodiesRequest = {};
|
|
}
|
|
HeadersViewSlice.prototype = Object.create(BridgedViewSlice.prototype);
|
|
|
|
/**
|
|
* Request a re-sync of the time interval covering the effective time
|
|
* range. If the most recently displayed message is the most recent message
|
|
* known to us, then the date range will cover through "now". The refresh
|
|
* mechanism will disable normal sync bisection limits, so take care to
|
|
* `requestShrinkage` to a reasonable value if you have a ridiculous number of
|
|
* headers currently present.
|
|
*/
|
|
HeadersViewSlice.prototype.refresh = function() {
|
|
this._api.__bridgeSend({
|
|
type: 'refreshHeaders',
|
|
handle: this._handle
|
|
});
|
|
};
|
|
|
|
HeadersViewSlice.prototype._notifyRequestBodiesComplete = function(reqId) {
|
|
var callback = this._bodiesRequest[reqId];
|
|
if (reqId && callback) {
|
|
callback(true);
|
|
delete this._bodiesRequest[reqId];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Requests bodies (if of a reasonable size) given a start/end position.
|
|
*
|
|
* // start/end inclusive
|
|
* slice.maybeRequestBodies(5, 10);
|
|
*
|
|
* The results will be sent through the standard slice/header events.
|
|
*/
|
|
HeadersViewSlice.prototype.maybeRequestBodies =
|
|
function(idxStart, idxEnd, options, callback) {
|
|
|
|
if (typeof(options) === 'function') {
|
|
callback = options;
|
|
options = null;
|
|
}
|
|
|
|
var messages = [];
|
|
|
|
idxEnd = Math.min(idxEnd, this.items.length - 1);
|
|
|
|
for (; idxStart <= idxEnd; idxStart++) {
|
|
var item = this.items[idxStart];
|
|
// ns of 'headers' has the id/date on the item, where 'matchedHeaders'
|
|
// has it on header.date
|
|
if (this._ns === 'matchedHeaders') {
|
|
item = item.header;
|
|
}
|
|
|
|
if (item && item.snippet === null) {
|
|
messages.push({
|
|
suid: item.id,
|
|
// backend does not care about Date objects
|
|
date: item.date.valueOf()
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!messages.length)
|
|
return callback && window.setZeroTimeout(callback, false);
|
|
|
|
var reqId = this._bodiesRequestId++;
|
|
this._bodiesRequest[reqId] = callback;
|
|
|
|
this._api.__bridgeSend({
|
|
type: 'requestBodies',
|
|
handle: this._handle,
|
|
requestId: reqId,
|
|
messages: messages,
|
|
options: options
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Handle for a current/ongoing message composition process. The UI reads state
|
|
* out of the object when it resumes editing a draft, otherwise this can just be
|
|
* treated as write-only.
|
|
*
|
|
* == Other clients and drafts:
|
|
*
|
|
* If another client deletes our draft out from under us, we currently won't
|
|
* notice.
|
|
*/
|
|
function MessageComposition(api, handle) {
|
|
this._api = api;
|
|
this._handle = handle;
|
|
|
|
this.senderIdentity = null;
|
|
|
|
this.to = null;
|
|
this.cc = null;
|
|
this.bcc = null;
|
|
|
|
this.subject = null;
|
|
|
|
this.body = null;
|
|
|
|
this._references = null;
|
|
/**
|
|
* @property attachments
|
|
* @type Object[]
|
|
*
|
|
* A list of attachments currently attached or currently being attached with
|
|
* the following attributes:
|
|
* - name: The filename
|
|
* - size: The size of the attachment payload in binary form. This does not
|
|
* include transport encoding costs.
|
|
*
|
|
* Manipulating this list has no effect on reality; the methods addAttachment
|
|
* and removeAttachment must be used.
|
|
*/
|
|
this.attachments = null;
|
|
|
|
this.hasDraft = false;
|
|
}
|
|
MessageComposition.prototype = {
|
|
toString: function() {
|
|
return '[MessageComposition: ' + this._handle + ']';
|
|
},
|
|
toJSON: function() {
|
|
return {
|
|
type: 'MessageComposition',
|
|
handle: this._handle
|
|
};
|
|
},
|
|
|
|
die: function() {
|
|
if (this._handle) {
|
|
this._api._composeDone(this._handle, 'die', null, null);
|
|
this._handle = null;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Add an attachment to this composition. This is an asynchronous process
|
|
* that incrementally converts the Blob we are provided into a line-wrapped
|
|
* base64-encoded message suitable for use in the rfc2822 message generation
|
|
* process. We will perform the conversion in slices whose sizes are
|
|
* chosen to avoid causing a memory usage explosion that causes us to be
|
|
* reaped. Once the conversion is completed we will forget the Blob reference
|
|
* provided to us.
|
|
*
|
|
* From the perspective of our drafts, an attachment is not fully attached
|
|
* until it has been completely encoded, sliced, and persisted to our
|
|
* IndexedDB database. In the event of a crash during this time window,
|
|
* the attachment will effectively have not been attached. Our logic will
|
|
* discard the partially-translated attachment when de-persisting the draft.
|
|
* We will, however, create an entry in the attachments array immediately;
|
|
* we also return it to you. You should be able to safely call
|
|
* removeAttachment with it regardless of what has happened on the backend.
|
|
*
|
|
* The caller *MUST* forget all references to the Blob that is being attached
|
|
* after issuing this call.
|
|
*
|
|
* @args[
|
|
* @param[attachmentDef @dict[
|
|
* @key[name String]
|
|
* @key[blob Blob]
|
|
* ]]
|
|
* ]
|
|
*/
|
|
addAttachment: function(attachmentDef, callback) {
|
|
// There needs to be a draft for us to attach things to.
|
|
if (!this.hasDraft)
|
|
this.saveDraft();
|
|
this._api._composeAttach(this._handle, attachmentDef, callback);
|
|
|
|
var placeholderAttachment = {
|
|
name: attachmentDef.name,
|
|
blob: {
|
|
size: attachmentDef.blob.size,
|
|
type: attachmentDef.blob.type
|
|
}
|
|
};
|
|
this.attachments.push(placeholderAttachment);
|
|
return placeholderAttachment;
|
|
},
|
|
|
|
/**
|
|
* Remove an attachment previously requested to be added via `addAttachment`.
|
|
*
|
|
* @method removeAttachment
|
|
* @param attachmentDef Object
|
|
* This must be one of the instances from our `attachments` list. A
|
|
* logically equivalent object is no good.
|
|
*/
|
|
removeAttachment: function(attachmentDef, callback) {
|
|
var idx = this.attachments.indexOf(attachmentDef);
|
|
if (idx !== -1) {
|
|
this.attachments.splice(idx, 1);
|
|
this._api._composeDetach(this._handle, idx, callback);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Populate our state to send over the wire to the back-end.
|
|
*/
|
|
_buildWireRep: function() {
|
|
return {
|
|
senderId: this.senderIdentity.id,
|
|
to: this.to,
|
|
cc: this.cc,
|
|
bcc: this.bcc,
|
|
subject: this.subject,
|
|
body: this.body,
|
|
referencesStr: this._references,
|
|
attachments: this.attachments,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Enqueue the message for sending. When the callback fires, the
|
|
* message will be in the outbox, but will likely not have been sent yet.
|
|
*/
|
|
finishCompositionSendMessage: function(callback) {
|
|
this._api._composeDone(this._handle, 'send', this._buildWireRep(),
|
|
callback);
|
|
},
|
|
|
|
/**
|
|
* Save the state of this composition.
|
|
*/
|
|
saveDraft: function(callback) {
|
|
this.hasDraft = true;
|
|
this._api._composeDone(this._handle, 'save', this._buildWireRep(),
|
|
callback);
|
|
},
|
|
|
|
/**
|
|
* The user has indicated they neither want to send nor save the draft. We
|
|
* want to delete the message so it is gone from everywhere.
|
|
*
|
|
* In the future, we might support some type of very limited undo
|
|
* functionality, possibly on the UI side of the house. This is not a secure
|
|
* delete.
|
|
*/
|
|
abortCompositionDeleteDraft: function(callback) {
|
|
this._api._composeDone(this._handle, 'delete', null, callback);
|
|
},
|
|
|
|
};
|
|
|
|
var LEGAL_CONFIG_KEYS = [];
|
|
|
|
// Common idioms:
|
|
//
|
|
// Lead-in (URL and email):
|
|
// ( Capture because we need to know if there was a lead-in
|
|
// character so we can include it as part of the text
|
|
// preceding the match. We lack look-behind matching.
|
|
// ^| The URL/email can start at the beginninf of the string.
|
|
// [\s(,;] Or whitespace or some punctuation that does not imply
|
|
// a context which would preclude a URL.
|
|
// )
|
|
//
|
|
// We do not need a trailing look-ahead because our regex's will terminate
|
|
// because they run out of characters they can eat.
|
|
|
|
// What we do not attempt to have the regexp do:
|
|
// - Avoid trailing '.' and ')' characters. We let our greedy match absorb
|
|
// these, but have a separate regex for extra characters to leave off at the
|
|
// end.
|
|
//
|
|
// The Regex (apart from lead-in/lead-out):
|
|
// ( Begin capture of the URL
|
|
// (?: (potential detect beginnings)
|
|
// https?:\/\/| Start with "http" or "https"
|
|
// www\d{0,3}[.][a-z0-9.\-]{2,249}|
|
|
// Start with "www", up to 3 numbers, then "." then
|
|
// something that looks domain-namey. We differ from the
|
|
// next case in that we do not constrain the top-level
|
|
// domain as tightly and do not require a trailing path
|
|
// indicator of "/". This is IDN root compatible.
|
|
// [a-z0-9.\-]{2,250}[.][a-z]{2,4}\/
|
|
// Detect a non-www domain, but requiring a trailing "/"
|
|
// to indicate a path. This only detects IDN domains
|
|
// with a non-IDN root. This is reasonable in cases where
|
|
// there is no explicit http/https start us out, but
|
|
// unreasonable where there is. Our real fix is the bug
|
|
// to port the Thunderbird/gecko linkification logic.
|
|
//
|
|
// Domain names can be up to 253 characters long, and are
|
|
// limited to a-zA-Z0-9 and '-'. The roots don't have
|
|
// hyphens unless they are IDN roots. Root zones can be
|
|
// found here: http://www.iana.org/domains/root/db
|
|
// )
|
|
// [-\w.!~*'();,/?:@&=+$#%]*
|
|
// path onwards. We allow the set of characters that
|
|
// encodeURI does not escape plus the result of escaping
|
|
// (so also '%')
|
|
// )
|
|
var RE_URL =
|
|
/(^|[\s(,;])((?:https?:\/\/|www\d{0,3}[.][a-z0-9.\-]{2,249}|[a-z0-9.\-]{2,250}[.][a-z]{2,4}\/)[-\w.!~*'();,/?:@&=+$#%]*)/im;
|
|
// Set of terminators that are likely to have been part of the context rather
|
|
// than part of the URL and so should be uneaten. This is the same as our
|
|
// mirror lead-in set (so '(', ',', ';') plus question end-ing punctuation and
|
|
// the potential permutations with parentheses (english-specific)
|
|
var RE_UNEAT_LAST_URL_CHARS = /(?:[),;.!?]|[.!?]\)|\)[.!?])$/;
|
|
// Don't require the trailing slashes here for pre-pending purposes, although
|
|
// our above regex currently requires them.
|
|
var RE_HTTP = /^https?:/i;
|
|
// Note: the [^\s] is fairly international friendly, but might be too friendly.
|
|
//
|
|
// Note: We've added support for IDN domains in the e-mail regexp. We would
|
|
// expect optimal presentation of IDN-based e-mail addresses to be using HTML
|
|
// mails with an 'a' tag so that the human-readable address is present/visible,
|
|
// but we can't be sure of that.
|
|
//
|
|
// Brief analysis:
|
|
// [a-z0-9.\-]{2,250}[.][a-z0-9\-]{2,32}
|
|
// Domain portion. We have looser constraints on the
|
|
// root in terms of size since we already have the '@'
|
|
// giving us a high probability of an e-mail address.
|
|
// Otherwise we use the same base regexp from our URL
|
|
// logic.
|
|
var RE_MAIL =
|
|
/(^|[\s(,;<>])([^(,;<>@\s]+@[a-z0-9.\-]{2,250}[.][a-z0-9\-]{2,32})/im;
|
|
var RE_MAILTO = /^mailto:/i;
|
|
|
|
var MailUtils = {
|
|
|
|
/**
|
|
* Linkify the given plaintext, producing an Array of HTML nodes as a result.
|
|
*/
|
|
linkifyPlain: function(body, doc) {
|
|
var nodes = [];
|
|
var match = true, contentStart;
|
|
while (true) {
|
|
var url = RE_URL.exec(body);
|
|
var email = RE_MAIL.exec(body);
|
|
// Pick the regexp with the earlier content; index will always be zero.
|
|
if (url &&
|
|
(!email || url.index < email.index)) {
|
|
contentStart = url.index + url[1].length;
|
|
if (contentStart > 0)
|
|
nodes.push(doc.createTextNode(body.substring(0, contentStart)));
|
|
|
|
// There are some final characters for a URL that are much more likely
|
|
// to have been part of the enclosing text rather than the end of the
|
|
// URL.
|
|
var useUrl = url[2];
|
|
var uneat = RE_UNEAT_LAST_URL_CHARS.exec(useUrl);
|
|
if (uneat) {
|
|
useUrl = useUrl.substring(0, uneat.index);
|
|
}
|
|
|
|
var link = doc.createElement('a');
|
|
link.className = 'moz-external-link';
|
|
// the browser app needs us to put a protocol on the front
|
|
if (RE_HTTP.test(url[2]))
|
|
link.setAttribute('ext-href', useUrl);
|
|
else
|
|
link.setAttribute('ext-href', 'http://' + useUrl);
|
|
var text = doc.createTextNode(useUrl);
|
|
link.appendChild(text);
|
|
nodes.push(link);
|
|
|
|
body = body.substring(url.index + url[1].length + useUrl.length);
|
|
}
|
|
else if (email) {
|
|
contentStart = email.index + email[1].length;
|
|
if (contentStart > 0)
|
|
nodes.push(doc.createTextNode(body.substring(0, contentStart)));
|
|
|
|
link = doc.createElement('a');
|
|
link.className = 'moz-external-link';
|
|
if (RE_MAILTO.test(email[2]))
|
|
link.setAttribute('ext-href', email[2]);
|
|
else
|
|
link.setAttribute('ext-href', 'mailto:' + email[2]);
|
|
text = doc.createTextNode(email[2]);
|
|
link.appendChild(text);
|
|
nodes.push(link);
|
|
|
|
body = body.substring(email.index + email[0].length);
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (body.length > 0)
|
|
nodes.push(doc.createTextNode(body));
|
|
|
|
return nodes;
|
|
},
|
|
|
|
/**
|
|
* Process the document of an HTML iframe to linkify the text portions of the
|
|
* HTML document. 'A' tags and their descendants are not linkified, nor
|
|
* are the attributes of HTML nodes.
|
|
*/
|
|
linkifyHTML: function(doc) {
|
|
function linkElem(elem) {
|
|
var children = elem.childNodes;
|
|
for (var i in children) {
|
|
var sub = children[i];
|
|
if (sub.nodeName == '#text') {
|
|
var nodes = MailUtils.linkifyPlain(sub.nodeValue, doc);
|
|
|
|
elem.replaceChild(nodes[nodes.length-1], sub);
|
|
for (var iNode = nodes.length-2; iNode >= 0; --iNode) {
|
|
elem.insertBefore(nodes[iNode], nodes[iNode+1]);
|
|
}
|
|
}
|
|
else if (sub.nodeName != 'A') {
|
|
linkElem(sub);
|
|
}
|
|
}
|
|
}
|
|
|
|
linkElem(doc.body);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* The public API exposed to the client via the MailAPI global.
|
|
*/
|
|
function MailAPI() {
|
|
this._nextHandle = 1;
|
|
|
|
this._slices = {};
|
|
this._pendingRequests = {};
|
|
this._liveBodies = {};
|
|
/**
|
|
* Functions to invoke to actually process/fire splices. Exists to support
|
|
* the fallout of waiting for contact resolution now that slice changes are
|
|
* batched.
|
|
*/
|
|
this._spliceFireFuncs = [];
|
|
|
|
// Store bridgeSend messages received before back end spawns.
|
|
this._storedSends = [];
|
|
|
|
this._processingMessage = null;
|
|
/**
|
|
* List of received messages whose processing is being deferred because we
|
|
* still have a message that is actively being processed, as stored in
|
|
* `_processingMessage`.
|
|
*/
|
|
this._deferredMessages = [];
|
|
|
|
/**
|
|
* @dict[
|
|
* @key[debugLogging]
|
|
* @key[checkInterval]
|
|
* ]{
|
|
* Configuration data. This is currently populated by data from
|
|
* `MailUniverse.exposeConfigForClient` by the code that constructs us. In
|
|
* the future, we will probably want to ask for this from the `MailUniverse`
|
|
* directly over the wire.
|
|
*
|
|
* This should be treated as read-only.
|
|
* }
|
|
*/
|
|
this.config = {};
|
|
|
|
/**
|
|
* @func[
|
|
* @args[
|
|
* @param[account MailAccount]
|
|
* ]
|
|
* ]{
|
|
* A callback invoked when we fail to login to an account and the server
|
|
* explicitly told us the login failed and we have no reason to suspect
|
|
* the login was temporarily disabled.
|
|
*
|
|
* The account is put in a disabled/offline state until such time as the
|
|
*
|
|
* }
|
|
*/
|
|
this.onbadlogin = null;
|
|
|
|
ContactCache.init();
|
|
}
|
|
exports.MailAPI = MailAPI;
|
|
MailAPI.prototype = {
|
|
toString: function() {
|
|
return '[MailAPI]';
|
|
},
|
|
toJSON: function() {
|
|
return { type: 'MailAPI' };
|
|
},
|
|
|
|
utils: MailUtils,
|
|
|
|
/**
|
|
* Send a message over/to the bridge. The idea is that we (can) communicate
|
|
* with the backend using only a postMessage-style JSON channel.
|
|
*/
|
|
__bridgeSend: function(msg) {
|
|
// This method gets clobbered eventually once back end worker is ready.
|
|
// Until then, it will store calls to send to the back end.
|
|
|
|
this._storedSends.push(msg);
|
|
},
|
|
|
|
/**
|
|
* Process a message received from the bridge.
|
|
*/
|
|
__bridgeReceive: function ma___bridgeReceive(msg) {
|
|
// Pong messages are used for tests
|
|
if (this._processingMessage && msg.type !== 'pong') {
|
|
this._deferredMessages.push(msg);
|
|
}
|
|
else {
|
|
this._processMessage(msg);
|
|
}
|
|
},
|
|
|
|
_processMessage: function ma__processMessage(msg) {
|
|
var methodName = '_recv_' + msg.type;
|
|
if (!(methodName in this)) {
|
|
unexpectedBridgeDataError('Unsupported message type:', msg.type);
|
|
return;
|
|
}
|
|
try {
|
|
var done = this[methodName](msg);
|
|
if (!done) {
|
|
this._processingMessage = msg;
|
|
}
|
|
}
|
|
catch (ex) {
|
|
internalError('Problem handling message type:', msg.type, ex,
|
|
'\n', ex.stack);
|
|
return;
|
|
}
|
|
},
|
|
|
|
_doneProcessingMessage: function(msg) {
|
|
if (this._processingMessage && this._processingMessage !== msg)
|
|
throw new Error('Mismatched message completion!');
|
|
|
|
this._processingMessage = null;
|
|
while (this._processingMessage === null && this._deferredMessages.length) {
|
|
this._processMessage(this._deferredMessages.shift());
|
|
}
|
|
},
|
|
|
|
_recv_badLogin: function ma__recv_badLogin(msg) {
|
|
if (this.onbadlogin)
|
|
this.onbadlogin(new MailAccount(this, msg.account, null),
|
|
msg.problem,
|
|
msg.whichSide);
|
|
return true;
|
|
},
|
|
|
|
_fireAllSplices: function() {
|
|
for (var i = 0; i < this._spliceFireFuncs.length; i++) {
|
|
var fireSpliceData = this._spliceFireFuncs[i];
|
|
fireSpliceData();
|
|
}
|
|
|
|
this._spliceFireFuncs.length = 0;
|
|
},
|
|
|
|
_recv_batchSlice: function receiveBatchSlice(msg) {
|
|
var slice = this._slices[msg.handle];
|
|
if (!slice) {
|
|
unexpectedBridgeDataError("Received message about nonexistent slice:", msg.handle);
|
|
return true;
|
|
}
|
|
|
|
var updateStatus = this._updateSliceStatus(msg, slice);
|
|
for (var i = 0; i < msg.sliceUpdates.length; i++) {
|
|
var update = msg.sliceUpdates[i];
|
|
if (update.type === 'update') {
|
|
// Updates are identified by their index position, so they need to be
|
|
// processed in the same order we're hearing about them.
|
|
this._spliceFireFuncs.push(
|
|
this._processSliceUpdate.bind(this, msg, update.updates, slice));
|
|
} else {
|
|
// Added items are transformed immediately, but the actual mutation of
|
|
// the slice and notifications do not fire until _fireAllSplices().
|
|
this._transformAndEnqueueSingleSplice(msg, update, slice);
|
|
}
|
|
}
|
|
|
|
// If there are pending contact resolutions, we need to wait them to
|
|
// complete before processing and firing the splices.
|
|
if (ContactCache.pendingLookupCount) {
|
|
ContactCache.callbacks.push(function contactsResolved() {
|
|
this._fireAllSplices();
|
|
this._fireStatusNotifications(updateStatus, slice);
|
|
this._doneProcessingMessage(msg);
|
|
}.bind(this));
|
|
// (Wait for us to call _doneProcessingMessage before processing the next
|
|
// message. This also means this method will only push one callback.)
|
|
return false;
|
|
}
|
|
|
|
this._fireAllSplices();
|
|
this._fireStatusNotifications(updateStatus, slice);
|
|
return true; // All done processing; feel free to process the next msg.
|
|
},
|
|
|
|
_fireStatusNotifications: function (updateStatus, slice) {
|
|
if (updateStatus && slice.onstatus) {
|
|
slice.onstatus(slice.status);
|
|
}
|
|
},
|
|
|
|
_updateSliceStatus: function(msg, slice) {
|
|
// - generate namespace-specific notifications
|
|
slice.atTop = msg.atTop;
|
|
slice.atBottom = msg.atBottom;
|
|
slice.userCanGrowUpwards = msg.userCanGrowUpwards;
|
|
slice.userCanGrowDownwards = msg.userCanGrowDownwards;
|
|
|
|
// Have to update slice status before we actually do the work
|
|
var generatedStatusChange = (msg.status &&
|
|
(slice.status !== msg.status ||
|
|
slice.syncProgress !== msg.progress));
|
|
|
|
if (msg.status) {
|
|
slice.status = msg.status;
|
|
slice.syncProgress = msg.syncProgress;
|
|
}
|
|
|
|
return generatedStatusChange;
|
|
},
|
|
|
|
_processSliceUpdate: function (msg, splice, slice) {
|
|
try {
|
|
for (var i = 0; i < splice.length; i += 2) {
|
|
var idx = splice[i], wireRep = splice[i + 1],
|
|
itemObj = slice.items[idx];
|
|
itemObj.__update(wireRep);
|
|
if (slice.onchange) {
|
|
slice.onchange(itemObj, idx);
|
|
}
|
|
if (itemObj.onchange) {
|
|
itemObj.onchange(itemObj, idx);
|
|
}
|
|
}
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('onchange notification error', ex,
|
|
'\n', ex.stack);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Transform the slice splice (for contact-resolution side-effects) and
|
|
* enqueue the eventual processing and firing of the splice once all contacts
|
|
* have been resolved.
|
|
*/
|
|
_transformAndEnqueueSingleSplice: function(msg, splice, slice) {
|
|
var transformedItems = this._transform_sliceSplice(splice, slice);
|
|
var fake = false;
|
|
// It's possible that a transformed representation is depending on an async
|
|
// call to mozContacts. In this case, we don't want to surface the data to
|
|
// the UI until the contacts are fully resolved in order to avoid the UI
|
|
// flickering or just triggering reflows that could otherwise be avoided.
|
|
// Since we could be processing multiple updates, just batch everything here
|
|
// and we'll check later to see if any of our splices requires a contact
|
|
// lookup
|
|
this._spliceFireFuncs.push(function singleSpliceUpdate() {
|
|
this._fireSplice(splice, slice, transformedItems, fake);
|
|
}.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Perform the actual splice, generating notifications.
|
|
*/
|
|
_fireSplice: function(splice, slice, transformedItems, fake) {
|
|
var i, stopIndex, items, tempMsg;
|
|
|
|
// - update header count, but only if the splice tracks a
|
|
// headerCount.
|
|
if (splice.headerCount !== undefined) {
|
|
slice.headerCount = splice.headerCount;
|
|
}
|
|
|
|
// - generate slice 'onsplice' notification
|
|
if (slice.onsplice) {
|
|
try {
|
|
slice.onsplice(splice.index, splice.howMany, transformedItems,
|
|
splice.requested, splice.moreExpected, fake);
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('onsplice notification error', ex,
|
|
'\n', ex.stack);
|
|
}
|
|
}
|
|
// - generate item 'onremove' notifications
|
|
if (splice.howMany) {
|
|
try {
|
|
stopIndex = splice.index + splice.howMany;
|
|
for (i = splice.index; i < stopIndex; i++) {
|
|
var item = slice.items[i];
|
|
if (slice.onremove)
|
|
slice.onremove(item, i);
|
|
if (item.onremove)
|
|
item.onremove(item, i);
|
|
// the item needs a chance to clean up after itself.
|
|
item.__die();
|
|
}
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('onremove notification error', ex,
|
|
'\n', ex.stack);
|
|
}
|
|
}
|
|
// - perform actual splice
|
|
slice.items.splice.apply(
|
|
slice.items,
|
|
[splice.index, splice.howMany].concat(transformedItems));
|
|
|
|
// - generate item 'onadd' notifications
|
|
if (slice.onadd) {
|
|
try {
|
|
stopIndex = splice.index + transformedItems.length;
|
|
for (i = splice.index; i < stopIndex; i++) {
|
|
slice.onadd(slice.items[i], i);
|
|
}
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('onadd notification error', ex,
|
|
'\n', ex.stack);
|
|
}
|
|
}
|
|
|
|
// - generate 'oncomplete' notification
|
|
if (splice.requested && !splice.moreExpected) {
|
|
slice._growing = 0;
|
|
if (slice.pendingRequestCount)
|
|
slice.pendingRequestCount--;
|
|
|
|
if (slice.oncomplete) {
|
|
var completeFunc = slice.oncomplete;
|
|
// reset before calling in case it wants to chain.
|
|
slice.oncomplete = null;
|
|
try {
|
|
// Maybe defer here?
|
|
completeFunc(splice.newEmailCount);
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('oncomplete notification error', ex,
|
|
'\n', ex.stack);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
_transform_sliceSplice: function ma__transform_sliceSplice(splice, slice) {
|
|
var addItems = splice.addItems, transformedItems = [], i;
|
|
switch (slice._ns) {
|
|
case 'accounts':
|
|
for (i = 0; i < addItems.length; i++) {
|
|
transformedItems.push(new MailAccount(this, addItems[i], slice));
|
|
}
|
|
break;
|
|
|
|
case 'identities':
|
|
for (i = 0; i < addItems.length; i++) {
|
|
transformedItems.push(new MailSenderIdentity(this, addItems[i]));
|
|
}
|
|
break;
|
|
|
|
case 'folders':
|
|
for (i = 0; i < addItems.length; i++) {
|
|
transformedItems.push(new MailFolder(this, addItems[i]));
|
|
}
|
|
break;
|
|
|
|
case 'headers':
|
|
for (i = 0; i < addItems.length; i++) {
|
|
transformedItems.push(new MailHeader(slice, addItems[i]));
|
|
}
|
|
break;
|
|
|
|
case 'matchedHeaders':
|
|
for (i = 0; i < addItems.length; i++) {
|
|
transformedItems.push(new MailMatchedHeader(slice, addItems[i]));
|
|
}
|
|
break;
|
|
|
|
|
|
default:
|
|
console.error('Slice notification for unknown type:', slice._ns);
|
|
break;
|
|
}
|
|
|
|
return transformedItems;
|
|
},
|
|
|
|
_recv_sliceDead: function(msg) {
|
|
var slice = this._slices[msg.handle];
|
|
delete this._slices[msg.handle];
|
|
if (slice.ondead)
|
|
slice.ondead(slice);
|
|
slice.ondead = null;
|
|
|
|
return true;
|
|
},
|
|
|
|
_getBodyForMessage: function(header, options, callback) {
|
|
var downloadBodyReps = false, withBodyReps = false;
|
|
|
|
if (options && options.downloadBodyReps) {
|
|
downloadBodyReps = options.downloadBodyReps;
|
|
}
|
|
if (options && options.withBodyReps) {
|
|
withBodyReps = options.withBodyReps;
|
|
}
|
|
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'getBody',
|
|
suid: header.id,
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'getBody',
|
|
handle: handle,
|
|
suid: header.id,
|
|
date: header.date.valueOf(),
|
|
downloadBodyReps: downloadBodyReps,
|
|
withBodyReps: withBodyReps
|
|
});
|
|
},
|
|
|
|
_recv_gotBody: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle for got body:', msg.handle);
|
|
return true;
|
|
}
|
|
delete this._pendingRequests[msg.handle];
|
|
|
|
var body = msg.bodyInfo ?
|
|
new MailBody(this, req.suid, msg.bodyInfo, msg.handle) :
|
|
null;
|
|
|
|
if (body) {
|
|
this._liveBodies[msg.handle] = body;
|
|
}
|
|
|
|
req.callback.call(null, body, req.suid);
|
|
|
|
return true;
|
|
},
|
|
|
|
_recv_requestBodiesComplete: function(msg) {
|
|
var slice = this._slices[msg.handle];
|
|
// The slice may be dead now!
|
|
if (slice)
|
|
slice._notifyRequestBodiesComplete(msg.requestId);
|
|
|
|
return true;
|
|
},
|
|
|
|
_recv_bodyModified: function(msg) {
|
|
var body = this._liveBodies[msg.handle];
|
|
|
|
if (!body) {
|
|
unexpectedBridgeDataError('body modified for dead handle', msg.handle);
|
|
// possible but very unlikely race condition where body is modified while
|
|
// we are removing the reference to the observer...
|
|
return true;
|
|
}
|
|
|
|
var wireRep = msg.bodyInfo;
|
|
// We update the body representation regardless of whether there is an
|
|
// onchange listener because the body may contain Blob handles that need to
|
|
// be updated so that in-memory blobs that have been superseded by on-disk
|
|
// Blobs can be garbage collected.
|
|
body.__update(wireRep, msg.detail);
|
|
|
|
if (body.onchange) {
|
|
body.onchange(
|
|
msg.detail,
|
|
body
|
|
);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
_recv_bodyDead: function(msg) {
|
|
var body = this._liveBodies[msg.handle];
|
|
|
|
if (body && body.ondead) {
|
|
body.ondead();
|
|
}
|
|
|
|
delete this._liveBodies[msg.handle];
|
|
return true;
|
|
},
|
|
|
|
_downloadAttachments: function(body, relPartIndices, attachmentIndices,
|
|
registerAttachments,
|
|
callWhenDone, callOnProgress) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'downloadAttachments',
|
|
body: body,
|
|
relParts: relPartIndices.length > 0,
|
|
attachments: attachmentIndices.length > 0,
|
|
callback: callWhenDone,
|
|
progress: callOnProgress
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'downloadAttachments',
|
|
handle: handle,
|
|
suid: body.id,
|
|
date: body._date,
|
|
relPartIndices: relPartIndices,
|
|
attachmentIndices: attachmentIndices,
|
|
registerAttachments: registerAttachments
|
|
});
|
|
},
|
|
|
|
_recv_downloadedAttachments: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle for got body:', msg.handle);
|
|
return true;
|
|
}
|
|
delete this._pendingRequests[msg.handle];
|
|
|
|
// We used to update the attachment representations here. This is now
|
|
// handled by `bodyModified` notifications which are guaranteed to occur
|
|
// prior to this callback being invoked.
|
|
|
|
if (req.callback)
|
|
req.callback.call(null, req.body);
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Given a user's email address, try and see if we can autoconfigure the
|
|
* account and what information we'll need to configure it, specifically
|
|
* a password or if XOAuth2 credentials will be needed.
|
|
*
|
|
* @param {Object} details
|
|
* @param {String} details.emailAddress
|
|
* The user's email address.
|
|
* @param {Function} callback
|
|
* Invoked once we have an answer. The object will look something like
|
|
* one of the following results:
|
|
*
|
|
* No autoconfig information is available and the user has to do manual
|
|
* setup:
|
|
*
|
|
* {
|
|
* result: 'no-config-info',
|
|
* configInfo: null
|
|
* }
|
|
*
|
|
* Autoconfig information is available and to complete the autoconfig
|
|
* we need the user's password. For IMAP and POP3 this means we know
|
|
* everything we need and can actually create the account. For ActiveSync
|
|
* we actually need the password to try and perform autodiscovery.
|
|
*
|
|
* {
|
|
* result: 'need-password',
|
|
* configInfo: { incoming, outgoing }
|
|
* }
|
|
*
|
|
* Autoconfig information is available and XOAuth2 authentication should
|
|
* be attempted and those credentials then provided to us.
|
|
*
|
|
* {
|
|
* result: 'need-oauth2',
|
|
* configInfo: {
|
|
* incoming,
|
|
* outgoing,
|
|
* oauth2Settings: {
|
|
* secretGroup: 'google' or 'microsoft' or other arbitrary string,
|
|
* authEndpoint: 'url to the auth endpoint',
|
|
* tokenEndpoint: 'url to where you ask for tokens',
|
|
* scope: 'space delimited list of scopes to request'
|
|
* }
|
|
* }
|
|
* }
|
|
*
|
|
* A `source` property will also be present in the result object. Its
|
|
* value will be one of: 'hardcoded', 'local', 'ispdb',
|
|
* 'autoconfig-subdomain', 'autoconfig-wellknown', 'mx local', 'mx ispdb',
|
|
* 'autodiscover'.
|
|
*/
|
|
learnAboutAccount: function(details, callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'learnAboutAccount',
|
|
details: details,
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'learnAboutAccount',
|
|
handle: handle,
|
|
details: details
|
|
});
|
|
},
|
|
|
|
_recv_learnAboutAccountResults: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle:', msg.handle);
|
|
return true;
|
|
}
|
|
delete this._pendingRequests[msg.handle];
|
|
|
|
req.callback.call(null, msg.data);
|
|
return true;
|
|
},
|
|
|
|
|
|
/**
|
|
* Try to create an account. There is currently no way to abort the process
|
|
* of creating an account. You really want to use learnAboutAccount before
|
|
* you call this unless you are an automated test.
|
|
*
|
|
* @typedef[AccountCreationError @oneof[
|
|
* @case['offline']{
|
|
* We are offline and have no network access to try and create the
|
|
* account.
|
|
* }
|
|
* @case['no-dns-entry']{
|
|
* We couldn't find the domain name in question, full stop.
|
|
*
|
|
* Not currently generated; eventually desired because it suggests a typo
|
|
* and so a specialized error message is useful.
|
|
* }
|
|
* @case['no-config-info']{
|
|
* We were unable to locate configuration information for the domain.
|
|
* }
|
|
* @case['unresponsive-server']{
|
|
* Requests to the server timed out. AKA we sent packets into a black
|
|
* hole.
|
|
* }
|
|
* @case['port-not-listening']{
|
|
* Attempts to connect to the given port on the server failed. We got
|
|
* packets back rejecting our connection.
|
|
*
|
|
* Not currently generated; primarily desired because it is very useful if
|
|
* we are domain guessing. Also desirable for error messages because it
|
|
* suggests a user typo or the less likely server outage.
|
|
* }
|
|
* @case['bad-security']{
|
|
* We were able to connect to the port and initiate TLS, but we didn't
|
|
* like what we found. This could be a mismatch on the server domain,
|
|
* a self-signed or otherwise invalid certificate, insufficient crypto,
|
|
* or a vulnerable server implementation.
|
|
* }
|
|
* @case['bad-user-or-pass']{
|
|
* The username and password didn't check out. We don't know which one
|
|
* is wrong, just that one of them is wrong.
|
|
* }
|
|
* @case['bad-address']{
|
|
* The e-mail address provided was rejected by the SMTP probe.
|
|
* }
|
|
* @case['pop-server-not-great']{
|
|
* The POP3 server doesn't support IDLE and TOP, so we can't use it.
|
|
* }
|
|
* @case['imap-disabled']{
|
|
* IMAP support is not enabled for the Gmail account in use.
|
|
* }
|
|
* @case['pop3-disabled']{
|
|
* POP3 support is not enabled for the Gmail account in use.
|
|
* }
|
|
* @case['needs-oauth-reauth']{
|
|
* The OAUTH refresh token was invalid, or there was some problem with
|
|
* the OAUTH credentials provided. The user needs to go through the
|
|
* OAUTH flow again.
|
|
* }
|
|
* @case['not-authorized']{
|
|
* The username and password are correct, but the user isn't allowed to
|
|
* access the mail server.
|
|
* }
|
|
* @case['server-problem']{
|
|
* We were able to talk to the "server" named in the details object, but
|
|
* we encountered some type of problem. The details object will also
|
|
* include a "status" value.
|
|
* }
|
|
* @case['server-maintenance']{
|
|
* The server appears to be undergoing maintenance, at least for this
|
|
* account. We infer this if the server is telling us that login is
|
|
* disabled in general or when we try and login the message provides
|
|
* positive indications of some type of maintenance rather than a
|
|
* generic error string.
|
|
* }
|
|
* @case['user-account-exists']{
|
|
* If the user tries to create an account which is already configured.
|
|
* Should not be created. We will show that account is already configured
|
|
* }
|
|
* @case['unknown']{
|
|
* We don't know what happened; count this as our bug for not knowing.
|
|
* }
|
|
* @case[null]{
|
|
* No error, the account was created and everything is terrific.
|
|
* }
|
|
* ]]
|
|
*
|
|
* @param {Object} details
|
|
* @param {String} details.emailAddress
|
|
* @param {String} [details.password]
|
|
* The user's password
|
|
* @param {Object} [configInfo]
|
|
* If continuing an autoconfig initiated by learnAboutAccount, the
|
|
* configInfo it returned as part of its results, although you will need
|
|
* to poke the following structured properties in if you're doing the oauth2
|
|
* thing:
|
|
*
|
|
* {
|
|
* oauth2Secrets: { clientId, clientSecret }
|
|
* oauth2Tokens: { accessToken, refreshToken, expireTimeMS }
|
|
* }
|
|
*
|
|
* If performing a manual config, a manually created configInfo object of
|
|
* the following form:
|
|
*
|
|
* {
|
|
* incoming: { hostname, port, socketType, username, password }
|
|
* outgoing: { hostname, port, socketType, username, password }
|
|
* }
|
|
*
|
|
*
|
|
*
|
|
* @param {Function} callback
|
|
* The callback to invoke upon success or failure. The callback will be
|
|
* called with 2 arguments in the case of failure: the error string code,
|
|
* and the error details object.
|
|
*
|
|
*
|
|
* @args[
|
|
* @param[details @dict[
|
|
* @key[displayName String]{
|
|
* The name the (human, per EULA) user wants to be known to the world
|
|
* as.
|
|
* }
|
|
* @key[emailAddress String]
|
|
* @key[password String]
|
|
* ]]
|
|
* @param[callback @func[
|
|
* @args[
|
|
* @param[err AccountCreationError]
|
|
* @param[errDetails @dict[
|
|
* @key[server #:optional String]{
|
|
* The server we had trouble talking to.
|
|
* }
|
|
* @key[status #:optional @oneof[Number String]]{
|
|
* The HTTP status code number, or "timeout", or something otherwise
|
|
* providing detailed additional information about the error. This
|
|
* is usually too technical to be presented to the user, but is
|
|
* worth encoding with the error name proper if possible.
|
|
* }
|
|
* ]]
|
|
* ]
|
|
* ]
|
|
* ]
|
|
*/
|
|
tryToCreateAccount: function ma_tryToCreateAccount(details, domainInfo,
|
|
callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'tryToCreateAccount',
|
|
details: details,
|
|
domainInfo: domainInfo,
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'tryToCreateAccount',
|
|
handle: handle,
|
|
details: details,
|
|
domainInfo: domainInfo
|
|
});
|
|
},
|
|
|
|
_recv_tryToCreateAccountResults:
|
|
function ma__recv_tryToCreateAccountResults(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle for create account:', msg.handle);
|
|
return true;
|
|
}
|
|
delete this._pendingRequests[msg.handle];
|
|
|
|
// We create this account to expose modifications functions to the
|
|
// frontend before we have access to the full accounts slice. Note that
|
|
// we may not have an account if we failed to create the account!
|
|
var account;
|
|
if (msg.account) {
|
|
account = new MailAccount(this, msg.account, null);
|
|
}
|
|
|
|
req.callback.call(null, msg.error, msg.errorDetails, account);
|
|
return true;
|
|
},
|
|
|
|
_clearAccountProblems: function ma__clearAccountProblems(account, callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'clearAccountProblems',
|
|
callback: callback,
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'clearAccountProblems',
|
|
accountId: account.id,
|
|
handle: handle,
|
|
});
|
|
},
|
|
|
|
_recv_clearAccountProblems: function ma__recv_clearAccountProblems(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
delete this._pendingRequests[msg.handle];
|
|
req.callback && req.callback();
|
|
return true;
|
|
},
|
|
|
|
_modifyAccount: function ma__modifyAccount(account, mods, callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'modifyAccount',
|
|
callback: callback,
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'modifyAccount',
|
|
accountId: account.id,
|
|
mods: mods,
|
|
handle: handle
|
|
});
|
|
},
|
|
|
|
_recv_modifyAccount: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
delete this._pendingRequests[msg.handle];
|
|
req.callback && req.callback();
|
|
return true;
|
|
},
|
|
|
|
_deleteAccount: function ma__deleteAccount(account) {
|
|
this.__bridgeSend({
|
|
type: 'deleteAccount',
|
|
accountId: account.id,
|
|
});
|
|
},
|
|
|
|
_modifyIdentity: function ma__modifyIdentity(identity, mods, callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'modifyIdentity',
|
|
callback: callback,
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'modifyIdentity',
|
|
identityId: identity.id,
|
|
mods: mods,
|
|
handle: handle
|
|
});
|
|
},
|
|
|
|
_recv_modifyIdentity: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
delete this._pendingRequests[msg.handle];
|
|
req.callback && req.callback();
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Get the list of accounts. This can be used for the list of accounts in
|
|
* setttings or for a folder tree where only one account's folders are visible
|
|
* at a time.
|
|
*
|
|
* @args[
|
|
* @param[realAccountsOnly Boolean]{
|
|
* Should we only list real accounts (aka not unified accounts)? This is
|
|
* meaningful for the settings UI and for the move-to-folder UI where
|
|
* selecting a unified account's folders is useless.
|
|
* }
|
|
* ]
|
|
*/
|
|
viewAccounts: function ma_viewAccounts(realAccountsOnly) {
|
|
var handle = this._nextHandle++,
|
|
slice = new AccountsViewSlice(this, handle);
|
|
this._slices[handle] = slice;
|
|
|
|
this.__bridgeSend({
|
|
type: 'viewAccounts',
|
|
handle: handle,
|
|
});
|
|
return slice;
|
|
},
|
|
|
|
/**
|
|
* Get the list of sender identities. The identities can also be found on
|
|
* their owning accounts via `viewAccounts`.
|
|
*/
|
|
viewSenderIdentities: function ma_viewSenderIdentities() {
|
|
var handle = this._nextHandle++,
|
|
slice = new BridgedViewSlice(this, 'identities', handle);
|
|
this._slices[handle] = slice;
|
|
|
|
this.__bridgeSend({
|
|
type: 'viewSenderIdentities',
|
|
handle: handle,
|
|
});
|
|
return slice;
|
|
},
|
|
|
|
/**
|
|
* Retrieve the entire folder hierarchy for either 'navigation' (pick what
|
|
* folder to show the contents of, including unified folders), 'movetarget'
|
|
* (pick target folder for moves, does not include unified folders), or
|
|
* 'account' (only show the folders belonging to a given account, implies
|
|
* selection). In all cases, there may exist non-selectable folders such as
|
|
* the account roots or IMAP folders that cannot contain messages.
|
|
*
|
|
* When accounts are presented as folders via this UI, they do not expose any
|
|
* of their `MailAccount` semantics.
|
|
*
|
|
* @args[
|
|
* @param[mode @oneof['navigation' 'movetarget' 'account']
|
|
* @param[argument #:optional]{
|
|
* Arguent appropriate to the mode; currently will only be a `MailAccount`
|
|
* instance.
|
|
* }
|
|
* ]
|
|
*/
|
|
viewFolders: function ma_viewFolders(mode, argument) {
|
|
var handle = this._nextHandle++,
|
|
slice = new FoldersViewSlice(this, handle);
|
|
|
|
this._slices[handle] = slice;
|
|
|
|
this.__bridgeSend({
|
|
type: 'viewFolders',
|
|
mode: mode,
|
|
handle: handle,
|
|
argument: argument ? argument.id : null,
|
|
});
|
|
|
|
return slice;
|
|
},
|
|
|
|
/**
|
|
* Retrieve a slice of the contents of a folder, starting from the most recent
|
|
* messages.
|
|
*/
|
|
viewFolderMessages: function ma_viewFolderMessages(folder) {
|
|
var handle = this._nextHandle++,
|
|
slice = new HeadersViewSlice(this, handle);
|
|
slice.folderId = folder.id;
|
|
// the initial population counts as a request.
|
|
slice.pendingRequestCount++;
|
|
this._slices[handle] = slice;
|
|
|
|
this.__bridgeSend({
|
|
type: 'viewFolderMessages',
|
|
folderId: folder.id,
|
|
handle: handle,
|
|
});
|
|
|
|
return slice;
|
|
},
|
|
|
|
/**
|
|
* Search a folder for messages containing the given text in the sender,
|
|
* recipients, or subject fields, as well as (optionally), the body with a
|
|
* default time constraint so we don't entirely kill the server or us.
|
|
*
|
|
* @args[
|
|
* @param[folder]{
|
|
* The folder whose messages we should search.
|
|
* }
|
|
* @param[text]{
|
|
* The phrase to search for. We don't split this up into words or
|
|
* anything like that. We just do straight-up indexOf on the whole thing.
|
|
* }
|
|
* @param[whatToSearch @dict[
|
|
* @key[author #:optional Boolean]
|
|
* @key[recipients #:optional Boolean]
|
|
* @key[subject #:optional Boolean]
|
|
* @key[body #:optional @oneof[false 'no-quotes' 'yes-quotes']]
|
|
* ]]
|
|
* ]
|
|
*/
|
|
searchFolderMessages:
|
|
function ma_searchFolderMessages(folder, text, whatToSearch) {
|
|
var handle = this._nextHandle++,
|
|
slice = new HeadersViewSlice(this, handle, 'matchedHeaders');
|
|
// the initial population counts as a request.
|
|
slice.pendingRequestCount++;
|
|
this._slices[handle] = slice;
|
|
|
|
this.__bridgeSend({
|
|
type: 'searchFolderMessages',
|
|
folderId: folder.id,
|
|
handle: handle,
|
|
phrase: text,
|
|
whatToSearch: whatToSearch,
|
|
});
|
|
|
|
return slice;
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Batch Message Mutation
|
|
//
|
|
// If you want to modify a single message, you can use the methods on it
|
|
// directly.
|
|
//
|
|
// All actions are undoable and return an `UndoableOperation`.
|
|
|
|
deleteMessages: function ma_deleteMessages(messages) {
|
|
// We allocate a handle that provides a temporary name for our undoable
|
|
// operation until we hear back from the other side about it.
|
|
var handle = this._nextHandle++;
|
|
|
|
var undoableOp = new UndoableOperation(this, 'delete', messages.length,
|
|
handle),
|
|
msgSuids = messages.map(serializeMessageName);
|
|
|
|
this._pendingRequests[handle] = {
|
|
type: 'mutation',
|
|
handle: handle,
|
|
undoableOp: undoableOp
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'deleteMessages',
|
|
handle: handle,
|
|
messages: msgSuids,
|
|
});
|
|
|
|
return undoableOp;
|
|
},
|
|
|
|
// Copying messages is not required yet.
|
|
/*
|
|
copyMessages: function ma_copyMessages(messages, targetFolder) {
|
|
},
|
|
*/
|
|
|
|
moveMessages: function ma_moveMessages(messages, targetFolder, callback) {
|
|
// We allocate a handle that provides a temporary name for our undoable
|
|
// operation until we hear back from the other side about it.
|
|
var handle = this._nextHandle++;
|
|
|
|
var undoableOp = new UndoableOperation(this, 'move', messages.length,
|
|
handle),
|
|
msgSuids = messages.map(serializeMessageName);
|
|
|
|
this._pendingRequests[handle] = {
|
|
type: 'mutation',
|
|
handle: handle,
|
|
undoableOp: undoableOp,
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'moveMessages',
|
|
handle: handle,
|
|
messages: msgSuids,
|
|
targetFolder: targetFolder.id
|
|
});
|
|
|
|
return undoableOp;
|
|
},
|
|
|
|
markMessagesRead: function ma_markMessagesRead(messages, beRead) {
|
|
return this.modifyMessageTags(messages,
|
|
beRead ? ['\\Seen'] : null,
|
|
beRead ? null : ['\\Seen'],
|
|
beRead ? 'read' : 'unread');
|
|
},
|
|
|
|
markMessagesStarred: function ma_markMessagesStarred(messages, beStarred) {
|
|
return this.modifyMessageTags(messages,
|
|
beStarred ? ['\\Flagged'] : null,
|
|
beStarred ? null : ['\\Flagged'],
|
|
beStarred ? 'star' : 'unstar');
|
|
},
|
|
|
|
modifyMessageTags: function ma_modifyMessageTags(messages, addTags,
|
|
removeTags, _opcode) {
|
|
// We allocate a handle that provides a temporary name for our undoable
|
|
// operation until we hear back from the other side about it.
|
|
var handle = this._nextHandle++;
|
|
|
|
if (!_opcode) {
|
|
if (addTags && addTags.length)
|
|
_opcode = 'addtag';
|
|
else if (removeTags && removeTags.length)
|
|
_opcode = 'removetag';
|
|
}
|
|
var undoableOp = new UndoableOperation(this, _opcode, messages.length,
|
|
handle),
|
|
msgSuids = messages.map(serializeMessageName);
|
|
|
|
this._pendingRequests[handle] = {
|
|
type: 'mutation',
|
|
handle: handle,
|
|
undoableOp: undoableOp
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'modifyMessageTags',
|
|
handle: handle,
|
|
opcode: _opcode,
|
|
addTags: addTags,
|
|
removeTags: removeTags,
|
|
messages: msgSuids,
|
|
});
|
|
|
|
return undoableOp;
|
|
},
|
|
|
|
/**
|
|
* Check the outbox for pending messages, and initiate a series of
|
|
* jobs to attempt to send them. The callback fires after the first
|
|
* message's send attempt completes; this job will then
|
|
* self-schedule further jobs to attempt to send the rest of the
|
|
* outbox.
|
|
*
|
|
* @param {MailAccount} account
|
|
* @param {function} callback
|
|
* Called after the first message's send attempt finishes.
|
|
*/
|
|
sendOutboxMessages: function (account, callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'sendOutboxMessages',
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'sendOutboxMessages',
|
|
accountId: account.id,
|
|
handle: handle
|
|
});
|
|
},
|
|
|
|
_recv_sendOutboxMessages: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
delete this._pendingRequests[msg.handle];
|
|
req.callback && req.callback();
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Enable or disable outbox syncing for this account. This is
|
|
* generally a temporary measure, used when the user is actively
|
|
* editing the list of outbox messages and we don't want to
|
|
* inadvertently move something out from under them. This change
|
|
* does _not_ persist; it's meant to be used only for brief periods
|
|
* of time, not as a "sync schedule" coordinator.
|
|
*/
|
|
setOutboxSyncEnabled: function (account, enabled, callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'setOutboxSyncEnabled',
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'setOutboxSyncEnabled',
|
|
accountId: account.id,
|
|
outboxSyncEnabled: enabled,
|
|
handle: handle
|
|
});
|
|
},
|
|
|
|
_recv_setOutboxSyncEnabled: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
delete this._pendingRequests[msg.handle];
|
|
req.callback && req.callback();
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Parse a structured email address
|
|
* into a display name and email address parts.
|
|
* It will return null on a parse failure.
|
|
*
|
|
* @param {String} email A email address.
|
|
* @return {Object} An object of the form { name, address }.
|
|
*/
|
|
parseMailbox: function(email) {
|
|
try {
|
|
var mailbox = addressparser.parse(email);
|
|
return (mailbox.length >= 1) ? mailbox[0] : null;
|
|
}
|
|
catch (ex) {
|
|
reportClientCodeError('parse mailbox error', ex,
|
|
'\n', ex.stack);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
_recv_mutationConfirmed: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle for mutation:', msg.handle);
|
|
return true;
|
|
}
|
|
|
|
req.undoableOp._tempHandle = null;
|
|
req.undoableOp._longtermIds = msg.longtermIds;
|
|
if (req.undoableOp._undoRequested)
|
|
req.undoableOp.undo();
|
|
|
|
if (req.callback) {
|
|
req.callback(msg.result);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
__undo: function undo(undoableOp) {
|
|
this.__bridgeSend({
|
|
type: 'undo',
|
|
longtermIds: undoableOp._longtermIds,
|
|
});
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Contact Support
|
|
|
|
resolveEmailAddressToPeep: function(emailAddress, callback) {
|
|
var peep = ContactCache.resolvePeep({ name: null, address: emailAddress });
|
|
if (ContactCache.pendingLookupCount)
|
|
ContactCache.callbacks.push(callback.bind(null, peep));
|
|
else
|
|
callback(peep);
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Message Composition
|
|
|
|
/**
|
|
* Begin the message composition process, creating a MessageComposition that
|
|
* stores the current message state and periodically persists its state to the
|
|
* backend so that the message is potentially available to other clients and
|
|
* recoverable in the event of a local crash.
|
|
*
|
|
* Composition is triggered in the context of a given message and folder so
|
|
* that the correct account and sender identity for composition can be
|
|
* inferred. Message may be null if there are no messages in the folder.
|
|
* Folder is not required if a message is provided.
|
|
*
|
|
* @args[
|
|
* @param[message #:optional MailHeader]{
|
|
* Some message to use as context when not issuing a reply/forward.
|
|
* }
|
|
* @param[folder #:optional MailFolder]{
|
|
* The folder to use as context if no `message` is provided and not
|
|
* issuing a reply/forward.
|
|
* }
|
|
* @param[options #:optional @dict[
|
|
* @key[replyTo #:optional MailHeader]
|
|
* @key[replyMode #:optional @oneof[null 'list' 'all']]
|
|
* @key[forwardOf #:optional MailHeader]
|
|
* @key[forwardMode #:optional @oneof['inline']]
|
|
* ]]
|
|
* @param[callback #:optional Function]{
|
|
* The callback to invoke once the composition handle is fully populated.
|
|
* This is necessary because the back-end decides what identity is
|
|
* appropriate, handles "re:" prefixing, quoting messages, etc.
|
|
* }
|
|
* ]
|
|
*/
|
|
beginMessageComposition: function(message, folder, options, callback) {
|
|
if (!callback)
|
|
throw new Error('A callback must be provided; you are using the API ' +
|
|
'wrong if you do not.');
|
|
if (!options)
|
|
options = {};
|
|
|
|
var handle = this._nextHandle++,
|
|
composer = new MessageComposition(this, handle);
|
|
|
|
this._pendingRequests[handle] = {
|
|
type: 'compose',
|
|
composer: composer,
|
|
callback: callback,
|
|
};
|
|
var msg = {
|
|
type: 'beginCompose',
|
|
handle: handle,
|
|
mode: null,
|
|
submode: null,
|
|
refSuid: null,
|
|
refDate: null,
|
|
refGuid: null,
|
|
refAuthor: null,
|
|
refSubject: null,
|
|
};
|
|
if (options.hasOwnProperty('replyTo') && options.replyTo) {
|
|
msg.mode = 'reply';
|
|
msg.submode = options.replyMode;
|
|
msg.refSuid = options.replyTo.id;
|
|
msg.refDate = options.replyTo.date.valueOf();
|
|
msg.refGuid = options.replyTo.guid;
|
|
msg.refAuthor = options.replyTo.author.toWireRep();
|
|
msg.refSubject = options.replyTo.subject;
|
|
}
|
|
else if (options.hasOwnProperty('forwardOf') && options.forwardOf) {
|
|
msg.mode = 'forward';
|
|
msg.submode = options.forwardMode;
|
|
msg.refSuid = options.forwardOf.id;
|
|
msg.refDate = options.forwardOf.date.valueOf();
|
|
msg.refGuid = options.forwardOf.guid;
|
|
msg.refAuthor = options.forwardOf.author.toWireRep();
|
|
msg.refSubject = options.forwardOf.subject;
|
|
}
|
|
else {
|
|
msg.mode = 'new';
|
|
if (message) {
|
|
msg.submode = 'message';
|
|
msg.refSuid = message.id;
|
|
}
|
|
else if (folder) {
|
|
msg.submode = 'folder';
|
|
msg.refSuid = folder.id;
|
|
}
|
|
}
|
|
this.__bridgeSend(msg);
|
|
return composer;
|
|
},
|
|
|
|
/**
|
|
* Open a message as if it were a draft message (hopefully it is), returning
|
|
* a MessageComposition object that will be asynchronously populated. The
|
|
* provided callback will be notified once all composition state has been
|
|
* loaded.
|
|
*
|
|
* The underlying message will be replaced by other messages as the draft
|
|
* is updated and effectively deleted once the draft is completed. (A
|
|
* move may be performed instead.)
|
|
*/
|
|
resumeMessageComposition: function(message, callback) {
|
|
if (!callback)
|
|
throw new Error('A callback must be provided; you are using the API ' +
|
|
'wrong if you do not.');
|
|
|
|
var handle = this._nextHandle++,
|
|
composer = new MessageComposition(this, handle);
|
|
|
|
this._pendingRequests[handle] = {
|
|
type: 'compose',
|
|
composer: composer,
|
|
callback: callback,
|
|
};
|
|
|
|
this.__bridgeSend({
|
|
type: 'resumeCompose',
|
|
handle: handle,
|
|
messageNamer: serializeMessageName(message)
|
|
});
|
|
|
|
return composer;
|
|
},
|
|
|
|
_recv_composeBegun: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle for compose begun:', msg.handle);
|
|
return true;
|
|
}
|
|
|
|
req.composer.senderIdentity = new MailSenderIdentity(this, msg.identity);
|
|
req.composer.subject = msg.subject;
|
|
req.composer.body = msg.body; // rich obj of {text, html}
|
|
req.composer.to = msg.to;
|
|
req.composer.cc = msg.cc;
|
|
req.composer.bcc = msg.bcc;
|
|
req.composer._references = msg.referencesStr;
|
|
req.composer.attachments = msg.attachments;
|
|
req.composer.sendStatus = msg.sendStatus; // For displaying "Send failed".
|
|
|
|
if (req.callback) {
|
|
var callback = req.callback;
|
|
req.callback = null;
|
|
callback.call(null, req.composer);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_composeAttach: function(draftHandle, attachmentDef, callback) {
|
|
if (!draftHandle) {
|
|
return;
|
|
}
|
|
var draftReq = this._pendingRequests[draftHandle];
|
|
if (!draftReq) {
|
|
return;
|
|
}
|
|
var callbackHandle = this._nextHandle++;
|
|
this._pendingRequests[callbackHandle] = {
|
|
type: 'attachBlobToDraft',
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'attachBlobToDraft',
|
|
handle: callbackHandle,
|
|
draftHandle: draftHandle,
|
|
attachmentDef: attachmentDef
|
|
});
|
|
},
|
|
|
|
_recv_attachedBlobToDraft: function(msg) {
|
|
var callbackReq = this._pendingRequests[msg.handle];
|
|
var draftReq = this._pendingRequests[msg.draftHandle];
|
|
if (!callbackReq) {
|
|
return true;
|
|
}
|
|
delete this._pendingRequests[msg.handle];
|
|
|
|
if (callbackReq.callback && draftReq && draftReq.composer) {
|
|
callbackReq.callback(msg.err, draftReq.composer);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_composeDetach: function(draftHandle, attachmentIndex, callback) {
|
|
if (!draftHandle) {
|
|
return;
|
|
}
|
|
var draftReq = this._pendingRequests[draftHandle];
|
|
if (!draftReq) {
|
|
return;
|
|
}
|
|
var callbackHandle = this._nextHandle++;
|
|
this._pendingRequests[callbackHandle] = {
|
|
type: 'detachAttachmentFromDraft',
|
|
callback: callback
|
|
};
|
|
this.__bridgeSend({
|
|
type: 'detachAttachmentFromDraft',
|
|
handle: callbackHandle,
|
|
draftHandle: draftHandle,
|
|
attachmentIndex: attachmentIndex
|
|
});
|
|
},
|
|
|
|
_recv_detachedAttachmentFromDraft: function(msg) {
|
|
var callbackReq = this._pendingRequests[msg.handle];
|
|
var draftReq = this._pendingRequests[msg.draftHandle];
|
|
if (!callbackReq) {
|
|
return true;
|
|
}
|
|
delete this._pendingRequests[msg.handle];
|
|
|
|
if (callbackReq.callback && draftReq && draftReq.composer) {
|
|
callbackReq.callback(msg.err, draftReq.composer);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_composeDone: function(handle, command, state, callback) {
|
|
if (!handle)
|
|
return;
|
|
var req = this._pendingRequests[handle];
|
|
if (!req) {
|
|
return;
|
|
}
|
|
req.type = command;
|
|
if (callback)
|
|
req.callback = callback;
|
|
this.__bridgeSend({
|
|
type: 'doneCompose',
|
|
handle: handle,
|
|
command: command,
|
|
state: state,
|
|
});
|
|
},
|
|
|
|
_recv_doneCompose: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
if (!req) {
|
|
unexpectedBridgeDataError('Bad handle for doneCompose:', msg.handle);
|
|
return true;
|
|
}
|
|
req.active = null;
|
|
// Do not cleanup on saves. Do cleanup on successful send, delete, die.
|
|
if (req.type === 'die' || (!msg.err && (req.type !== 'save')))
|
|
delete this._pendingRequests[msg.handle];
|
|
if (req.callback) {
|
|
req.callback.call(null, {
|
|
sentDate: msg.sentDate,
|
|
messageId: msg.messageId,
|
|
sendStatus: msg.sendStatus
|
|
});
|
|
req.callback = null;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// mode setting for back end universe. Set interactive
|
|
// if the user has been exposed to the UI and it is a
|
|
// longer lived application, not just a cron sync.
|
|
setInteractive: function() {
|
|
this.__bridgeSend({
|
|
type: 'setInteractive'
|
|
});
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// cron syncing
|
|
|
|
/**
|
|
* Receive events about the start and stop of periodic syncing
|
|
*/
|
|
_recv_cronSyncStart: function ma__recv_cronSyncStart(msg) {
|
|
if (this.oncronsyncstart)
|
|
this.oncronsyncstart(msg.accountIds);
|
|
return true;
|
|
},
|
|
|
|
_recv_cronSyncStop: function ma__recv_cronSyncStop(msg) {
|
|
if (this.oncronsyncstop)
|
|
this.oncronsyncstop(msg.accountsResults);
|
|
return true;
|
|
},
|
|
|
|
_recv_backgroundSendStatus: function(msg) {
|
|
if (this.onbackgroundsendstatus) {
|
|
this.onbackgroundsendstatus(msg.data);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Localization
|
|
|
|
/**
|
|
* Provide a list of localized strings for use in message composition. This
|
|
* should be a dictionary with the following values, with their expected
|
|
* default values for English provided. Try to avoid being clever and instead
|
|
* just pick the same strings Thunderbird uses for these for the given locale.
|
|
*
|
|
* - wrote: "{{name}} wrote". Used for the lead-in to the quoted message.
|
|
* - originalMessage: "Original Message". Gets put between a bunch of dashes
|
|
* when forwarding a message inline.
|
|
* - forwardHeaderLabels:
|
|
* - subject
|
|
* - date
|
|
* - from
|
|
* - replyTo (for the "reply-to" header)
|
|
* - to
|
|
* - cc
|
|
*/
|
|
useLocalizedStrings: function(strings) {
|
|
this.__bridgeSend({
|
|
type: 'localizedStrings',
|
|
strings: strings
|
|
});
|
|
if (strings.folderNames)
|
|
this.l10n_folder_names = strings.folderNames;
|
|
},
|
|
|
|
/**
|
|
* L10n strings for folder names. These map folder types to appropriate
|
|
* localized strings.
|
|
*
|
|
* We don't remap unknown types, so this doesn't need defaults.
|
|
*/
|
|
l10n_folder_names: {},
|
|
|
|
l10n_folder_name: function(name, type) {
|
|
if (this.l10n_folder_names.hasOwnProperty(type)) {
|
|
var lowerName = name.toLowerCase();
|
|
// Many of the names are the same as the type, but not all.
|
|
if ((type === lowerName) ||
|
|
(type === 'drafts') ||
|
|
(type === 'junk') ||
|
|
(type === 'queue'))
|
|
return this.l10n_folder_names[type];
|
|
}
|
|
return name;
|
|
},
|
|
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Configuration
|
|
|
|
/**
|
|
* Change one-or-more backend-wide settings; use `MailAccount.modifyAccount`
|
|
* to chang per-account settings.
|
|
*/
|
|
modifyConfig: function(mods) {
|
|
for (var key in mods) {
|
|
if (LEGAL_CONFIG_KEYS.indexOf(key) === -1)
|
|
throw new Error(key + ' is not a legal config key!');
|
|
}
|
|
this.__bridgeSend({
|
|
type: 'modifyConfig',
|
|
mods: mods
|
|
});
|
|
},
|
|
|
|
_recv_config: function(msg) {
|
|
this.config = msg.config;
|
|
return true;
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Diagnostics / Test Hacks
|
|
|
|
/**
|
|
* After a setZeroTimeout, send a 'ping' to the bridge which will send a
|
|
* 'pong' back, notifying the provided callback. This is intended to be hack
|
|
* to provide a way to ensure that some function only runs after all of the
|
|
* notifications have been received and processed by the back-end.
|
|
*
|
|
* Note that ping messages are always processed as they are received; they do
|
|
* not get deferred like other messages.
|
|
*/
|
|
ping: function(callback) {
|
|
var handle = this._nextHandle++;
|
|
this._pendingRequests[handle] = {
|
|
type: 'ping',
|
|
callback: callback,
|
|
};
|
|
|
|
// With the introduction of slice batching, we now wait to send the ping.
|
|
// This is reasonable because there are conceivable situations where the
|
|
// caller really wants to wait until all related callbacks fire before
|
|
// dispatching. And the ping method is already a hack to ensure correctness
|
|
// ordering that should be done using better/more specific methods, so this
|
|
// change is not any less of a hack/evil, although it does cause misuse to
|
|
// potentially be more capable of causing intermittent failures.
|
|
window.setZeroTimeout(function() {
|
|
this.__bridgeSend({
|
|
type: 'ping',
|
|
handle: handle,
|
|
});
|
|
}.bind(this));
|
|
},
|
|
|
|
_recv_pong: function(msg) {
|
|
var req = this._pendingRequests[msg.handle];
|
|
delete this._pendingRequests[msg.handle];
|
|
req.callback();
|
|
return true;
|
|
},
|
|
|
|
debugSupport: function(command, argument) {
|
|
if (command === 'setLogging')
|
|
this.config.debugLogging = argument;
|
|
this.__bridgeSend({
|
|
type: 'debugSupport',
|
|
cmd: command,
|
|
arg: argument
|
|
});
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
};
|
|
|
|
}); // end define
|