summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/addrbook/modules
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/addrbook/modules')
-rw-r--r--comm/mailnews/addrbook/modules/AddrBookCard.jsm481
-rw-r--r--comm/mailnews/addrbook/modules/AddrBookDirectory.jsm817
-rw-r--r--comm/mailnews/addrbook/modules/AddrBookMailingList.jsm420
-rw-r--r--comm/mailnews/addrbook/modules/AddrBookManager.jsm608
-rw-r--r--comm/mailnews/addrbook/modules/AddrBookUtils.jsm522
-rw-r--r--comm/mailnews/addrbook/modules/CardDAVDirectory.jsm925
-rw-r--r--comm/mailnews/addrbook/modules/CardDAVUtils.jsm718
-rw-r--r--comm/mailnews/addrbook/modules/LDAPClient.jsm285
-rw-r--r--comm/mailnews/addrbook/modules/LDAPConnection.jsm53
-rw-r--r--comm/mailnews/addrbook/modules/LDAPDirectory.jsm230
-rw-r--r--comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm218
-rw-r--r--comm/mailnews/addrbook/modules/LDAPListenerBase.jsm117
-rw-r--r--comm/mailnews/addrbook/modules/LDAPMessage.jsm632
-rw-r--r--comm/mailnews/addrbook/modules/LDAPOperation.jsm198
-rw-r--r--comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm41
-rw-r--r--comm/mailnews/addrbook/modules/LDAPReplicationService.jsm233
-rw-r--r--comm/mailnews/addrbook/modules/LDAPService.jsm66
-rw-r--r--comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm112
-rw-r--r--comm/mailnews/addrbook/modules/LDAPURLParser.jsm42
-rw-r--r--comm/mailnews/addrbook/modules/QueryStringToExpression.jsm186
-rw-r--r--comm/mailnews/addrbook/modules/SQLiteDirectory.jsm474
-rw-r--r--comm/mailnews/addrbook/modules/VCardUtils.jsm973
-rw-r--r--comm/mailnews/addrbook/modules/components.conf136
-rw-r--r--comm/mailnews/addrbook/modules/moz.build34
24 files changed, 8521 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/modules/AddrBookCard.jsm b/comm/mailnews/addrbook/modules/AddrBookCard.jsm
new file mode 100644
index 0000000000..23f387f921
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/AddrBookCard.jsm
@@ -0,0 +1,481 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["AddrBookCard"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm",
+ newUID: "resource:///modules/AddrBookUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardPropertyEntry: "resource:///modules/VCardUtils.jsm",
+});
+
+/**
+ * Prototype for nsIAbCard objects that are not mailing lists.
+ *
+ * @implements {nsIAbCard}
+ */
+function AddrBookCard() {
+ this._directoryUID = "";
+ this._properties = new Map([
+ ["PopularityIndex", 0],
+ ["LastModifiedDate", 0],
+ ]);
+
+ this._hasVCard = false;
+ XPCOMUtils.defineLazyGetter(this, "_vCardProperties", () => {
+ // Lazy creation of the VCardProperties object. Change the `_properties`
+ // object as much as you like (e.g. loading in properties from a database)
+ // before running this code. After it runs, the `_vCardProperties` object
+ // takes over and anything in `_properties` which could be stored in the
+ // vCard will be ignored!
+
+ this._hasVCard = true;
+
+ let vCard = this.getProperty("_vCard", "");
+ try {
+ if (vCard) {
+ let vCardProperties = lazy.VCardProperties.fromVCard(vCard, {
+ isGoogleCardDAV: this._isGoogleCardDAV,
+ });
+ // Custom1..4 properties could still exist as nsIAbCard properties.
+ // Migrate them now.
+ for (let key of ["Custom1", "Custom2", "Custom3", "Custom4"]) {
+ let value = this.getProperty(key, "");
+ if (
+ value &&
+ vCardProperties.getFirstEntry(`x-${key.toLowerCase()}`) === null
+ ) {
+ vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry(
+ `x-${key.toLowerCase()}`,
+ {},
+ "text",
+ value
+ )
+ );
+ }
+ this.deleteProperty(key);
+ }
+ return vCardProperties;
+ }
+ return lazy.VCardProperties.fromPropertyMap(this._properties);
+ } catch (error) {
+ console.error("Error creating vCard properties", error);
+ // Return an empty VCardProperties object if parsing failed
+ // catastrophically.
+ return new lazy.VCardProperties("4.0");
+ }
+ });
+}
+
+AddrBookCard.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAbCard"]),
+ classID: Components.ID("{1143991d-31cd-4ea6-9c97-c587d990d724}"),
+
+ /* nsIAbCard */
+
+ generateName(generateFormat, bundle) {
+ let result = "";
+ switch (generateFormat) {
+ case Ci.nsIAbCard.GENERATE_DISPLAY_NAME:
+ result = this.displayName;
+ break;
+
+ case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER:
+ if (this.lastName) {
+ let otherNames = [
+ this.prefixName,
+ this.firstName,
+ this.middleName,
+ this.suffixName,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ if (!otherNames) {
+ // Only use the lastName if we don't have anything to add after the
+ // comma, in order to avoid for the string to finish with ", ".
+ result = this.lastName;
+ } else {
+ result =
+ bundle?.formatStringFromName("lastFirstFormat", [
+ this.lastName,
+ otherNames,
+ ]) ?? `${this.lastName}, ${otherNames}`;
+ }
+ }
+ break;
+
+ default:
+ let startNames = [this.prefixName, this.firstName, this.middleName]
+ .filter(Boolean)
+ .join(" ");
+ let endNames = [this.lastName, this.suffixName]
+ .filter(Boolean)
+ .join(" ");
+ result =
+ bundle?.formatStringFromName("firstLastFormat", [
+ startNames,
+ endNames,
+ ]) ?? `${startNames} ${endNames}`;
+ break;
+ }
+
+ // Remove any leftover blank spaces.
+ result = result.trim();
+
+ if (result == "" || result == ",") {
+ result =
+ this.displayName ||
+ [
+ this.prefixName,
+ this.firstName,
+ this.middleName,
+ this.lastName,
+ this.suffixName,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .trim();
+
+ if (!result) {
+ // So far we don't have anything to show as a contact name.
+
+ if (this.primaryEmail) {
+ // Let's use the primary email localpart.
+ result = this.primaryEmail.split("@", 1)[0];
+ } else {
+ // We don't have a primary email either, let's try with the
+ // organization name.
+ result = !this._hasVCard
+ ? this.getProperty("Company", "")
+ : this._vCardProperties.getFirstValue("org");
+ }
+ }
+ }
+ return result || "";
+ },
+ get directoryUID() {
+ return this._directoryUID;
+ },
+ set directoryUID(value) {
+ this._directoryUID = value;
+ },
+ get UID() {
+ if (!this._uid) {
+ this._uid = lazy.newUID();
+ }
+ return this._uid;
+ },
+ set UID(value) {
+ if (this._uid && value != this._uid) {
+ throw Components.Exception(
+ `Bad UID: got ${value} != ${this.uid}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ this._uid = value;
+ },
+ get properties() {
+ let props = [];
+ for (const [name, value] of this._properties) {
+ props.push({
+ get name() {
+ return name;
+ },
+ get value() {
+ return value;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIProperty"]),
+ });
+ }
+ return props;
+ },
+ get supportsVCard() {
+ return true;
+ },
+ get vCardProperties() {
+ return this._vCardProperties;
+ },
+ get firstName() {
+ if (!this._hasVCard) {
+ return this.getProperty("FirstName", "");
+ }
+ let name = this._vCardProperties.getFirstValue("n");
+ if (!Array.isArray(name)) {
+ return "";
+ }
+ name = name[1];
+ if (Array.isArray(name)) {
+ name = name.join(" ");
+ }
+ return name;
+ },
+ set firstName(value) {
+ let n = this._vCardProperties.getFirstEntry("n");
+ if (n) {
+ n.value[1] = value;
+ } else {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry("n", {}, "text", ["", value, "", "", ""])
+ );
+ }
+ },
+ get lastName() {
+ if (!this._hasVCard) {
+ return this.getProperty("LastName", "");
+ }
+ let name = this._vCardProperties.getFirstValue("n");
+ if (!Array.isArray(name)) {
+ return "";
+ }
+ name = name[0];
+ if (Array.isArray(name)) {
+ name = name.join(" ");
+ }
+ return name;
+ },
+ set lastName(value) {
+ let n = this._vCardProperties.getFirstEntry("n");
+ if (n) {
+ n.value[0] = value;
+ } else {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry("n", {}, "text", [value, "", "", "", ""])
+ );
+ }
+ },
+ get displayName() {
+ if (!this._hasVCard) {
+ return this.getProperty("DisplayName", "");
+ }
+ return this._vCardProperties.getFirstValue("fn") || "";
+ },
+ set displayName(value) {
+ let fn = this._vCardProperties.getFirstEntry("fn");
+ if (fn) {
+ fn.value = value;
+ } else {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry("fn", {}, "text", value)
+ );
+ }
+ },
+ get primaryEmail() {
+ if (!this._hasVCard) {
+ return this.getProperty("PrimaryEmail", "");
+ }
+ return this._vCardProperties.getAllValuesSorted("email")[0] ?? "";
+ },
+ set primaryEmail(value) {
+ let entries = this._vCardProperties.getAllEntriesSorted("email");
+ if (entries.length && entries[0].value != value) {
+ this._vCardProperties.removeEntry(entries[0]);
+ entries.shift();
+ }
+
+ if (value) {
+ let existing = entries.find(e => e.value == value);
+ if (existing) {
+ existing.params.pref = "1";
+ } else {
+ this._vCardProperties.addEntry(
+ new lazy.VCardPropertyEntry("email", { pref: "1" }, "text", value)
+ );
+ }
+ } else if (entries.length) {
+ entries[0].params.pref = "1";
+ }
+ },
+ get isMailList() {
+ return false;
+ },
+ get mailListURI() {
+ return "";
+ },
+ get emailAddresses() {
+ return this._vCardProperties.getAllValuesSorted("email");
+ },
+ get photoURL() {
+ let photoEntry = this.vCardProperties.getFirstEntry("photo");
+ if (photoEntry?.value) {
+ if (photoEntry.value?.startsWith("data:image/")) {
+ // This is a version 4.0 card
+ // OR a version 3.0 card with the URI type set (uncommon)
+ // OR a version 3.0 card that is lying about its type.
+ return photoEntry.value;
+ }
+ if (photoEntry.type == "binary" && photoEntry.value.startsWith("iVBO")) {
+ // This is a version 3.0 card.
+ // The first 3 bytes say this image is PNG.
+ return `data:image/png;base64,${photoEntry.value}`;
+ }
+ if (photoEntry.type == "binary" && photoEntry.value.startsWith("/9j/")) {
+ // This is a version 3.0 card.
+ // The first 3 bytes say this image is JPEG.
+ return `data:image/jpeg;base64,${photoEntry.value}`;
+ }
+ if (photoEntry.type == "uri" && /^https?:\/\//.test(photoEntry.value)) {
+ // A remote URI.
+ return photoEntry.value;
+ }
+ }
+
+ let photoName = this.getProperty("PhotoName", "");
+ if (photoName) {
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("Photos");
+ file.append(photoName);
+ return Services.io.newFileURI(file).spec;
+ }
+
+ return "";
+ },
+
+ getProperty(name, defaultValue) {
+ if (this._properties.has(name)) {
+ return this._properties.get(name);
+ }
+ return defaultValue;
+ },
+ getPropertyAsAString(name) {
+ if (!this._properties.has(name)) {
+ return "";
+ }
+ return this.getProperty(name);
+ },
+ getPropertyAsAUTF8String(name) {
+ if (!this._properties.has(name)) {
+ throw Components.Exception(`${name} N/A`, Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ return this.getProperty(name);
+ },
+ getPropertyAsUint32(name) {
+ let value = this.getProperty(name);
+ if (!isNaN(parseInt(value, 10))) {
+ return parseInt(value, 10);
+ }
+ if (!isNaN(parseInt(value, 16))) {
+ return parseInt(value, 16);
+ }
+ throw Components.Exception(
+ `${name}: ${value} - not an int`,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ },
+ getPropertyAsBool(name, defaultValue) {
+ let value = this.getProperty(name);
+ switch (value) {
+ case false:
+ case 0:
+ case "0":
+ return false;
+ case true:
+ case 1:
+ case "1":
+ return true;
+ case undefined:
+ return defaultValue;
+ }
+ throw Components.Exception(
+ `${name}: ${value} - not a boolean`,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ },
+ setProperty(name, value) {
+ if (lazy.BANISHED_PROPERTIES.includes(name)) {
+ throw new Components.Exception(
+ `Unable to set ${name} as a property, use vCardProperties`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ if ([null, undefined, ""].includes(value)) {
+ this._properties.delete(name);
+ return;
+ }
+ if (typeof value == "boolean") {
+ value = value ? "1" : "0";
+ }
+ this._properties.set(name, "" + value);
+ },
+ setPropertyAsAString(name, value) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ setPropertyAsAUTF8String(name, value) {
+ this.setProperty(name, value);
+ },
+ setPropertyAsUint32(name, value) {
+ this.setProperty(name, value);
+ },
+ setPropertyAsBool(name, value) {
+ this.setProperty(name, value ? "1" : "0");
+ },
+ deleteProperty(name) {
+ this._properties.delete(name);
+ },
+ hasEmailAddress(emailAddress) {
+ emailAddress = emailAddress.toLowerCase();
+ return this.emailAddresses.some(e => e.toLowerCase() == emailAddress);
+ },
+ translateTo(type) {
+ if (type == "vcard") {
+ if (!this._vCardProperties.getFirstValue("uid")) {
+ this._vCardProperties.addValue("uid", this.UID);
+ }
+ return encodeURIComponent(this._vCardProperties.toVCard());
+ }
+ // Get nsAbCardProperty to do the work, the code is in C++ anyway.
+ let cardCopy = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ cardCopy.UID = this.UID;
+ cardCopy.copy(this);
+ return cardCopy.translateTo(type);
+ },
+ generatePhoneticName(lastNameFirst) {
+ if (lastNameFirst) {
+ return (
+ this.getProperty("PhoneticLastName", "") +
+ this.getProperty("PhoneticFirstName", "")
+ );
+ }
+ return (
+ this.getProperty("PhoneticFirstName", "") +
+ this.getProperty("PhoneticLastName", "")
+ );
+ },
+ generateChatName() {
+ for (let name of [
+ "_GoogleTalk",
+ "_AimScreenName",
+ "_Yahoo",
+ "_Skype",
+ "_QQ",
+ "_MSN",
+ "_ICQ",
+ "_JabberId",
+ "_IRC",
+ ]) {
+ if (this._properties.has(name)) {
+ return this._properties.get(name);
+ }
+ }
+ return "";
+ },
+ copy(srcCard) {
+ throw Components.Exception(
+ "nsIAbCard.copy() not implemented",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+ equals(card) {
+ return this.UID == card.UID;
+ },
+};
diff --git a/comm/mailnews/addrbook/modules/AddrBookDirectory.jsm b/comm/mailnews/addrbook/modules/AddrBookDirectory.jsm
new file mode 100644
index 0000000000..b35f35b147
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/AddrBookDirectory.jsm
@@ -0,0 +1,817 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["AddrBookDirectory"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+ AddrBookMailingList: "resource:///modules/AddrBookMailingList.jsm",
+ BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm",
+ compareAddressBooks: "resource:///modules/AddrBookUtils.jsm",
+ newUID: "resource:///modules/AddrBookUtils.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+});
+
+/**
+ * Abstract base class implementing nsIAbDirectory.
+ *
+ * @abstract
+ * @implements {nsIAbDirectory}
+ */
+class AddrBookDirectory {
+ QueryInterface = ChromeUtils.generateQI(["nsIAbDirectory"]);
+
+ constructor() {
+ this._uid = null;
+ this._dirName = null;
+ }
+
+ _initialized = false;
+ init(uri) {
+ if (this._initialized) {
+ throw new Components.Exception(
+ `Directory already initialized: ${uri}`,
+ Cr.NS_ERROR_ALREADY_INITIALIZED
+ );
+ }
+
+ // If this._readOnly is true, the user is prevented from making changes to
+ // the contacts. Subclasses may override this (for example to sync with a
+ // server) by setting this._overrideReadOnly to true, but must clear it
+ // before yielding to another thread (e.g. awaiting a Promise).
+
+ if (this._dirPrefId) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_readOnly",
+ `${this.dirPrefId}.readOnly`,
+ false
+ );
+ }
+
+ this._initialized = true;
+ }
+ async cleanUp() {
+ if (!this._initialized) {
+ throw new Components.Exception(
+ "Directory not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+ }
+
+ get _prefBranch() {
+ if (this.__prefBranch) {
+ return this.__prefBranch;
+ }
+ if (!this._dirPrefId) {
+ throw Components.Exception("No dirPrefId!", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ return (this.__prefBranch = Services.prefs.getBranch(
+ `${this._dirPrefId}.`
+ ));
+ }
+ /** @abstract */
+ get lists() {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement lists getter.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /** @abstract */
+ get cards() {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement cards getter.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+
+ getCard(uid) {
+ let card = new lazy.AddrBookCard();
+ card.directoryUID = this.UID;
+ card._uid = uid;
+ card._properties = this.loadCardProperties(uid);
+ card._isGoogleCardDAV = this._isGoogleCardDAV;
+ return card.QueryInterface(Ci.nsIAbCard);
+ }
+ /** @abstract */
+ loadCardProperties(uid) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement loadCardProperties.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /** @abstract */
+ saveCardProperties(uid, properties) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement saveCardProperties.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /** @abstract */
+ deleteCard(uid) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement deleteCard.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /** @abstract */
+ saveList(list) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement saveList.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /** @abstract */
+ deleteList(uid) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement deleteList.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /**
+ * Create a Map of the properties to record when saving `card`, including
+ * any changes we want to make just before saving.
+ *
+ * @param {nsIAbCard} card
+ * @param {?string} uid
+ * @returns {Map<string, string>}
+ */
+ prepareToSaveCard(card, uid) {
+ let propertyMap = new Map(
+ Array.from(card.properties, p => [p.name, p.value])
+ );
+ let newProperties = new Map();
+
+ // Get a VCardProperties object for the card.
+ let vCardProperties;
+ if (card.supportsVCard) {
+ vCardProperties = card.vCardProperties;
+ } else {
+ vCardProperties = lazy.VCardProperties.fromPropertyMap(propertyMap);
+ }
+
+ if (uid) {
+ // Force the UID to be as passed.
+ vCardProperties.clearValues("uid");
+ vCardProperties.addValue("uid", uid);
+ } else if (vCardProperties.getFirstValue("uid") != card.UID) {
+ vCardProperties.clearValues("uid");
+ vCardProperties.addValue("uid", card.UID);
+ }
+
+ // Collect only the properties we intend to keep.
+ for (let [name, value] of propertyMap) {
+ if (lazy.BANISHED_PROPERTIES.includes(name)) {
+ continue;
+ }
+ if (value !== null && value !== undefined && value !== "") {
+ newProperties.set(name, value);
+ }
+ }
+
+ // Add the vCard and the properties from it we want to cache.
+ newProperties.set("_vCard", vCardProperties.toVCard());
+
+ let displayName = vCardProperties.getFirstValue("fn");
+ newProperties.set("DisplayName", displayName || "");
+
+ let flatten = value => {
+ if (Array.isArray(value)) {
+ return value.join(" ");
+ }
+ return value;
+ };
+
+ let name = vCardProperties.getFirstValue("n");
+ if (Array.isArray(name)) {
+ newProperties.set("FirstName", flatten(name[1]));
+ newProperties.set("LastName", flatten(name[0]));
+ }
+
+ let email = vCardProperties.getAllValuesSorted("email");
+ if (email[0]) {
+ newProperties.set("PrimaryEmail", email[0]);
+ }
+ if (email[1]) {
+ newProperties.set("SecondEmail", email[1]);
+ }
+
+ let nickname = vCardProperties.getFirstValue("nickname");
+ if (nickname) {
+ newProperties.set("NickName", flatten(nickname));
+ }
+
+ // Always set the last modified date.
+ newProperties.set("LastModifiedDate", "" + Math.floor(Date.now() / 1000));
+ return newProperties;
+ }
+
+ /* nsIAbDirectory */
+
+ get readOnly() {
+ return this._readOnly;
+ }
+ get isRemote() {
+ return false;
+ }
+ get isSecure() {
+ return false;
+ }
+ get propertiesChromeURI() {
+ return "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml";
+ }
+ get dirPrefId() {
+ return this._dirPrefId;
+ }
+ get dirName() {
+ if (this._dirName === null) {
+ this._dirName = this.getLocalizedStringValue("description", "");
+ }
+ return this._dirName;
+ }
+ set dirName(value) {
+ this.setLocalizedStringValue("description", value);
+ this._dirName = value;
+ Services.obs.notifyObservers(this, "addrbook-directory-updated", "DirName");
+ }
+ get dirType() {
+ return Ci.nsIAbManager.JS_DIRECTORY_TYPE;
+ }
+ get fileName() {
+ return this._fileName;
+ }
+ get UID() {
+ if (!this._uid) {
+ if (this._prefBranch.getPrefType("uid") == Services.prefs.PREF_STRING) {
+ this._uid = this._prefBranch.getStringPref("uid");
+ } else {
+ this._uid = lazy.newUID();
+ this._prefBranch.setStringPref("uid", this._uid);
+ }
+ }
+ return this._uid;
+ }
+ get URI() {
+ return this._uri;
+ }
+ get position() {
+ return this._prefBranch.getIntPref("position", 1);
+ }
+ get childNodes() {
+ let lists = Array.from(
+ this.lists.values(),
+ list =>
+ new lazy.AddrBookMailingList(
+ list.uid,
+ this,
+ list.name,
+ list.nickName,
+ list.description
+ ).asDirectory
+ );
+ lists.sort(lazy.compareAddressBooks);
+ return lists;
+ }
+ /** @abstract */
+ get childCardCount() {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement childCardCount getter.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ get childCards() {
+ let results = Array.from(
+ this.lists.values(),
+ list =>
+ new lazy.AddrBookMailingList(
+ list.uid,
+ this,
+ list.name,
+ list.nickName,
+ list.description
+ ).asCard
+ ).concat(Array.from(this.cards.keys(), this.getCard, this));
+
+ return results;
+ }
+ get supportsMailingLists() {
+ return true;
+ }
+
+ search(query, string, listener) {
+ if (!listener) {
+ return;
+ }
+ if (!query) {
+ listener.onSearchFinished(Cr.NS_ERROR_FAILURE, true, null, "");
+ return;
+ }
+ if (query[0] == "?") {
+ query = query.substring(1);
+ }
+
+ let results = Array.from(
+ this.lists.values(),
+ list =>
+ new lazy.AddrBookMailingList(
+ list.uid,
+ this,
+ list.name,
+ list.nickName,
+ list.description
+ ).asCard
+ ).concat(Array.from(this.cards.keys(), this.getCard, this));
+
+ // Process the query string into a tree of conditions to match.
+ let lispRegexp = /^\((and|or|not|([^\)]*)(\)+))/;
+ let index = 0;
+ let rootQuery = { children: [], op: "or" };
+ let currentQuery = rootQuery;
+
+ while (true) {
+ let match = lispRegexp.exec(query.substring(index));
+ if (!match) {
+ break;
+ }
+ index += match[0].length;
+
+ if (["and", "or", "not"].includes(match[1])) {
+ // For the opening bracket, step down a level.
+ let child = {
+ parent: currentQuery,
+ children: [],
+ op: match[1],
+ };
+ currentQuery.children.push(child);
+ currentQuery = child;
+ } else {
+ let [name, condition, value] = match[2].split(",");
+ currentQuery.children.push({
+ name,
+ condition,
+ value: decodeURIComponent(value).toLowerCase(),
+ });
+
+ // For each closing bracket except the first, step up a level.
+ for (let i = match[3].length - 1; i > 0; i--) {
+ currentQuery = currentQuery.parent;
+ }
+ }
+ }
+
+ results = results.filter(card => {
+ let properties;
+ if (card.isMailList) {
+ properties = new Map([
+ ["DisplayName", card.displayName],
+ ["NickName", card.getProperty("NickName", "")],
+ ["Notes", card.getProperty("Notes", "")],
+ ]);
+ } else if (card._properties.has("_vCard")) {
+ try {
+ properties = card.vCardProperties.toPropertyMap();
+ } catch (ex) {
+ // Parsing failed. Skip the vCard and just use the other properties.
+ console.error(ex);
+ properties = new Map();
+ }
+ for (let [key, value] of card._properties) {
+ if (!properties.has(key)) {
+ properties.set(key, value);
+ }
+ }
+ } else {
+ properties = card._properties;
+ }
+ let matches = b => {
+ if ("condition" in b) {
+ let { name, condition, value } = b;
+ if (name == "IsMailList" && condition == "=") {
+ return card.isMailList == (value == "true");
+ }
+ let cardValue = properties.get(name);
+ if (!cardValue) {
+ return condition == "!ex";
+ }
+ if (condition == "ex") {
+ return true;
+ }
+
+ cardValue = cardValue.toLowerCase();
+ switch (condition) {
+ case "=":
+ return cardValue == value;
+ case "!=":
+ return cardValue != value;
+ case "lt":
+ return cardValue < value;
+ case "gt":
+ return cardValue > value;
+ case "bw":
+ return cardValue.startsWith(value);
+ case "ew":
+ return cardValue.endsWith(value);
+ case "c":
+ return cardValue.includes(value);
+ case "!c":
+ return !cardValue.includes(value);
+ case "~=":
+ case "regex":
+ default:
+ return false;
+ }
+ }
+ if (b.op == "or") {
+ return b.children.some(bb => matches(bb));
+ }
+ if (b.op == "and") {
+ return b.children.every(bb => matches(bb));
+ }
+ if (b.op == "not") {
+ return !matches(b.children[0]);
+ }
+ return false;
+ };
+
+ return matches(rootQuery);
+ }, this);
+
+ for (let card of results) {
+ listener.onSearchFoundCard(card);
+ }
+ listener.onSearchFinished(Cr.NS_OK, true, null, "");
+ }
+ generateName(generateFormat, bundle) {
+ return this.dirName;
+ }
+ cardForEmailAddress(emailAddress) {
+ if (!emailAddress) {
+ return null;
+ }
+
+ // Check the properties. We copy the first two addresses to properties for
+ // this purpose, so it should be fast.
+ let card = this.getCardFromProperty("PrimaryEmail", emailAddress, false);
+ if (card) {
+ return card;
+ }
+ card = this.getCardFromProperty("SecondEmail", emailAddress, false);
+ if (card) {
+ return card;
+ }
+
+ // Nothing so far? Go through all the cards checking all of the addresses.
+ // This could be slow.
+ emailAddress = emailAddress.toLowerCase();
+ for (let [uid, properties] of this.cards) {
+ let vCard = properties.get("_vCard");
+ // If the vCard string doesn't include the email address, the parsed
+ // vCard won't include it either, so don't waste time parsing it.
+ if (!vCard?.toLowerCase().includes(emailAddress)) {
+ continue;
+ }
+ card = this.getCard(uid);
+ if (card.emailAddresses.some(e => e.toLowerCase() == emailAddress)) {
+ return card;
+ }
+ }
+
+ return null;
+ }
+ /** @abstract */
+ getCardFromProperty(property, value, caseSensitive) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement getCardFromProperty.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ /** @abstract */
+ getCardsFromProperty(property, value, caseSensitive) {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement getCardsFromProperty.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ getMailListFromName(name) {
+ for (let list of this.lists.values()) {
+ if (list.name.toLowerCase() == name.toLowerCase()) {
+ return new lazy.AddrBookMailingList(
+ list.uid,
+ this,
+ list.name,
+ list.nickName,
+ list.description
+ ).asDirectory;
+ }
+ }
+ return null;
+ }
+ deleteDirectory(directory) {
+ if (this._readOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ let list = this.lists.get(directory.UID);
+ list = new lazy.AddrBookMailingList(
+ list.uid,
+ this,
+ list.name,
+ list.nickName,
+ list.description
+ );
+
+ this.deleteList(directory.UID);
+
+ Services.obs.notifyObservers(
+ list.asDirectory,
+ "addrbook-list-deleted",
+ this.UID
+ );
+ }
+ hasCard(card) {
+ return this.lists.has(card.UID) || this.cards.has(card.UID);
+ }
+ hasDirectory(dir) {
+ return this.lists.has(dir.UID);
+ }
+ hasMailListWithName(name) {
+ return this.getMailListFromName(name) != null;
+ }
+ addCard(card) {
+ return this.dropCard(card, false);
+ }
+ modifyCard(card) {
+ if (this._readOnly && !this._overrideReadOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ let oldProperties = this.loadCardProperties(card.UID);
+ let newProperties = this.prepareToSaveCard(card);
+
+ let allProperties = new Set(oldProperties.keys());
+ for (let key of newProperties.keys()) {
+ allProperties.add(key);
+ }
+
+ if (this.hasOwnProperty("cards")) {
+ this.cards.set(card.UID, newProperties);
+ }
+ this.saveCardProperties(card.UID, newProperties);
+
+ let changeData = {};
+ for (let name of allProperties) {
+ if (name == "LastModifiedDate") {
+ continue;
+ }
+
+ let oldValue = oldProperties.get(name) || null;
+ let newValue = newProperties.get(name) || null;
+ if (oldValue != newValue) {
+ changeData[name] = { oldValue, newValue };
+ }
+ }
+
+ // Increment this preference if one or both of these properties change.
+ // This will cause the UI to throw away cached values.
+ if ("DisplayName" in changeData || "PreferDisplayName" in changeData) {
+ Services.prefs.setIntPref(
+ "mail.displayname.version",
+ Services.prefs.getIntPref("mail.displayname.version", 0) + 1
+ );
+ }
+
+ // Send the card as it is in this directory, not as passed to this function.
+ let newCard = this.getCard(card.UID);
+ Services.obs.notifyObservers(newCard, "addrbook-contact-updated", this.UID);
+
+ Services.obs.notifyObservers(
+ newCard,
+ "addrbook-contact-properties-updated",
+ JSON.stringify(changeData)
+ );
+
+ // Return the card, even though the interface says not to, because
+ // subclasses may want it.
+ return newCard;
+ }
+ deleteCards(cards) {
+ if (this._readOnly && !this._overrideReadOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (cards === null) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_POINTER);
+ }
+
+ let updateDisplayNameVersion = false;
+ for (let card of cards) {
+ updateDisplayNameVersion = updateDisplayNameVersion || card.displayName;
+ // TODO: delete photo if there is one
+ this.deleteCard(card.UID);
+ if (this.hasOwnProperty("cards")) {
+ this.cards.delete(card.UID);
+ }
+ }
+
+ // Increment this preference if one or more cards has a display name.
+ // This will cause the UI to throw away cached values.
+ if (updateDisplayNameVersion) {
+ Services.prefs.setIntPref(
+ "mail.displayname.version",
+ Services.prefs.getIntPref("mail.displayname.version", 0) + 1
+ );
+ }
+
+ for (let card of cards) {
+ Services.obs.notifyObservers(card, "addrbook-contact-deleted", this.UID);
+ card.directoryUID = null;
+ }
+
+ // We could just delete all non-existent cards from list_cards, but a
+ // notification should be fired for each one. Let the list handle that.
+ for (let list of this.childNodes) {
+ list.deleteCards(cards);
+ }
+ }
+ dropCard(card, needToCopyCard) {
+ if (this._readOnly && !this._overrideReadOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (!card.UID) {
+ throw new Error("Card must have a UID to be added to this directory.");
+ }
+
+ let uid = needToCopyCard ? lazy.newUID() : card.UID;
+ let newProperties = this.prepareToSaveCard(card, uid);
+ if (card.directoryUID && card.directoryUID != this._uid) {
+ // These properties belong to a different directory. Don't keep them.
+ newProperties.delete("_etag");
+ newProperties.delete("_href");
+ }
+
+ if (this.hasOwnProperty("cards")) {
+ this.cards.set(uid, newProperties);
+ }
+ this.saveCardProperties(uid, newProperties);
+
+ // Increment this preference if the card has a display name.
+ // This will cause the UI to throw away cached values.
+ if (card.displayName) {
+ Services.prefs.setIntPref(
+ "mail.displayname.version",
+ Services.prefs.getIntPref("mail.displayname.version", 0) + 1
+ );
+ }
+
+ let newCard = this.getCard(uid);
+ Services.obs.notifyObservers(newCard, "addrbook-contact-created", this.UID);
+ return newCard;
+ }
+ useForAutocomplete(identityKey) {
+ return (
+ Services.prefs.getBoolPref("mail.enable_autocomplete") &&
+ this.getBoolValue("enable_autocomplete", true)
+ );
+ }
+ addMailList(list) {
+ if (this._readOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (!list.isMailList) {
+ throw Components.Exception(
+ "Can't add; not a mail list",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ // Check if the new name is empty.
+ if (!list.dirName) {
+ throw new Components.Exception(
+ `Mail list name must be set; list.dirName=${list.dirName}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ // Check if the new name contains 2 spaces.
+ if (list.dirName.match(" ")) {
+ throw new Components.Exception(
+ `Invalid mail list name: ${list.dirName}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ // Check if the new name contains the following special characters.
+ for (let char of ',;"<>') {
+ if (list.dirName.includes(char)) {
+ throw new Components.Exception(
+ `Invalid mail list name: ${list.dirName}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+
+ let newList = new lazy.AddrBookMailingList(
+ lazy.newUID(),
+ this,
+ list.dirName || "",
+ list.listNickName || "",
+ list.description || ""
+ );
+ this.saveList(newList);
+
+ let newListDirectory = newList.asDirectory;
+ Services.obs.notifyObservers(
+ newListDirectory,
+ "addrbook-list-created",
+ this.UID
+ );
+ return newListDirectory;
+ }
+ editMailListToDatabase(listCard) {
+ // Deliberately not implemented, this isn't a mailing list.
+ throw Components.Exception(
+ "editMailListToDatabase not relevant here",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ copyMailList(srcList) {
+ // Deliberately not implemented, this isn't a mailing list.
+ throw Components.Exception(
+ "copyMailList not relevant here",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ getIntValue(name, defaultValue) {
+ return this._prefBranch
+ ? this._prefBranch.getIntPref(name, defaultValue)
+ : defaultValue;
+ }
+ getBoolValue(name, defaultValue) {
+ return this._prefBranch
+ ? this._prefBranch.getBoolPref(name, defaultValue)
+ : defaultValue;
+ }
+ getStringValue(name, defaultValue) {
+ return this._prefBranch
+ ? this._prefBranch.getStringPref(name, defaultValue)
+ : defaultValue;
+ }
+ getLocalizedStringValue(name, defaultValue) {
+ if (!this._prefBranch) {
+ return defaultValue;
+ }
+ if (this._prefBranch.getPrefType(name) == Ci.nsIPrefBranch.PREF_INVALID) {
+ return defaultValue;
+ }
+ try {
+ return this._prefBranch.getComplexValue(name, Ci.nsIPrefLocalizedString)
+ .data;
+ } catch (e) {
+ // getComplexValue doesn't work with autoconfig.
+ return this._prefBranch.getStringPref(name);
+ }
+ }
+ setIntValue(name, value) {
+ this._prefBranch.setIntPref(name, value);
+ }
+ setBoolValue(name, value) {
+ this._prefBranch.setBoolPref(name, value);
+ }
+ setStringValue(name, value) {
+ this._prefBranch.setStringPref(name, value);
+ }
+ setLocalizedStringValue(name, value) {
+ let valueLocal = Cc["@mozilla.org/pref-localizedstring;1"].createInstance(
+ Ci.nsIPrefLocalizedString
+ );
+ valueLocal.data = value;
+ this._prefBranch.setComplexValue(
+ name,
+ Ci.nsIPrefLocalizedString,
+ valueLocal
+ );
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/AddrBookMailingList.jsm b/comm/mailnews/addrbook/modules/AddrBookMailingList.jsm
new file mode 100644
index 0000000000..31d16e93aa
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/AddrBookMailingList.jsm
@@ -0,0 +1,420 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["AddrBookMailingList"];
+
+/* Prototype for mailing lists. A mailing list can appear as nsIAbDirectory
+ * or as nsIAbCard. Here we keep all relevant information in the class itself
+ * and fulfill each interface on demand. This will make more sense and be
+ * a lot neater once we stop using two XPCOM interfaces for one job. */
+
+function AddrBookMailingList(uid, parent, name, nickName, description) {
+ this._uid = uid;
+ this._parent = parent;
+ this._name = name;
+ this._nickName = nickName;
+ this._description = description;
+}
+AddrBookMailingList.prototype = {
+ get asDirectory() {
+ let self = this;
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIAbDirectory"]),
+ classID: Components.ID("{e96ee804-0bd3-472f-81a6-8a9d65277ad3}"),
+
+ get readOnly() {
+ return self._parent._readOnly;
+ },
+ get isRemote() {
+ return self._parent.isRemote;
+ },
+ get isSecure() {
+ return self._parent.isSecure;
+ },
+ get propertiesChromeURI() {
+ return "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml";
+ },
+ get UID() {
+ return self._uid;
+ },
+ get URI() {
+ return `${self._parent.URI}/${self._uid}`;
+ },
+ get dirName() {
+ return self._name;
+ },
+ set dirName(value) {
+ self._name = value;
+ },
+ get listNickName() {
+ return self._nickName;
+ },
+ set listNickName(value) {
+ self._nickName = value;
+ },
+ get description() {
+ return self._description;
+ },
+ set description(value) {
+ self._description = value;
+ },
+ get isMailList() {
+ return true;
+ },
+ get childNodes() {
+ return [];
+ },
+ get childCards() {
+ let selectStatement = self._parent._dbConnection.createStatement(
+ "SELECT card FROM list_cards WHERE list = :list ORDER BY oid"
+ );
+ selectStatement.params.list = self._uid;
+ let results = [];
+ while (selectStatement.executeStep()) {
+ results.push(self._parent.getCard(selectStatement.row.card));
+ }
+ selectStatement.finalize();
+ return results;
+ },
+ get supportsMailingLists() {
+ return false;
+ },
+
+ search(query, string, listener) {
+ if (!listener) {
+ return;
+ }
+ if (!query) {
+ listener.onSearchFinished(Cr.NS_ERROR_FAILURE, true, null, "");
+ return;
+ }
+ if (query[0] == "?") {
+ query = query.substring(1);
+ }
+
+ let results = this.childCards;
+
+ // Process the query string into a tree of conditions to match.
+ let lispRegexp = /^\((and|or|not|([^\)]*)(\)+))/;
+ let index = 0;
+ let rootQuery = { children: [], op: "or" };
+ let currentQuery = rootQuery;
+
+ while (true) {
+ let match = lispRegexp.exec(query.substring(index));
+ if (!match) {
+ break;
+ }
+ index += match[0].length;
+
+ if (["and", "or", "not"].includes(match[1])) {
+ // For the opening bracket, step down a level.
+ let child = {
+ parent: currentQuery,
+ children: [],
+ op: match[1],
+ };
+ currentQuery.children.push(child);
+ currentQuery = child;
+ } else {
+ let [name, condition, value] = match[2].split(",");
+ currentQuery.children.push({
+ name,
+ condition,
+ value: decodeURIComponent(value).toLowerCase(),
+ });
+
+ // For each closing bracket except the first, step up a level.
+ for (let i = match[3].length - 1; i > 0; i--) {
+ currentQuery = currentQuery.parent;
+ }
+ }
+ }
+
+ results = results.filter(card => {
+ let properties = card._properties;
+ let matches = b => {
+ if ("condition" in b) {
+ let { name, condition, value } = b;
+ if (name == "IsMailList" && condition == "=") {
+ return value == "true";
+ }
+
+ if (!properties.has(name)) {
+ return condition == "!ex";
+ }
+ if (condition == "ex") {
+ return true;
+ }
+
+ let cardValue = properties.get(name).toLowerCase();
+ switch (condition) {
+ case "=":
+ return cardValue == value;
+ case "!=":
+ return cardValue != value;
+ case "lt":
+ return cardValue < value;
+ case "gt":
+ return cardValue > value;
+ case "bw":
+ return cardValue.startsWith(value);
+ case "ew":
+ return cardValue.endsWith(value);
+ case "c":
+ return cardValue.includes(value);
+ case "!c":
+ return !cardValue.includes(value);
+ case "~=":
+ case "regex":
+ default:
+ return false;
+ }
+ }
+ if (b.op == "or") {
+ return b.children.some(bb => matches(bb));
+ }
+ if (b.op == "and") {
+ return b.children.every(bb => matches(bb));
+ }
+ if (b.op == "not") {
+ return !matches(b.children[0]);
+ }
+ return false;
+ };
+
+ return matches(rootQuery);
+ }, this);
+
+ for (let card of results) {
+ listener.onSearchFoundCard(card);
+ }
+ listener.onSearchFinished(Cr.NS_OK, true, null, "");
+ },
+ addCard(card) {
+ if (this.readOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (!card.primaryEmail) {
+ return card;
+ }
+ if (!self._parent.hasCard(card)) {
+ card = self._parent.addCard(card);
+ }
+ let insertStatement = self._parent._dbConnection.createStatement(
+ "REPLACE INTO list_cards (list, card) VALUES (:list, :card)"
+ );
+ insertStatement.params.list = self._uid;
+ insertStatement.params.card = card.UID;
+ insertStatement.execute();
+ Services.obs.notifyObservers(
+ card,
+ "addrbook-list-member-added",
+ self._uid
+ );
+ insertStatement.finalize();
+ return card;
+ },
+ deleteCards(cards) {
+ if (this.readOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ let deleteCardStatement = self._parent._dbConnection.createStatement(
+ "DELETE FROM list_cards WHERE list = :list AND card = :card"
+ );
+ for (let card of cards) {
+ deleteCardStatement.params.list = self._uid;
+ deleteCardStatement.params.card = card.UID;
+ deleteCardStatement.execute();
+ if (self._parent._dbConnection.affectedRows) {
+ Services.obs.notifyObservers(
+ card,
+ "addrbook-list-member-removed",
+ self._uid
+ );
+ }
+ deleteCardStatement.reset();
+ }
+ deleteCardStatement.finalize();
+ },
+ dropCard(card, needToCopyCard) {
+ if (this.readOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (needToCopyCard) {
+ card = self._parent.dropCard(card, true);
+ }
+ this.addCard(card);
+ Services.obs.notifyObservers(
+ card,
+ "addrbook-list-member-added",
+ self._uid
+ );
+ },
+ editMailListToDatabase(listCard) {
+ if (this.readOnly) {
+ throw new Components.Exception(
+ "Directory is read-only",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ // Check if the new name is empty.
+ if (!self._name) {
+ throw new Components.Exception(
+ "Invalid mailing list name",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ // Check if the new name contains 2 spaces.
+ if (self._name.match(" ")) {
+ throw new Components.Exception(
+ "Invalid mailing list name",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ // Check if the new name contains the following special characters.
+ for (let char of ',;"<>') {
+ if (self._name.includes(char)) {
+ throw new Components.Exception(
+ "Invalid mailing list name",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+
+ self._parent.saveList(self);
+ Services.obs.notifyObservers(
+ this,
+ "addrbook-list-updated",
+ self._parent.UID
+ );
+ },
+ hasMailListWithName(name) {
+ return false;
+ },
+ getMailListFromName(name) {
+ return null;
+ },
+ };
+ },
+ get asCard() {
+ let self = this;
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIAbCard"]),
+ classID: Components.ID("{1143991d-31cd-4ea6-9c97-c587d990d724}"),
+
+ get UID() {
+ return self._uid;
+ },
+ get isMailList() {
+ return true;
+ },
+ get mailListURI() {
+ return `${self._parent.URI}/${self._uid}`;
+ },
+
+ get directoryUID() {
+ return self._parent.UID;
+ },
+ get firstName() {
+ return "";
+ },
+ get lastName() {
+ return self._name;
+ },
+ get displayName() {
+ return self._name;
+ },
+ set displayName(value) {
+ self._name = value;
+ },
+ get primaryEmail() {
+ return "";
+ },
+ get emailAddresses() {
+ // NOT the members of this list.
+ return [];
+ },
+
+ generateName(generateFormat) {
+ return self._name;
+ },
+ getProperty(name, defaultValue) {
+ switch (name) {
+ case "NickName":
+ return self._nickName;
+ case "Notes":
+ return self._description;
+ }
+ return defaultValue;
+ },
+ setProperty(name, value) {
+ switch (name) {
+ case "NickName":
+ self._nickName = value;
+ break;
+ case "Notes":
+ self._description = value;
+ break;
+ }
+ },
+ equals(card) {
+ return self._uid == card.UID;
+ },
+ hasEmailAddress(emailAddress) {
+ return false;
+ },
+ get properties() {
+ const entries = [
+ ["DisplayName", this.displayName],
+ ["NickName", this.getProperty("NickName", "")],
+ ["Notes", this.getProperty("Notes", "")],
+ ];
+ let props = [];
+ for (const [name, value] of entries) {
+ props.push({
+ get name() {
+ return name;
+ },
+ get value() {
+ return value;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIProperty"]),
+ });
+ }
+ return props;
+ },
+ get supportsVCard() {
+ return false;
+ },
+ get vCardProperties() {
+ return null;
+ },
+ translateTo(type) {
+ // Get nsAbCardProperty to do the work, the code is in C++ anyway.
+ let cardCopy = Cc[
+ "@mozilla.org/addressbook/cardproperty;1"
+ ].createInstance(Ci.nsIAbCard);
+ cardCopy.UID = this.UID;
+ cardCopy.copy(this);
+ return cardCopy.translateTo(type);
+ },
+ };
+ },
+};
diff --git a/comm/mailnews/addrbook/modules/AddrBookManager.jsm b/comm/mailnews/addrbook/modules/AddrBookManager.jsm
new file mode 100644
index 0000000000..6e15a4c971
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/AddrBookManager.jsm
@@ -0,0 +1,608 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["AddrBookManager"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ compareAddressBooks: "resource:///modules/AddrBookUtils.jsm",
+ MailGlue: "resource:///modules/MailGlue.jsm",
+});
+
+/** Test for valid directory URIs. */
+const URI_REGEXP = /^([\w-]+):\/\/([\w\.-]*)([/:].*|$)/;
+
+/**
+ * When initialized, a map of nsIAbDirectory objects. Keys to this map are
+ * the directories' URIs.
+ */
+let store = null;
+
+/** Valid address book types. This differs by operating system. */
+let types = ["jsaddrbook", "jscarddav", "moz-abldapdirectory"];
+if (AppConstants.platform == "macosx") {
+ types.push("moz-abosxdirectory");
+} else if (AppConstants.platform == "win") {
+ types.push("moz-aboutlookdirectory");
+}
+
+/**
+ * A pre-sorted list of directories in the right order, to be returned by
+ * AddrBookManager.directories. That function is called a lot, and there's
+ * no need to sort the list every time.
+ *
+ * Call updateSortedDirectoryList after `store` changes and before any
+ * notifications happen.
+ */
+let sortedDirectoryList = [];
+function updateSortedDirectoryList() {
+ sortedDirectoryList = [...store.values()];
+ sortedDirectoryList.sort(lazy.compareAddressBooks);
+}
+
+/**
+ * Initialise an address book directory by URI.
+ *
+ * @param {string} uri - URI for the directory.
+ * @param {boolean} shouldStore - Whether to keep a reference to this address
+ * book in the store.
+ * @returns {nsIAbDirectory}
+ */
+function createDirectoryObject(uri, shouldStore = false) {
+ let uriParts = URI_REGEXP.exec(uri);
+ if (!uriParts) {
+ throw Components.Exception(
+ `Unexpected uri: ${uri}`,
+ Cr.NS_ERROR_MALFORMED_URI
+ );
+ }
+
+ let [, scheme] = uriParts;
+ let dir = Cc[
+ `@mozilla.org/addressbook/directory;1?type=${scheme}`
+ ].createInstance(Ci.nsIAbDirectory);
+
+ try {
+ if (shouldStore) {
+ // This must happen before .init is called, or the OS X provider breaks
+ // in some circumstances. If .init fails, we'll remove it again.
+ // The Outlook provider also needs this since during the initialisation
+ // of the top-most directory, contained mailing lists already need
+ // to loop that directory.
+ store.set(uri, dir);
+ }
+ dir.init(uri);
+ } catch (ex) {
+ if (shouldStore) {
+ store.delete(uri);
+ }
+ throw ex;
+ }
+
+ return dir;
+}
+
+/**
+ * Read the preferences and create any address books defined there.
+ */
+function ensureInitialized() {
+ if (store !== null) {
+ return;
+ }
+ if (lazy.MailGlue.isToolboxProcess) {
+ throw new Components.Exception(
+ "AddrBookManager tried to start in the Developer Tools process!",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ store = new Map();
+
+ for (let pref of Services.prefs.getChildList("ldap_2.servers.")) {
+ try {
+ if (pref.endsWith(".uri")) {
+ let uri = Services.prefs.getStringPref(pref);
+ if (uri.startsWith("ldap://") || uri.startsWith("ldaps://")) {
+ let prefName = pref.substring(0, pref.length - 4);
+
+ uri = `moz-abldapdirectory://${prefName}`;
+ createDirectoryObject(uri, true);
+ }
+ } else if (pref.endsWith(".dirType")) {
+ let prefName = pref.substring(0, pref.length - 8);
+ let dirType = Services.prefs.getIntPref(pref);
+ let fileName = Services.prefs.getStringPref(`${prefName}.filename`, "");
+ let uri = Services.prefs.getStringPref(`${prefName}.uri`, "");
+
+ switch (dirType) {
+ case Ci.nsIAbManager.MAPI_DIRECTORY_TYPE:
+ if (
+ Cu.isInAutomation ||
+ Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
+ ) {
+ // Don't load the OS Address Book in tests.
+ break;
+ }
+ if (Services.prefs.getIntPref(`${prefName}.position`, 1) < 1) {
+ // Migration: the previous address book manager set the position
+ // value to 0 to indicate the removal of an address book.
+ Services.prefs.clearUserPref(`${prefName}.position`);
+ Services.prefs.setIntPref(pref, -1);
+ break;
+ }
+ if (AppConstants.platform == "macosx") {
+ createDirectoryObject(uri, true);
+ } else if (AppConstants.platform == "win") {
+ let outlookInterface = Cc[
+ "@mozilla.org/addressbook/outlookinterface;1"
+ ].getService(Ci.nsIAbOutlookInterface);
+ for (let folderURI of outlookInterface.getFolderURIs(uri)) {
+ createDirectoryObject(folderURI, true);
+ }
+ }
+ break;
+ case Ci.nsIAbManager.JS_DIRECTORY_TYPE:
+ if (fileName) {
+ let uri = `jsaddrbook://${fileName}`;
+ createDirectoryObject(uri, true);
+ }
+ break;
+ case Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE:
+ if (fileName) {
+ let uri = `jscarddav://${fileName}`;
+ createDirectoryObject(uri, true);
+ }
+ break;
+ }
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+
+ updateSortedDirectoryList();
+}
+
+// Force the manager to shut down. For tests only.
+Services.obs.addObserver(async () => {
+ // Allow directories to tidy up.
+ for (let directory of store.values()) {
+ await directory.cleanUp();
+ }
+ // Clear the store. The next call to ensureInitialized will recreate it.
+ store = null;
+ Services.obs.notifyObservers(null, "addrbook-reloaded");
+}, "addrbook-reload");
+
+/** Cache for the cardForEmailAddress function, and timer to clear it. */
+let addressCache = new Map();
+let addressCacheTimer = null;
+
+// Throw away cached cards if the display name properties change, so we can
+// get the updated version of the card that changed.
+Services.prefs.addObserver("mail.displayname.version", () => {
+ addressCache.clear();
+ Services.obs.notifyObservers(null, "addrbook-displayname-changed");
+});
+
+// When this prefence has been updated, we need to update the
+// mail.displayname.version, which notifies it's preference observer (above).
+// This will then notify the addrbook-displayname-changed observer, and change
+// the displayname in the thread tree and message header.
+Services.prefs.addObserver("mail.showCondensedAddresses", () => {
+ Services.prefs.setIntPref(
+ "mail.displayname.version",
+ Services.prefs.getIntPref("mail.displayname.version") + 1
+ );
+});
+
+/**
+ * @implements {nsIAbManager}
+ * @implements {nsICommandLineHandler}
+ */
+function AddrBookManager() {}
+AddrBookManager.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIAbManager",
+ "nsICommandLineHandler",
+ ]),
+ classID: Components.ID("{224d3ef9-d81c-4d94-8826-a79a5835af93}"),
+
+ /* nsIAbManager */
+
+ get directories() {
+ ensureInitialized();
+ return sortedDirectoryList.slice();
+ },
+ getDirectory(uri) {
+ if (uri.startsWith("moz-abdirectory://")) {
+ throw new Components.Exception(
+ "The root address book no longer exists",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ ensureInitialized();
+ if (store.has(uri)) {
+ return store.get(uri);
+ }
+
+ let uriParts = URI_REGEXP.exec(uri);
+ if (!uriParts) {
+ throw Components.Exception(
+ `Unexpected uri: ${uri}`,
+ Cr.NS_ERROR_MALFORMED_URI
+ );
+ }
+ let [, scheme, fileName, tail] = uriParts;
+ if (tail && types.includes(scheme)) {
+ if (
+ (scheme == "jsaddrbook" && tail.startsWith("/")) ||
+ scheme == "moz-aboutlookdirectory"
+ ) {
+ let parent;
+ if (scheme == "jsaddrbook") {
+ parent = this.getDirectory(`${scheme}://${fileName}`);
+ } else {
+ parent = this.getDirectory(`${scheme}:///${tail.split("/")[1]}`);
+ }
+ for (let list of parent.childNodes) {
+ list.QueryInterface(Ci.nsIAbDirectory);
+ if (list.URI == uri) {
+ return list;
+ }
+ }
+ throw Components.Exception(
+ `No ${scheme} directory for uri=${uri}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ } else if (scheme == "jscarddav") {
+ throw Components.Exception(
+ `No ${scheme} directory for uri=${uri}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ // `tail` could point to a mailing list.
+ return createDirectoryObject(uri);
+ }
+ throw Components.Exception(
+ `No directory for uri=${uri}`,
+ Cr.NS_ERROR_FAILURE
+ );
+ },
+ getDirectoryFromId(dirPrefId) {
+ ensureInitialized();
+ for (let dir of store.values()) {
+ if (dir.dirPrefId == dirPrefId) {
+ return dir;
+ }
+ }
+ return null;
+ },
+ getDirectoryFromUID(uid) {
+ ensureInitialized();
+ for (let dir of store.values()) {
+ if (dir.UID == uid) {
+ return dir;
+ }
+ }
+ return null;
+ },
+ getMailListFromName(name) {
+ ensureInitialized();
+ for (let dir of store.values()) {
+ let hit = dir.getMailListFromName(name);
+ if (hit) {
+ return hit;
+ }
+ }
+ return null;
+ },
+ newAddressBook(dirName, uri, type, uid) {
+ function ensureUniquePrefName() {
+ let leafName = dirName.replace(/\W/g, "");
+ if (!leafName) {
+ leafName = "_nonascii";
+ }
+
+ let existingNames = Array.from(store.values(), dir => dir.dirPrefId);
+ let uniqueCount = 0;
+ prefName = `ldap_2.servers.${leafName}`;
+ while (existingNames.includes(prefName)) {
+ prefName = `ldap_2.servers.${leafName}_${++uniqueCount}`;
+ }
+ }
+
+ if (!dirName) {
+ throw new Components.Exception(
+ "dirName must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+ if (uid && this.getDirectoryFromUID(uid)) {
+ throw new Components.Exception(
+ `An address book with the UID ${uid} already exists`,
+ Cr.NS_ERROR_ABORT
+ );
+ }
+
+ let prefName;
+ ensureInitialized();
+
+ switch (type) {
+ case Ci.nsIAbManager.LDAP_DIRECTORY_TYPE: {
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("ldap.sqlite");
+ file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+
+ ensureUniquePrefName();
+ Services.prefs.setStringPref(`${prefName}.description`, dirName);
+ Services.prefs.setStringPref(`${prefName}.filename`, file.leafName);
+ Services.prefs.setStringPref(`${prefName}.uri`, uri);
+ if (uid) {
+ Services.prefs.setStringPref(`${prefName}.uid`, uid);
+ }
+
+ uri = `moz-abldapdirectory://${prefName}`;
+ let dir = createDirectoryObject(uri, true);
+ updateSortedDirectoryList();
+ Services.obs.notifyObservers(dir, "addrbook-directory-created");
+ break;
+ }
+ case Ci.nsIAbManager.MAPI_DIRECTORY_TYPE: {
+ if (AppConstants.platform == "macosx") {
+ uri = "moz-abosxdirectory:///";
+ if (store.has(uri)) {
+ throw Components.Exception(
+ `Can't create new ab of type=${type} - already exists`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ prefName = "ldap_2.servers.osx";
+ } else if (AppConstants.platform == "win") {
+ uri = "moz-aboutlookdirectory:///";
+ if (store.has(uri)) {
+ throw Components.Exception(
+ `Can't create new ab of type=${type} - already exists`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ prefName = "ldap_2.servers.outlook";
+ } else {
+ throw Components.Exception(
+ "Can't create new ab of type=MAPI_DIRECTORY_TYPE",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ Services.prefs.setIntPref(
+ `${prefName}.dirType`,
+ Ci.nsIAbManager.MAPI_DIRECTORY_TYPE
+ );
+ Services.prefs.setStringPref(
+ `${prefName}.description`,
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+ Services.prefs.setStringPref(`${prefName}.uri`, uri);
+ if (uid) {
+ Services.prefs.setStringPref(`${prefName}.uid`, uid);
+ }
+
+ if (AppConstants.platform == "macosx") {
+ let dir = createDirectoryObject(uri, true);
+ updateSortedDirectoryList();
+ Services.obs.notifyObservers(dir, "addrbook-directory-created");
+ } else if (AppConstants.platform == "win") {
+ let outlookInterface = Cc[
+ "@mozilla.org/addressbook/outlookinterface;1"
+ ].getService(Ci.nsIAbOutlookInterface);
+ for (let folderURI of outlookInterface.getFolderURIs(uri)) {
+ let dir = createDirectoryObject(folderURI, true);
+ updateSortedDirectoryList();
+ Services.obs.notifyObservers(dir, "addrbook-directory-created");
+ }
+ }
+ break;
+ }
+ case Ci.nsIAbManager.JS_DIRECTORY_TYPE:
+ case Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE: {
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append("abook.sqlite");
+ file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+
+ ensureUniquePrefName();
+ Services.prefs.setStringPref(`${prefName}.description`, dirName);
+ Services.prefs.setIntPref(`${prefName}.dirType`, type);
+ Services.prefs.setStringPref(`${prefName}.filename`, file.leafName);
+ if (uid) {
+ Services.prefs.setStringPref(`${prefName}.uid`, uid);
+ }
+
+ let scheme =
+ type == Ci.nsIAbManager.JS_DIRECTORY_TYPE
+ ? "jsaddrbook"
+ : "jscarddav";
+ uri = `${scheme}://${file.leafName}`;
+ let dir = createDirectoryObject(uri, true);
+ updateSortedDirectoryList();
+ Services.obs.notifyObservers(dir, "addrbook-directory-created");
+ break;
+ }
+ default:
+ throw Components.Exception(
+ `Unexpected directory type: ${type}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ return prefName;
+ },
+ addAddressBook(dir) {
+ if (
+ !dir.URI ||
+ !dir.dirName ||
+ !dir.UID ||
+ dir.isMailList ||
+ dir.isQuery ||
+ dir.dirPrefId
+ ) {
+ throw new Components.Exception(
+ "Invalid directory",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ ensureInitialized();
+ if (store.has(dir.URI)) {
+ throw new Components.Exception(
+ "Directory already exists",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ store.set(dir.URI, dir);
+ updateSortedDirectoryList();
+ Services.obs.notifyObservers(dir, "addrbook-directory-created");
+ },
+ deleteAddressBook(uri) {
+ let uriParts = URI_REGEXP.exec(uri);
+ if (!uriParts) {
+ throw Components.Exception("", Cr.NS_ERROR_MALFORMED_URI);
+ }
+
+ let [, scheme, fileName, tail] = uriParts;
+ if (tail && tail.startsWith("/")) {
+ let dir;
+ if (scheme == "jsaddrbook") {
+ dir = store.get(`${scheme}://${fileName}`);
+ } else if (scheme == "moz-aboutlookdirectory") {
+ dir = store.get(`${scheme}:///${tail.split("/")[1]}`);
+ }
+ let list = this.getDirectory(uri);
+ if (dir && list) {
+ dir.deleteDirectory(list);
+ return;
+ }
+ }
+
+ let dir = store.get(uri);
+ if (!dir) {
+ throw new Components.Exception(
+ `Address book not found: ${uri}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ let prefName = dir.dirPrefId;
+ if (prefName) {
+ let dirType = Services.prefs.getIntPref(`${prefName}.dirType`, 0);
+ fileName = dir.fileName;
+
+ // Deleting the built-in address books is very bad.
+ if (["ldap_2.servers.pab", "ldap_2.servers.history"].includes(prefName)) {
+ throw new Components.Exception(
+ "Refusing to delete a built-in address book",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ for (let name of Services.prefs.getChildList(`${prefName}.`)) {
+ Services.prefs.clearUserPref(name);
+ }
+ if (dirType == Ci.nsIAbManager.MAPI_DIRECTORY_TYPE) {
+ // The prefs for this directory type are defaults. Setting the dirType
+ // to -1 ensures the directory is ignored.
+ Services.prefs.setIntPref(`${prefName}.dirType`, -1);
+ }
+ }
+
+ store.delete(uri);
+ updateSortedDirectoryList();
+
+ // Clear this reference to the deleted address book.
+ if (Services.prefs.getStringPref("mail.collect_addressbook") == uri) {
+ Services.prefs.clearUserPref("mail.collect_addressbook");
+ }
+
+ dir.cleanUp().then(() => {
+ if (fileName) {
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append(fileName);
+ if (file.exists()) {
+ file.remove(false);
+ }
+ }
+
+ Services.obs.notifyObservers(dir, "addrbook-directory-deleted");
+ });
+ },
+ mailListNameExists(name) {
+ ensureInitialized();
+ for (let dir of store.values()) {
+ if (dir.hasMailListWithName(name)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ /**
+ * Finds out if the directory name already exists.
+ *
+ * @param {string} name - The name of a directory to check for.
+ */
+ directoryNameExists(name) {
+ ensureInitialized();
+ for (let dir of store.values()) {
+ if (dir.dirName.toLowerCase() === name.toLowerCase()) {
+ return true;
+ }
+ }
+ return false;
+ },
+ cardForEmailAddress(emailAddress) {
+ if (!emailAddress) {
+ return null;
+ }
+
+ if (addressCacheTimer) {
+ lazy.clearTimeout(addressCacheTimer);
+ }
+ addressCacheTimer = lazy.setTimeout(() => {
+ addressCacheTimer = null;
+ addressCache.clear();
+ }, 60000);
+
+ if (addressCache.has(emailAddress)) {
+ return addressCache.get(emailAddress);
+ }
+
+ for (let directory of sortedDirectoryList) {
+ try {
+ let card = directory.cardForEmailAddress(emailAddress);
+ if (card) {
+ addressCache.set(emailAddress, card);
+ return card;
+ }
+ } catch (ex) {
+ // Directories can throw, that's okay.
+ }
+ }
+
+ addressCache.set(emailAddress, null);
+ return null;
+ },
+};
diff --git a/comm/mailnews/addrbook/modules/AddrBookUtils.jsm b/comm/mailnews/addrbook/modules/AddrBookUtils.jsm
new file mode 100644
index 0000000000..aea0c152ad
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/AddrBookUtils.jsm
@@ -0,0 +1,522 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "exportAttributes",
+ "AddrBookUtils",
+ "compareAddressBooks",
+ "newUID",
+ "SimpleEnumerator",
+];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { MailStringUtils } = ChromeUtils.import(
+ "resource:///modules/MailStringUtils.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ attrMapService: [
+ "@mozilla.org/addressbook/ldap-attribute-map-service;1",
+ "nsIAbLDAPAttributeMapService",
+ ],
+});
+
+function SimpleEnumerator(elements) {
+ this._elements = elements;
+ this._position = 0;
+}
+SimpleEnumerator.prototype = {
+ hasMoreElements() {
+ return this._position < this._elements.length;
+ },
+ getNext() {
+ if (this.hasMoreElements()) {
+ return this._elements[this._position++];
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]),
+ *[Symbol.iterator]() {
+ while (this.hasMoreElements()) {
+ yield this.getNext();
+ }
+ },
+};
+
+function newUID() {
+ return Services.uuid.generateUUID().toString().substring(1, 37);
+}
+
+let abSortOrder = {
+ [Ci.nsIAbManager.JS_DIRECTORY_TYPE]: 1,
+ [Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE]: 2,
+ [Ci.nsIAbManager.LDAP_DIRECTORY_TYPE]: 3,
+ [Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE]: 3,
+ [Ci.nsIAbManager.MAPI_DIRECTORY_TYPE]: 4,
+};
+let abNameComparer = new Intl.Collator(undefined, { numeric: true });
+
+/**
+ * Comparator for address books. Any UI that lists address books should use
+ * this order, although generally speaking, using nsIAbManager.directories is
+ * all that is required to get the order.
+ *
+ * Note that directories should not be compared with mailing lists in this way,
+ * however two mailing lists with the same parent can be safely compared.
+ *
+ * @param {nsIAbDirectory} a
+ * @param {nsIAbDirectory} b
+ * @returns {integer}
+ */
+function compareAddressBooks(a, b) {
+ if (a.isMailList != b.isMailList) {
+ throw Components.Exception(
+ "Tried to compare a mailing list with a directory",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ // Only compare the names of mailing lists.
+ if (a.isMailList) {
+ return abNameComparer.compare(a.dirName, b.dirName);
+ }
+
+ // The Personal Address Book is first and Collected Addresses last.
+ let aPrefId = a.dirPrefId;
+ let bPrefId = b.dirPrefId;
+
+ if (aPrefId == "ldap_2.servers.pab" || bPrefId == "ldap_2.servers.history") {
+ return -1;
+ }
+ if (bPrefId == "ldap_2.servers.pab" || aPrefId == "ldap_2.servers.history") {
+ return 1;
+ }
+
+ // Order remaining directories by type.
+ let aType = a.dirType;
+ let bType = b.dirType;
+
+ if (aType != bType) {
+ return abSortOrder[aType] - abSortOrder[bType];
+ }
+
+ // Order directories of the same type by name, case-insensitively.
+ return abNameComparer.compare(a.dirName, b.dirName);
+}
+
+const exportAttributes = [
+ ["FirstName", 2100],
+ ["LastName", 2101],
+ ["DisplayName", 2102],
+ ["NickName", 2103],
+ ["PrimaryEmail", 2104],
+ ["SecondEmail", 2105],
+ ["_AimScreenName", 2136],
+ ["LastModifiedDate", 0],
+ ["WorkPhone", 2106],
+ ["WorkPhoneType", 0],
+ ["HomePhone", 2107],
+ ["HomePhoneType", 0],
+ ["FaxNumber", 2108],
+ ["FaxNumberType", 0],
+ ["PagerNumber", 2109],
+ ["PagerNumberType", 0],
+ ["CellularNumber", 2110],
+ ["CellularNumberType", 0],
+ ["HomeAddress", 2111],
+ ["HomeAddress2", 2112],
+ ["HomeCity", 2113],
+ ["HomeState", 2114],
+ ["HomeZipCode", 2115],
+ ["HomeCountry", 2116],
+ ["WorkAddress", 2117],
+ ["WorkAddress2", 2118],
+ ["WorkCity", 2119],
+ ["WorkState", 2120],
+ ["WorkZipCode", 2121],
+ ["WorkCountry", 2122],
+ ["JobTitle", 2123],
+ ["Department", 2124],
+ ["Company", 2125],
+ ["WebPage1", 2126],
+ ["WebPage2", 2127],
+ ["BirthYear", 2128],
+ ["BirthMonth", 2129],
+ ["BirthDay", 2130],
+ ["Custom1", 2131],
+ ["Custom2", 2132],
+ ["Custom3", 2133],
+ ["Custom4", 2134],
+ ["Notes", 2135],
+ ["AnniversaryYear", 0],
+ ["AnniversaryMonth", 0],
+ ["AnniversaryDay", 0],
+ ["SpouseName", 0],
+ ["FamilyName", 0],
+];
+const LINEBREAK = AppConstants.platform == "win" ? "\r\n" : "\n";
+
+var AddrBookUtils = {
+ compareAddressBooks,
+ async exportDirectory(directory) {
+ let systemCharset = "utf-8";
+ if (AppConstants.platform == "win") {
+ // Some Windows applications (notably Outlook) still don't understand
+ // UTF-8 encoding when importing address books and instead use the current
+ // operating system encoding. We can get that encoding from the registry.
+ let registryKey = Cc[
+ "@mozilla.org/windows-registry-key;1"
+ ].createInstance(Ci.nsIWindowsRegKey);
+ registryKey.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SYSTEM\\CurrentControlSet\\Control\\Nls\\CodePage",
+ Ci.nsIWindowsRegKey.ACCESS_READ
+ );
+ let acpValue = registryKey.readStringValue("ACP");
+
+ // This data converts the registry key value into encodings that
+ // nsIConverterOutputStream understands. It is from
+ // https://github.com/hsivonen/encoding_rs/blob/c3eb642cdf3f17003b8dac95c8fff478568e46da/generate-encoding-data.py#L188
+ systemCharset =
+ {
+ 866: "IBM866",
+ 874: "windows-874",
+ 932: "Shift_JIS",
+ 936: "GBK",
+ 949: "EUC-KR",
+ 950: "Big5",
+ 1200: "UTF-16LE",
+ 1201: "UTF-16BE",
+ 1250: "windows-1250",
+ 1251: "windows-1251",
+ 1252: "windows-1252",
+ 1253: "windows-1253",
+ 1254: "windows-1254",
+ 1255: "windows-1255",
+ 1256: "windows-1256",
+ 1257: "windows-1257",
+ 1258: "windows-1258",
+ 10000: "macintosh",
+ 10017: "x-mac-cyrillic",
+ 20866: "KOI8-R",
+ 20932: "EUC-JP",
+ 21866: "KOI8-U",
+ 28592: "ISO-8859-2",
+ 28593: "ISO-8859-3",
+ 28594: "ISO-8859-4",
+ 28595: "ISO-8859-5",
+ 28596: "ISO-8859-6",
+ 28597: "ISO-8859-7",
+ 28598: "ISO-8859-8",
+ 28600: "ISO-8859-10",
+ 28603: "ISO-8859-13",
+ 28604: "ISO-8859-14",
+ 28605: "ISO-8859-15",
+ 28606: "ISO-8859-16",
+ 38598: "ISO-8859-8-I",
+ 50221: "ISO-2022-JP",
+ 54936: "gb18030",
+ }[acpValue] || systemCharset;
+ }
+
+ let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
+ Ci.nsIFilePicker
+ );
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addressbook/addressBook.properties"
+ );
+
+ let title = bundle.formatStringFromName("ExportAddressBookNameTitle", [
+ directory.dirName,
+ ]);
+ filePicker.init(Services.ww.activeWindow, title, Ci.nsIFilePicker.modeSave);
+ filePicker.defaultString = directory.dirName;
+
+ let filterString;
+ // Since the list of file picker filters isn't fixed, keep track of which
+ // ones are added, so we can use them in the switch block below.
+ let activeFilters = [];
+
+ // CSV
+ if (systemCharset != "utf-8") {
+ filterString = bundle.GetStringFromName("CSVFilesSysCharset");
+ filePicker.appendFilter(filterString, "*.csv");
+ activeFilters.push("CSVFilesSysCharset");
+ }
+ filterString = bundle.GetStringFromName("CSVFilesUTF8");
+ filePicker.appendFilter(filterString, "*.csv");
+ activeFilters.push("CSVFilesUTF8");
+
+ // Tab separated
+ if (systemCharset != "utf-8") {
+ filterString = bundle.GetStringFromName("TABFilesSysCharset");
+ filePicker.appendFilter(filterString, "*.tab; *.txt");
+ activeFilters.push("TABFilesSysCharset");
+ }
+ filterString = bundle.GetStringFromName("TABFilesUTF8");
+ filePicker.appendFilter(filterString, "*.tab; *.txt");
+ activeFilters.push("TABFilesUTF8");
+
+ // vCard
+ filterString = bundle.GetStringFromName("VCFFiles");
+ filePicker.appendFilter(filterString, "*.vcf");
+ activeFilters.push("VCFFiles");
+
+ // LDIF
+ filterString = bundle.GetStringFromName("LDIFFiles");
+ filePicker.appendFilter(filterString, "*.ldi; *.ldif");
+ activeFilters.push("LDIFFiles");
+
+ let rv = await new Promise(resolve => filePicker.open(resolve));
+ if (
+ rv == Ci.nsIFilePicker.returnCancel ||
+ !filePicker.file ||
+ !filePicker.file.path
+ ) {
+ return;
+ }
+
+ if (rv == Ci.nsIFilePicker.returnReplace) {
+ if (filePicker.file.isFile()) {
+ filePicker.file.remove(false);
+ }
+ }
+
+ let exportFile = filePicker.file.clone();
+ let leafName = exportFile.leafName;
+ let output = "";
+ let charset = "utf-8";
+
+ switch (activeFilters[filePicker.filterIndex]) {
+ case "CSVFilesSysCharset":
+ charset = systemCharset;
+ // Falls through.
+ case "CSVFilesUTF8":
+ if (!leafName.endsWith(".csv")) {
+ exportFile.leafName += ".csv";
+ }
+ output = AddrBookUtils.exportDirectoryToDelimitedText(directory, ",");
+ break;
+ case "TABFilesSysCharset":
+ charset = systemCharset;
+ // Falls through.
+ case "TABFilesUTF8":
+ if (!leafName.endsWith(".txt") && !leafName.endsWith(".tab")) {
+ exportFile.leafName += ".txt";
+ }
+ output = AddrBookUtils.exportDirectoryToDelimitedText(directory, "\t");
+ break;
+ case "VCFFiles":
+ if (!leafName.endsWith(".vcf")) {
+ exportFile.leafName += ".vcf";
+ }
+ output = AddrBookUtils.exportDirectoryToVCard(directory);
+ break;
+ case "LDIFFiles":
+ if (!leafName.endsWith(".ldi") && !leafName.endsWith(".ldif")) {
+ exportFile.leafName += ".ldif";
+ }
+ output = AddrBookUtils.exportDirectoryToLDIF(directory);
+ break;
+ }
+
+ if (charset == "utf-8") {
+ await IOUtils.writeUTF8(exportFile.path, output);
+ return;
+ }
+
+ let outputFileStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ outputFileStream.init(exportFile, -1, -1, 0);
+ let outputStream = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ outputStream.init(outputFileStream, charset);
+ outputStream.writeString(output);
+ outputStream.close();
+ },
+ exportDirectoryToDelimitedText(directory, delimiter) {
+ let bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/importMsgs.properties"
+ );
+ let output = "";
+ for (let i = 0; i < exportAttributes.length; i++) {
+ let [, plainTextStringID] = exportAttributes[i];
+ if (plainTextStringID != 0) {
+ if (i != 0) {
+ output += delimiter;
+ }
+ output += bundle.GetStringFromID(plainTextStringID);
+ }
+ }
+ output += LINEBREAK;
+ for (let card of directory.childCards) {
+ if (card.isMailList) {
+ // .tab, .txt and .csv aren't able to export mailing lists.
+ // Use LDIF for that.
+ continue;
+ }
+ let propertyMap = card.supportsVCard
+ ? card.vCardProperties.toPropertyMap()
+ : null;
+ for (let i = 0; i < exportAttributes.length; i++) {
+ let [abPropertyName, plainTextStringID] = exportAttributes[i];
+ if (plainTextStringID == 0) {
+ continue;
+ }
+ if (i != 0) {
+ output += delimiter;
+ }
+ let value;
+ if (propertyMap) {
+ value = propertyMap.get(abPropertyName);
+ }
+ if (!value) {
+ value = card.getProperty(abPropertyName, "");
+ }
+
+ // If a string contains at least one comma, tab, double quote or line
+ // break then we need to quote the entire string. Also if double quote
+ // is part of the string we need to quote the double quote(s) as well.
+ let needsQuotes = false;
+ if (value.includes('"')) {
+ needsQuotes = true;
+ value = value.replace(/"/g, '""');
+ } else if (/[,\t\r\n]/.test(value)) {
+ needsQuotes = true;
+ }
+ if (needsQuotes) {
+ value = `"${value}"`;
+ }
+
+ output += value;
+ }
+ output += LINEBREAK;
+ }
+
+ return output;
+ },
+ exportDirectoryToLDIF(directory) {
+ function appendProperty(name, value) {
+ if (!value) {
+ return;
+ }
+ // Follow RFC 2849 to determine if something is safe "as is" for LDIF.
+ // If not, base 64 encode it as UTF-8.
+ if (
+ value[0] == " " ||
+ value[0] == ":" ||
+ value[0] == "<" ||
+ /[\0\r\n\u0080-\uffff]/.test(value)
+ ) {
+ // Convert 16bit JavaScript string to a byteString, to make it work with
+ // btoa().
+ let byteString = MailStringUtils.stringToByteString(value);
+ output += name + ":: " + btoa(byteString) + LINEBREAK;
+ } else {
+ output += name + ": " + value + LINEBREAK;
+ }
+ }
+
+ function appendDNForCard(property, card, attrMap) {
+ let value = "";
+ if (card.displayName) {
+ value +=
+ attrMap.getFirstAttribute("DisplayName") + "=" + card.displayName;
+ }
+ if (card.primaryEmail) {
+ if (card.displayName) {
+ value += ",";
+ }
+ value +=
+ attrMap.getFirstAttribute("PrimaryEmail") + "=" + card.primaryEmail;
+ }
+ appendProperty(property, value);
+ }
+
+ let output = "";
+ let attrMap = lazy.attrMapService.getMapForPrefBranch(
+ "ldap_2.servers.default.attrmap"
+ );
+
+ for (let card of directory.childCards) {
+ if (card.isMailList) {
+ appendDNForCard("dn", card, attrMap);
+ appendProperty("objectclass", "top");
+ appendProperty("objectclass", "groupOfNames");
+ appendProperty(
+ attrMap.getFirstAttribute("DisplayName"),
+ card.displayName
+ );
+ if (card.getProperty("NickName", "")) {
+ appendProperty(
+ attrMap.getFirstAttribute("NickName"),
+ card.getProperty("NickName", "")
+ );
+ }
+ if (card.getProperty("Notes", "")) {
+ appendProperty(
+ attrMap.getFirstAttribute("Notes"),
+ card.getProperty("Notes", "")
+ );
+ }
+ let listAsDirectory = MailServices.ab.getDirectory(card.mailListURI);
+ for (let childCard of listAsDirectory.childCards) {
+ appendDNForCard("member", childCard, attrMap);
+ }
+ } else {
+ appendDNForCard("dn", card, attrMap);
+ appendProperty("objectclass", "top");
+ appendProperty("objectclass", "person");
+ appendProperty("objectclass", "organizationalPerson");
+ appendProperty("objectclass", "inetOrgPerson");
+ appendProperty("objectclass", "mozillaAbPersonAlpha");
+
+ let propertyMap = card.supportsVCard
+ ? card.vCardProperties.toPropertyMap()
+ : null;
+ for (let [abPropertyName] of exportAttributes) {
+ let attrName = attrMap.getFirstAttribute(abPropertyName);
+ if (attrName) {
+ let attrValue;
+ if (propertyMap) {
+ attrValue = propertyMap.get(abPropertyName);
+ }
+ if (!attrValue) {
+ attrValue = card.getProperty(abPropertyName, "");
+ }
+ appendProperty(attrName, attrValue);
+ }
+ }
+ }
+ output += LINEBREAK;
+ }
+
+ return output;
+ },
+ exportDirectoryToVCard(directory) {
+ let output = "";
+ for (let card of directory.childCards) {
+ if (!card.isMailList) {
+ // We don't know how to export mailing lists to vcf.
+ // Use LDIF for that.
+ output += decodeURIComponent(card.translateTo("vcard"));
+ }
+ }
+ return output;
+ },
+ newUID,
+ SimpleEnumerator,
+};
diff --git a/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm b/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm
new file mode 100644
index 0000000000..e4361d53bb
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm
@@ -0,0 +1,925 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CardDAVDirectory"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+const { SQLiteDirectory } = ChromeUtils.import(
+ "resource:///modules/SQLiteDirectory.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ clearInterval: "resource://gre/modules/Timer.sys.mjs",
+ setInterval: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CardDAVUtils: "resource:///modules/CardDAVUtils.jsm",
+ NotificationCallbacks: "resource:///modules/CardDAVUtils.jsm",
+ OAuth2Module: "resource:///modules/OAuth2Module.jsm",
+ OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
+ VCardProperties: "resource:///modules/VCardUtils.jsm",
+ VCardUtils: "resource:///modules/VCardUtils.jsm",
+});
+
+const PREFIX_BINDINGS = {
+ card: "urn:ietf:params:xml:ns:carddav",
+ cs: "http://calendarserver.org/ns/",
+ d: "DAV:",
+};
+const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
+ .map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
+ .join(" ");
+
+const log = console.createInstance({
+ prefix: "carddav.sync",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "carddav.sync.loglevel",
+});
+
+/**
+ * Adds CardDAV sync to SQLiteDirectory.
+ */
+class CardDAVDirectory extends SQLiteDirectory {
+ /** nsIAbDirectory */
+
+ init(uri) {
+ super.init(uri);
+
+ let serverURL = this._serverURL;
+ if (serverURL) {
+ // Google's server enforces some vCard 3.0-isms (or just fails badly if
+ // you don't provide exactly what it wants) so we use this property to
+ // determine when to do things differently. Cards from this directory
+ // inherit the same property.
+ if (this.getBoolValue("carddav.vcard3")) {
+ this._isGoogleCardDAV = true;
+ } else {
+ this._isGoogleCardDAV = serverURL.startsWith(
+ "https://www.googleapis.com/"
+ );
+ if (this._isGoogleCardDAV) {
+ this.setBoolValue("carddav.vcard3", true);
+ }
+ }
+
+ // If this directory is configured, start sync'ing with the server in 30s.
+ // Don't do this immediately, as this code runs at start-up and could
+ // impact performance if there are lots of changes to process.
+ if (this.getIntValue("carddav.syncinterval", 30) > 0) {
+ this._syncTimer = lazy.setTimeout(() => this.syncWithServer(), 30000);
+ }
+ }
+
+ let uidsToSync = this.getStringValue("carddav.uidsToSync", "");
+ if (uidsToSync) {
+ this._uidsToSync = new Set(uidsToSync.split(" ").filter(Boolean));
+ this.setStringValue("carddav.uidsToSync", "");
+ log.debug(`Retrieved list of cards to sync: ${uidsToSync}`);
+ } else {
+ this._uidsToSync = new Set();
+ }
+
+ let hrefsToRemove = this.getStringValue("carddav.hrefsToRemove", "");
+ if (hrefsToRemove) {
+ this._hrefsToRemove = new Set(hrefsToRemove.split(" ").filter(Boolean));
+ this.setStringValue("carddav.hrefsToRemove", "");
+ log.debug(`Retrieved list of cards to remove: ${hrefsToRemove}`);
+ } else {
+ this._hrefsToRemove = new Set();
+ }
+ }
+ async cleanUp() {
+ await super.cleanUp();
+
+ if (this._syncTimer) {
+ lazy.clearInterval(this._syncTimer);
+ this._syncTimer = null;
+ }
+
+ if (this._uidsToSync.size) {
+ let uidsToSync = [...this._uidsToSync].join(" ");
+ this.setStringValue("carddav.uidsToSync", uidsToSync);
+ log.debug(`Stored list of cards to sync: ${uidsToSync}`);
+ }
+ if (this._hrefsToRemove.size) {
+ let hrefsToRemove = [...this._hrefsToRemove].join(" ");
+ this.setStringValue("carddav.hrefsToRemove", hrefsToRemove);
+ log.debug(`Stored list of cards to remove: ${hrefsToRemove}`);
+ }
+ }
+
+ get propertiesChromeURI() {
+ return "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml";
+ }
+ get dirType() {
+ return Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE;
+ }
+ get supportsMailingLists() {
+ return false;
+ }
+
+ modifyCard(card) {
+ // Well this is awkward. Because it's defined in nsIAbDirectory,
+ // modifyCard must not be async, but we need to do async operations.
+ let newCard = super.modifyCard(card);
+ this._modifyCard(newCard);
+ }
+ async _modifyCard(card) {
+ try {
+ await this._sendCardToServer(card);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ deleteCards(cards) {
+ super.deleteCards(cards);
+ this._deleteCards(cards);
+ }
+ async _deleteCards(cards) {
+ for (let card of cards) {
+ try {
+ await this._deleteCardFromServer(card);
+ } catch (ex) {
+ console.error(ex);
+ break;
+ }
+ }
+
+ for (let card of cards) {
+ this._uidsToSync.delete(card.UID);
+ }
+ }
+ dropCard(card, needToCopyCard) {
+ // Ideally, we'd not add the card until it was on the server, but we have
+ // to return newCard synchronously.
+ let newCard = super.dropCard(card, needToCopyCard);
+ this._sendCardToServer(newCard).catch(console.error);
+ return newCard;
+ }
+ addMailList() {
+ throw Components.Exception(
+ "CardDAVDirectory does not implement addMailList",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+ setIntValue(name, value) {
+ super.setIntValue(name, value);
+
+ // Capture changes to the sync interval from the UI.
+ if (name == "carddav.syncinterval") {
+ this._scheduleNextSync();
+ }
+ }
+
+ /** CardDAV specific */
+ _syncInProgress = false;
+ _syncTimer = null;
+
+ get _serverURL() {
+ return this.getStringValue("carddav.url", "");
+ }
+ get _syncToken() {
+ return this.getStringValue("carddav.token", "");
+ }
+ set _syncToken(value) {
+ this.setStringValue("carddav.token", value);
+ }
+
+ /**
+ * Wraps CardDAVUtils.makeRequest, resolving path this directory's server
+ * URL, and providing a mechanism to give a username and password specific
+ * to this directory.
+ *
+ * @param {string} path - A path relative to the server URL.
+ * @param {object} details - See CardDAVUtils.makeRequest.
+ * @returns {Promise<object>} - See CardDAVUtils.makeRequest.
+ */
+ async _makeRequest(path, details = {}) {
+ let serverURI = Services.io.newURI(this._serverURL);
+ let uri = serverURI.resolve(path);
+
+ if (!("_oAuth" in this)) {
+ if (lazy.OAuth2Providers.getHostnameDetails(serverURI.host)) {
+ this._oAuth = new lazy.OAuth2Module();
+ this._oAuth.initFromABDirectory(this, serverURI.host);
+ } else {
+ this._oAuth = null;
+ }
+ }
+ details.oAuth = this._oAuth;
+
+ let username = this.getStringValue("carddav.username", "");
+ let callbacks = new lazy.NotificationCallbacks(username);
+ details.callbacks = callbacks;
+
+ details.userContextId =
+ this._userContextId ?? lazy.CardDAVUtils.contextForUsername(username);
+
+ let response;
+ try {
+ Services.obs.notifyObservers(
+ this,
+ "addrbook-directory-request-start",
+ this.UID
+ );
+ response = await lazy.CardDAVUtils.makeRequest(uri, details);
+ } finally {
+ Services.obs.notifyObservers(
+ this,
+ "addrbook-directory-request-end",
+ this.UID
+ );
+ }
+ if (
+ details.expectedStatuses &&
+ !details.expectedStatuses.includes(response.status)
+ ) {
+ throw Components.Exception(
+ `Incorrect response from server: ${response.status} ${response.statusText}`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (callbacks.shouldSaveAuth) {
+ // The user was prompted for a username and password. Save the response.
+ this.setStringValue("carddav.username", callbacks.authInfo?.username);
+ callbacks.saveAuth();
+ }
+ return response;
+ }
+
+ /**
+ * Gets or creates the path for storing this card on the server. Cards that
+ * already exist on the server have this value in the _href property.
+ *
+ * @param {nsIAbCard} card
+ * @returns {string}
+ */
+ _getCardHref(card) {
+ let href = card.getProperty("_href", "");
+ if (href) {
+ return href;
+ }
+ href = Services.io.newURI(this._serverURL).resolve(`${card.UID}.vcf`);
+ return new URL(href).pathname;
+ }
+
+ _multigetRequest(hrefsToFetch) {
+ hrefsToFetch = hrefsToFetch.map(
+ href => ` <d:href>${xmlEncode(href)}</d:href>`
+ );
+ let data = `<card:addressbook-multiget ${NAMESPACE_STRING}>
+ <d:prop>
+ <d:getetag/>
+ <card:address-data/>
+ </d:prop>
+ ${hrefsToFetch.join("\n")}
+ </card:addressbook-multiget>`;
+
+ return this._makeRequest("", {
+ method: "REPORT",
+ body: data,
+ headers: {
+ Depth: 1,
+ },
+ expectedStatuses: [207],
+ });
+ }
+
+ /**
+ * Performs a multiget request for the provided hrefs, and adds each response
+ * to the directory, adding or modifying as necessary.
+ *
+ * @param {string[]} hrefsToFetch - The href of each card to be requested.
+ */
+ async _fetchAndStore(hrefsToFetch) {
+ if (hrefsToFetch.length == 0) {
+ return;
+ }
+
+ let response = await this._multigetRequest(hrefsToFetch);
+
+ // If this directory is set to read-only, the following operations would
+ // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only
+ // directory, so set this._overrideReadOnly to avoid the exception.
+ //
+ // Do not use await while it is set, and use a try/finally block to ensure
+ // it is cleared.
+
+ try {
+ this._overrideReadOnly = true;
+ for (let { href, properties } of this._readResponse(response.dom)) {
+ if (!properties) {
+ continue;
+ }
+
+ let etag = properties.querySelector("getetag")?.textContent;
+ let vCard = normalizeLineEndings(
+ properties.querySelector("address-data")?.textContent
+ );
+
+ let abCard = lazy.VCardUtils.vCardToAbCard(vCard);
+ abCard.setProperty("_etag", etag);
+ abCard.setProperty("_href", href);
+
+ if (!this.cards.has(abCard.UID)) {
+ super.dropCard(abCard, false);
+ } else if (this.loadCardProperties(abCard.UID).get("_etag") != etag) {
+ super.modifyCard(abCard);
+ }
+ }
+ } finally {
+ this._overrideReadOnly = false;
+ }
+ }
+
+ /**
+ * Reads a multistatus response, yielding once for each response element.
+ *
+ * @param {Document} dom - as returned by CardDAVUtils.makeRequest.
+ * @yields {object} - An object representing a single <response> element
+ * from the document:
+ * - href, the href of the object represented
+ * - notFound, if a 404 status applies to this response
+ * - properties, the <prop> element, if any, containing properties
+ * of the object represented
+ */
+ _readResponse = function* (dom) {
+ if (!dom || dom.documentElement.localName != "multistatus") {
+ throw Components.Exception(
+ `Expected a multistatus response, but didn't get one`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ for (let r of dom.querySelectorAll("response")) {
+ let response = {
+ href: r.querySelector("href")?.textContent,
+ };
+
+ let responseStatus = r.querySelector("response > status");
+ if (responseStatus?.textContent.startsWith("HTTP/1.1 404")) {
+ response.notFound = true;
+ yield response;
+ continue;
+ }
+
+ for (let p of r.querySelectorAll("response > propstat")) {
+ let status = p.querySelector("propstat > status").textContent;
+ if (status == "HTTP/1.1 200 OK") {
+ response.properties = p.querySelector("propstat > prop");
+ }
+ }
+
+ yield response;
+ }
+ };
+
+ /**
+ * Converts the card to a vCard and performs a PUT request to store it on the
+ * server. Then immediately performs a GET request ensuring the local copy
+ * matches the server copy. Stores the card in the database on success.
+ *
+ * @param {nsIAbCard} card
+ * @returns {boolean} true if the PUT request succeeded without conflict,
+ * false if there was a conflict.
+ * @throws if the server responded with anything other than a success or
+ * conflict status code.
+ */
+ async _sendCardToServer(card) {
+ let href = this._getCardHref(card);
+ let requestDetails = {
+ method: "PUT",
+ contentType: "text/vcard",
+ };
+
+ let vCard = card.getProperty("_vCard", "");
+ if (this._isGoogleCardDAV) {
+ // There must be an `N` property, even if empty.
+ let vCardProperties = lazy.VCardProperties.fromVCard(vCard);
+ if (!vCardProperties.getFirstEntry("n")) {
+ vCardProperties.addValue("n", ["", "", "", "", ""]);
+ }
+ requestDetails.body = vCardProperties.toVCard();
+ } else {
+ requestDetails.body = vCard;
+ }
+
+ let response;
+ try {
+ log.debug(`Sending ${href} to server.`);
+ response = await this._makeRequest(href, requestDetails);
+ } catch (ex) {
+ Services.obs.notifyObservers(this, "addrbook-directory-sync-failed");
+ this._uidsToSync.add(card.UID);
+ throw ex;
+ }
+
+ if (response.status >= 400) {
+ throw Components.Exception(
+ `Sending card to the server failed, response was ${response.status} ${response.statusText}`,
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ // At this point we *should* be able to make a simple GET request and
+ // store the response. But Google moves the data (fair enough) without
+ // telling us where it went (c'mon, really?). Fortunately a multiget
+ // request at the original location works.
+
+ response = await this._multigetRequest([href]);
+
+ for (let { href, properties } of this._readResponse(response.dom)) {
+ if (!properties) {
+ continue;
+ }
+
+ let etag = properties.querySelector("getetag")?.textContent;
+ let vCard = normalizeLineEndings(
+ properties.querySelector("address-data")?.textContent
+ );
+
+ let abCard = lazy.VCardUtils.vCardToAbCard(vCard);
+ abCard.setProperty("_etag", etag);
+ abCard.setProperty("_href", href);
+
+ if (abCard.UID == card.UID) {
+ super.modifyCard(abCard);
+ } else {
+ // Add a property so the UI can work out if it's still displaying the
+ // old card and respond appropriately.
+ abCard.setProperty("_originalUID", card.UID);
+ super.dropCard(abCard, false);
+ super.deleteCards([card]);
+ }
+ }
+ }
+
+ /**
+ * Deletes card from the server.
+ *
+ * @param {nsIAbCard|string} cardOrHRef
+ */
+ async _deleteCardFromServer(cardOrHRef) {
+ let href;
+ if (typeof cardOrHRef == "string") {
+ href = cardOrHRef;
+ } else {
+ href = cardOrHRef.getProperty("_href", "");
+ }
+ if (!href) {
+ return;
+ }
+
+ try {
+ log.debug(`Removing ${href} from server.`);
+ await this._makeRequest(href, { method: "DELETE" });
+ } catch (ex) {
+ Services.obs.notifyObservers(this, "addrbook-directory-sync-failed");
+ this._hrefsToRemove.add(href);
+ throw ex;
+ }
+ }
+
+ /**
+ * Set up a repeating timer for synchronisation with the server. The timer's
+ * interval is defined by pref, set it to 0 to disable sync'ing altogether.
+ */
+ _scheduleNextSync() {
+ if (this._syncTimer) {
+ lazy.clearInterval(this._syncTimer);
+ this._syncTimer = null;
+ }
+
+ let interval = this.getIntValue("carddav.syncinterval", 30);
+ if (interval <= 0) {
+ return;
+ }
+
+ this._syncTimer = lazy.setInterval(
+ () => this.syncWithServer(false),
+ interval * 60000
+ );
+ }
+
+ /**
+ * Get all cards on the server and add them to this directory.
+ *
+ * This is usually used for the initial population of a directory, but it
+ * can also be used for a complete re-sync.
+ */
+ async fetchAllFromServer() {
+ log.log("Fetching all cards from the server.");
+ this._syncInProgress = true;
+
+ let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <prop>
+ <resourcetype/>
+ <getetag/>
+ <cs:getctag/>
+ </prop>
+ </propfind>`;
+
+ let response = await this._makeRequest("", {
+ method: "PROPFIND",
+ body: data,
+ headers: {
+ Depth: 1,
+ },
+ expectedStatuses: [207],
+ });
+
+ // A map of all existing hrefs and etags. If the etag for an href matches
+ // what we already have, we won't fetch it.
+ let currentHrefs = new Map(
+ Array.from(this.cards.values(), c => [c.get("_href"), c.get("_etag")])
+ );
+
+ let hrefsToFetch = [];
+ for (let { href, properties } of this._readResponse(response.dom)) {
+ if (!properties || properties.querySelector("resourcetype collection")) {
+ continue;
+ }
+
+ let currentEtag = currentHrefs.get(href);
+ currentHrefs.delete(href);
+
+ let etag = properties.querySelector("getetag")?.textContent;
+ if (etag && currentEtag == etag) {
+ continue;
+ }
+
+ hrefsToFetch.push(href);
+ }
+
+ // Delete any existing cards we didn't see. They're not on the server so
+ // they shouldn't be on the client.
+ let cardsToDelete = [];
+ for (let href of currentHrefs.keys()) {
+ cardsToDelete.push(this.getCardFromProperty("_href", href, true));
+ }
+ if (cardsToDelete.length > 0) {
+ super.deleteCards(cardsToDelete);
+ }
+
+ // Fetch any cards we don't already have, or that have changed.
+ if (hrefsToFetch.length > 0) {
+ response = await this._multigetRequest(hrefsToFetch);
+
+ let abCards = [];
+
+ for (let { href, properties } of this._readResponse(response.dom)) {
+ if (!properties) {
+ continue;
+ }
+
+ let etag = properties.querySelector("getetag")?.textContent;
+ let vCard = normalizeLineEndings(
+ properties.querySelector("address-data")?.textContent
+ );
+
+ try {
+ let abCard = lazy.VCardUtils.vCardToAbCard(vCard);
+ abCard.setProperty("_etag", etag);
+ abCard.setProperty("_href", href);
+ abCards.push(abCard);
+ } catch (ex) {
+ log.error(`Error parsing: ${vCard}`);
+ console.error(ex);
+ }
+ }
+
+ await this.bulkAddCards(abCards);
+ }
+
+ await this._getSyncToken();
+
+ log.log("Sync with server completed successfully.");
+ Services.obs.notifyObservers(this, "addrbook-directory-synced");
+
+ this._scheduleNextSync();
+ this._syncInProgress = false;
+ }
+
+ /**
+ * Begin a sync operation. This function will decide which sync protocol to
+ * use based on the directory's configuration. It will also (re)start the
+ * timer for the next synchronisation unless told not to.
+ *
+ * @param {boolean} shouldResetTimer
+ */
+ async syncWithServer(shouldResetTimer = true) {
+ if (this._syncInProgress || !this._serverURL) {
+ return;
+ }
+
+ log.log("Performing sync with server.");
+ this._syncInProgress = true;
+
+ try {
+ // First perform all pending removals. We don't want to have deleted cards
+ // reappearing when we sync.
+ for (let href of this._hrefsToRemove) {
+ await this._deleteCardFromServer(href);
+ }
+ this._hrefsToRemove.clear();
+
+ // Now update any cards that were modified while not connected to the server.
+ for (let uid of this._uidsToSync) {
+ let card = this.getCard(uid);
+ // The card may no longer exist. It shouldn't still be listed to send,
+ // but it might be.
+ if (card) {
+ await this._sendCardToServer(card);
+ }
+ }
+ this._uidsToSync.clear();
+
+ if (this._syncToken) {
+ await this.updateAllFromServerV2();
+ } else {
+ await this.updateAllFromServerV1();
+ }
+ } catch (ex) {
+ log.error("Sync with server failed.");
+ throw ex;
+ } finally {
+ if (shouldResetTimer) {
+ this._scheduleNextSync();
+ }
+ this._syncInProgress = false;
+ }
+ }
+
+ /**
+ * Compares cards in the directory with cards on the server, and updates the
+ * directory to match what is on the server.
+ */
+ async updateAllFromServerV1() {
+ let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <prop>
+ <resourcetype/>
+ <getetag/>
+ <cs:getctag/>
+ </prop>
+ </propfind>`;
+
+ let response = await this._makeRequest("", {
+ method: "PROPFIND",
+ body: data,
+ headers: {
+ Depth: 1,
+ },
+ expectedStatuses: [207],
+ });
+
+ let hrefMap = new Map();
+ for (let { href, properties } of this._readResponse(response.dom)) {
+ if (
+ !properties ||
+ !properties.querySelector("resourcetype") ||
+ properties.querySelector("resourcetype collection")
+ ) {
+ continue;
+ }
+
+ let etag = properties.querySelector("getetag").textContent;
+ hrefMap.set(href, etag);
+ }
+
+ let cardMap = new Map();
+ let hrefsToFetch = [];
+ let cardsToDelete = [];
+ for (let card of this.childCards) {
+ let href = card.getProperty("_href", "");
+ let etag = card.getProperty("_etag", "");
+
+ if (!href || !etag) {
+ // Not sure how we got here. Ignore it.
+ continue;
+ }
+ cardMap.set(href, card);
+ if (hrefMap.has(href)) {
+ if (hrefMap.get(href) != etag) {
+ // The card was updated on server.
+ hrefsToFetch.push(href);
+ }
+ } else {
+ // The card doesn't exist on the server.
+ cardsToDelete.push(card);
+ }
+ }
+
+ for (let href of hrefMap.keys()) {
+ if (!cardMap.has(href)) {
+ // The card is new on the server.
+ hrefsToFetch.push(href);
+ }
+ }
+
+ // If this directory is set to read-only, the following operations would
+ // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only
+ // directory, so set this._overrideReadOnly to avoid the exception.
+ //
+ // Do not use await while it is set, and use a try/finally block to ensure
+ // it is cleared.
+
+ if (cardsToDelete.length > 0) {
+ this._overrideReadOnly = true;
+ try {
+ super.deleteCards(cardsToDelete);
+ } finally {
+ this._overrideReadOnly = false;
+ }
+ }
+
+ await this._fetchAndStore(hrefsToFetch);
+
+ log.log("Sync with server completed successfully.");
+ Services.obs.notifyObservers(this, "addrbook-directory-synced");
+ }
+
+ /**
+ * Retrieves the current sync token from the server.
+ *
+ * @see RFC 6578
+ */
+ async _getSyncToken() {
+ log.log("Fetching new sync token");
+
+ let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <prop>
+ <displayname/>
+ <cs:getctag/>
+ <sync-token/>
+ </prop>
+ </propfind>`;
+
+ let response = await this._makeRequest("", {
+ method: "PROPFIND",
+ body: data,
+ headers: {
+ Depth: 0,
+ },
+ });
+
+ if (response.status == 207) {
+ for (let { properties } of this._readResponse(response.dom)) {
+ let token = properties?.querySelector("prop sync-token");
+ if (token) {
+ this._syncToken = token.textContent;
+ return;
+ }
+ }
+ }
+
+ this._syncToken = "";
+ }
+
+ /**
+ * Gets a list of changes on the server since the last call to getSyncToken
+ * or updateAllFromServerV2, and updates the directory to match what is on
+ * the server.
+ *
+ * @see RFC 6578
+ */
+ async updateAllFromServerV2() {
+ let syncToken = this._syncToken;
+ if (!syncToken) {
+ throw new Components.Exception("No sync token", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let data = `<sync-collection xmlns="${
+ PREFIX_BINDINGS.d
+ }" ${NAMESPACE_STRING}>
+ <sync-token>${xmlEncode(syncToken)}</sync-token>
+ <sync-level>1</sync-level>
+ <prop>
+ <getetag/>
+ <card:address-data/>
+ </prop>
+ </sync-collection>`;
+
+ let response = await this._makeRequest("", {
+ method: "REPORT",
+ body: data,
+ headers: {
+ Depth: 1, // Only Google seems to need this.
+ },
+ expectedStatuses: [207, 400],
+ });
+
+ if (response.status == 400) {
+ log.warn(
+ `Server responded with: ${response.status} ${response.statusText}`
+ );
+ await this.fetchAllFromServer();
+ return;
+ }
+
+ let dom = response.dom;
+
+ // If this directory is set to read-only, the following operations would
+ // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only
+ // directory, so set this._overrideReadOnly to avoid the exception.
+ //
+ // Do not use await while it is set, and use a try/finally block to ensure
+ // it is cleared.
+
+ let hrefsToFetch = [];
+ try {
+ this._overrideReadOnly = true;
+ let cardsToDelete = [];
+ for (let { href, notFound, properties } of this._readResponse(dom)) {
+ let card = this.getCardFromProperty("_href", href, true);
+ if (notFound) {
+ if (card) {
+ cardsToDelete.push(card);
+ }
+ continue;
+ }
+ if (!properties) {
+ continue;
+ }
+
+ let etag = properties.querySelector("getetag")?.textContent;
+ if (!etag) {
+ continue;
+ }
+ let vCard = properties.querySelector("address-data")?.textContent;
+ if (!vCard) {
+ hrefsToFetch.push(href);
+ continue;
+ }
+ vCard = normalizeLineEndings(vCard);
+
+ let abCard = lazy.VCardUtils.vCardToAbCard(vCard);
+ abCard.setProperty("_etag", etag);
+ abCard.setProperty("_href", href);
+
+ if (card) {
+ if (card.getProperty("_etag", "") != etag) {
+ super.modifyCard(abCard);
+ }
+ } else {
+ super.dropCard(abCard, false);
+ }
+ }
+
+ if (cardsToDelete.length > 0) {
+ super.deleteCards(cardsToDelete);
+ }
+ } finally {
+ this._overrideReadOnly = false;
+ }
+
+ await this._fetchAndStore(hrefsToFetch);
+
+ this._syncToken = dom.querySelector("sync-token").textContent;
+
+ log.log("Sync with server completed successfully.");
+ Services.obs.notifyObservers(this, "addrbook-directory-synced");
+ }
+
+ static forFile(fileName) {
+ let directory = super.forFile(fileName);
+ if (directory instanceof CardDAVDirectory) {
+ return directory;
+ }
+ return undefined;
+ }
+}
+CardDAVDirectory.prototype.classID = Components.ID(
+ "{1fa9941a-07d5-4a6f-9673-15327fc2b9ab}"
+);
+
+/**
+ * Ensure that `string` always has Windows line-endings. Some functions,
+ * notably DOMParser.parseFromString, strip \r, but we want it because \r\n
+ * is a part of the vCard specification.
+ */
+function normalizeLineEndings(string) {
+ if (string.includes("\r\n")) {
+ return string;
+ }
+ return string.replace(/\n/g, "\r\n");
+}
+
+/**
+ * Encode special characters safely for XML.
+ */
+function xmlEncode(string) {
+ return string
+ .replace(/&/g, "&amp;")
+ .replace(/"/g, "&quot;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;");
+}
diff --git a/comm/mailnews/addrbook/modules/CardDAVUtils.jsm b/comm/mailnews/addrbook/modules/CardDAVUtils.jsm
new file mode 100644
index 0000000000..d45b5a9b42
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/CardDAVUtils.jsm
@@ -0,0 +1,718 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CardDAVUtils", "NotificationCallbacks"];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CardDAVDirectory: "resource:///modules/CardDAVDirectory.jsm",
+ MsgAuthPrompt: "resource:///modules/MsgAsyncPrompter.jsm",
+ OAuth2: "resource:///modules/OAuth2.jsm",
+ OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
+});
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "nssErrorsService",
+ "@mozilla.org/nss_errors_service;1",
+ "nsINSSErrorsService"
+);
+
+// Use presets only where DNS discovery fails. Set to null to prevent
+// auto-fill completely for a domain.
+const PRESETS = {
+ // For testing purposes.
+ "bad.invalid": null,
+ // Google responds correctly but the provided address returns 404.
+ "gmail.com": "https://www.googleapis.com",
+ "googlemail.com": "https://www.googleapis.com",
+ // For testing purposes.
+ "test.invalid": "http://localhost:9999",
+ // Yahoo! OAuth is not working yet.
+ "yahoo.com": null,
+};
+
+// At least one of these ACL privileges must be present to consider an address
+// book writable.
+const writePrivs = ["write", "write-properties", "write-content", "all"];
+
+// At least one of these ACL privileges must be present to consider an address
+// book readable.
+const readPrivs = ["read", "all"];
+
+var CardDAVUtils = {
+ _contextMap: new Map(),
+
+ /**
+ * Returns the id of a unique private context for each username. When the
+ * userContextId is set on a principal, this allows the use of multiple
+ * usernames on the same server without the networking code causing issues.
+ *
+ * @param {string} username
+ * @returns {integer}
+ */
+ contextForUsername(username) {
+ if (username && CardDAVUtils._contextMap.has(username)) {
+ return CardDAVUtils._contextMap.get(username);
+ }
+
+ // This could be any 32-bit integer, as long as it isn't already in use.
+ let nextId = 25000 + CardDAVUtils._contextMap.size;
+ lazy.ContextualIdentityService.remove(nextId);
+ CardDAVUtils._contextMap.set(username, nextId);
+ return nextId;
+ },
+
+ /**
+ * Make an HTTP request. If the request needs a username and password, the
+ * given authPrompt is called.
+ *
+ * @param {string} uri
+ * @param {object} details
+ * @param {string} [details.method]
+ * @param {object} [details.headers]
+ * @param {string} [details.body]
+ * @param {string} [details.contentType]
+ * @param {msgIOAuth2Module} [details.oAuth] - If this is present the
+ * request will use OAuth2 authorization.
+ * @param {NotificationCallbacks} [details.callbacks] - Handles usernames
+ * and passwords for this request.
+ * @param {integer} [details.userContextId] - See _contextForUsername.
+ *
+ * @returns {Promise<object>} - Resolves to an object with getters for:
+ * - status, the HTTP response code
+ * - statusText, the HTTP response message
+ * - text, the returned data as a String
+ * - dom, the returned data parsed into a Document
+ */
+ async makeRequest(uri, details) {
+ if (typeof uri == "string") {
+ uri = Services.io.newURI(uri);
+ }
+ let {
+ method = "GET",
+ headers = {},
+ body = null,
+ contentType = "text/xml",
+ oAuth = null,
+ callbacks = new NotificationCallbacks(),
+ userContextId = Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID,
+ } = details;
+ headers["Content-Type"] = contentType;
+ if (oAuth) {
+ headers.Authorization = await new Promise((resolve, reject) => {
+ oAuth.connect(true, {
+ onSuccess(token) {
+ resolve(
+ // `token` is a base64-encoded string for SASL XOAUTH2. That is
+ // not what we want, extract just the Bearer token part.
+ // (See OAuth2Module.connect.)
+ atob(token).split("\x01")[1].slice(5)
+ );
+ },
+ onFailure: reject,
+ });
+ });
+ }
+
+ return new Promise((resolve, reject) => {
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ { userContextId }
+ );
+
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ principal,
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ for (let [name, value] of Object.entries(headers)) {
+ channel.setRequestHeader(name, value, false);
+ }
+ if (body !== null) {
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setUTF8Data(body, body.length);
+
+ channel.QueryInterface(Ci.nsIUploadChannel);
+ channel.setUploadStream(stream, contentType, -1);
+ }
+ channel.requestMethod = method; // Must go after setUploadStream.
+ channel.notificationCallbacks = callbacks;
+
+ let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+ listener.init({
+ onStreamComplete(loader, context, status, resultLength, result) {
+ let finalChannel = loader.request.QueryInterface(Ci.nsIHttpChannel);
+ if (!Components.isSuccessCode(status)) {
+ let isCertError = false;
+ try {
+ let errorType = lazy.nssErrorsService.getErrorClass(status);
+ if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ isCertError = true;
+ }
+ } catch (ex) {
+ // nsINSSErrorsService.getErrorClass throws if given a non-TLS,
+ // non-cert error, so ignore this.
+ }
+
+ if (isCertError && finalChannel.securityInfo) {
+ let secInfo = finalChannel.securityInfo.QueryInterface(
+ Ci.nsITransportSecurityInfo
+ );
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location: finalChannel.originalURI.displayHostPort,
+ };
+ Services.wm
+ .getMostRecentWindow("")
+ .openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+
+ if (params.exceptionAdded) {
+ // Try again now that an exception has been added.
+ CardDAVUtils.makeRequest(uri, details).then(resolve, reject);
+ return;
+ }
+ }
+
+ reject(new Components.Exception("Connection failure", status));
+ return;
+ }
+ if (finalChannel.responseStatus == 401) {
+ // We tried to authenticate, but failed.
+ reject(
+ new Components.Exception(
+ "Authorization failure",
+ Cr.NS_ERROR_FAILURE
+ )
+ );
+ return;
+ }
+ resolve({
+ get status() {
+ return finalChannel.responseStatus;
+ },
+ get statusText() {
+ return finalChannel.responseStatusText;
+ },
+ get text() {
+ return new TextDecoder().decode(Uint8Array.from(result));
+ },
+ get dom() {
+ if (this._dom === undefined) {
+ try {
+ this._dom = new DOMParser().parseFromString(
+ this.text,
+ "text/xml"
+ );
+ } catch (ex) {
+ this._dom = null;
+ }
+ }
+ return this._dom;
+ },
+ });
+ },
+ });
+ channel.asyncOpen(listener, channel);
+ });
+ },
+
+ /**
+ * @typedef foundBook
+ * @property {URL} url - The address for this address book.
+ * @param {string} name - The name of this address book on the server.
+ * @param {Function} create - A callback to add this address book locally.
+ */
+
+ /**
+ * Uses DNS look-ups and magic URLs to detects CardDAV address books.
+ *
+ * @param {string} username - Username for the server at `location`.
+ * @param {string} [password] - If not given, the user will be prompted.
+ * @param {string} location - The URL of a server to query.
+ * @param {boolean} [forcePrompt=false] - If true, the user will be shown a
+ * login prompt even if `password` is specified. If false, the user will
+ * be shown a prompt only if `password` is not specified and no saved
+ * password matches `username` and `location`.
+ * @returns {foundBook[]} - An array of found address books.
+ */
+ async detectAddressBooks(username, password, location, forcePrompt = false) {
+ let log = console.createInstance({
+ prefix: "carddav.setup",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "carddav.setup.loglevel",
+ });
+
+ // Use a unique context for each attempt, so a prompt is always shown.
+ let userContextId = Math.floor(Date.now() / 1000);
+
+ let url = new URL(location);
+
+ if (url.hostname in PRESETS) {
+ if (PRESETS[url.hostname] === null) {
+ throw new Components.Exception(
+ `${url} is known to be incompatible`,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ log.log(`Using preset URL for ${url}`);
+ url = new URL(PRESETS[url.hostname]);
+ }
+
+ if (url.pathname == "/" && !(url.hostname in PRESETS)) {
+ log.log(`Looking up DNS record for ${url.hostname}`);
+ let domain = `_carddavs._tcp.${url.hostname}`;
+ let srvRecords = await DNS.srv(domain);
+ srvRecords.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
+
+ if (srvRecords[0]) {
+ url = new URL(`https://${srvRecords[0].host}:${srvRecords[0].port}`);
+ log.log(`Found a DNS SRV record pointing to ${url.host}`);
+
+ let txtRecords = await DNS.txt(domain);
+ txtRecords.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
+ txtRecords = txtRecords.filter(result =>
+ result.data.startsWith("path=")
+ );
+
+ if (txtRecords[0]) {
+ url.pathname = txtRecords[0].data.substr(5);
+ log.log(`Found a DNS TXT record pointing to ${url.href}`);
+ }
+ } else {
+ let mxRecords = await DNS.mx(url.hostname);
+ if (mxRecords.some(r => /\bgoogle\.com$/.test(r.host))) {
+ log.log(
+ `Found a DNS MX record for Google, using preset URL for ${url}`
+ );
+ url = new URL(PRESETS["gmail.com"]);
+ }
+ }
+ }
+
+ let oAuth = null;
+ let callbacks = new NotificationCallbacks(username, password, forcePrompt);
+
+ let requestParams = {
+ method: "PROPFIND",
+ callbacks,
+ userContextId,
+ headers: {
+ Depth: 0,
+ },
+ body: `<propfind xmlns="DAV:">
+ <prop>
+ <resourcetype/>
+ <displayname/>
+ <current-user-principal/>
+ <current-user-privilege-set/>
+ </prop>
+ </propfind>`,
+ };
+
+ let details = lazy.OAuth2Providers.getHostnameDetails(url.host);
+ if (details) {
+ let [issuer, scope] = details;
+ let issuerDetails = lazy.OAuth2Providers.getIssuerDetails(issuer);
+
+ oAuth = new lazy.OAuth2(scope, issuerDetails);
+ oAuth._isNew = true;
+ oAuth._loginOrigin = `oauth://${issuer}`;
+ oAuth._scope = scope;
+ for (let login of Services.logins.findLogins(
+ oAuth._loginOrigin,
+ null,
+ ""
+ )) {
+ if (
+ login.username == username &&
+ (login.httpRealm == scope ||
+ login.httpRealm.split(" ").includes(scope))
+ ) {
+ oAuth.refreshToken = login.password;
+ oAuth._isNew = false;
+ break;
+ }
+ }
+
+ if (username) {
+ oAuth.extraAuthParams = [["login_hint", username]];
+ }
+
+ // Implement msgIOAuth2Module.connect, which CardDAVUtils.makeRequest expects.
+ requestParams.oAuth = {
+ QueryInterface: ChromeUtils.generateQI(["msgIOAuth2Module"]),
+ connect(withUI, listener) {
+ oAuth.connect(
+ () =>
+ listener.onSuccess(
+ // String format based on what OAuth2Module has.
+ btoa(`\x01auth=Bearer ${oAuth.accessToken}`)
+ ),
+ () => listener.onFailure(Cr.NS_ERROR_ABORT),
+ withUI,
+ false
+ );
+ },
+ };
+ }
+
+ let response;
+ let triedURLs = new Set();
+ async function tryURL(url) {
+ if (triedURLs.has(url)) {
+ return;
+ }
+ triedURLs.add(url);
+
+ log.log(`Attempting to connect to ${url}`);
+ response = await CardDAVUtils.makeRequest(url, requestParams);
+ if (response.status == 207 && response.dom) {
+ log.log(`${url} ... success`);
+ } else {
+ log.log(
+ `${url} ... response was "${response.status} ${response.statusText}"`
+ );
+ response = null;
+ }
+ }
+
+ if (url.pathname != "/") {
+ // This might be the full URL of an address book.
+ await tryURL(url.href);
+ if (
+ !response?.dom?.querySelector("resourcetype addressbook") &&
+ !response?.dom?.querySelector("current-user-principal href")
+ ) {
+ response = null;
+ }
+ }
+ if (!response || !response.dom) {
+ // Auto-discovery using a magic URL.
+ requestParams.body = `<propfind xmlns="DAV:">
+ <prop>
+ <current-user-principal/>
+ </prop>
+ </propfind>`;
+ await tryURL(`${url.origin}/.well-known/carddav`);
+ }
+ if (!response) {
+ // Auto-discovery at the root of the domain.
+ await tryURL(`${url.origin}/`);
+ }
+ if (!response) {
+ // We've run out of ideas.
+ throw new Components.Exception(
+ "Address book discovery failed",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ if (!response.dom.querySelector("resourcetype addressbook")) {
+ let userPrincipal = response.dom.querySelector(
+ "current-user-principal href"
+ );
+ if (!userPrincipal) {
+ // We've run out of ideas.
+ throw new Components.Exception(
+ "Address book discovery failed",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ // Steps two and three of auto-discovery. If the entered URL did point
+ // to an address book, we won't get here.
+ url = new URL(userPrincipal.textContent, url);
+ requestParams.body = `<propfind xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+ <prop>
+ <card:addressbook-home-set/>
+ </prop>
+ </propfind>`;
+ await tryURL(url.href);
+
+ url = new URL(
+ response.dom.querySelector("addressbook-home-set href").textContent,
+ url
+ );
+ requestParams.headers.Depth = 1;
+ requestParams.body = `<propfind xmlns="DAV:">
+ <prop>
+ <resourcetype/>
+ <displayname/>
+ <current-user-privilege-set/>
+ </prop>
+ </propfind>`;
+ await tryURL(url.href);
+ }
+
+ // Find any directories in the response.
+
+ let foundBooks = [];
+ for (let r of response.dom.querySelectorAll("response")) {
+ if (r.querySelector("status")?.textContent != "HTTP/1.1 200 OK") {
+ continue;
+ }
+ if (!r.querySelector("resourcetype addressbook")) {
+ continue;
+ }
+
+ // If the server provided ACL information, skip address books that we do
+ // not have read privileges to.
+ let privNode = r.querySelector("current-user-privilege-set");
+ let isWritable = false;
+ let isReadable = false;
+ if (privNode) {
+ let privs = Array.from(privNode.querySelectorAll("privilege > *")).map(
+ node => node.localName
+ );
+
+ isWritable = writePrivs.some(priv => privs.includes(priv));
+ isReadable = readPrivs.some(priv => privs.includes(priv));
+
+ if (!isWritable && !isReadable) {
+ continue;
+ }
+ }
+
+ url = new URL(r.querySelector("href").textContent, url);
+ let name = r.querySelector("displayname")?.textContent;
+ if (!name) {
+ // The server didn't give a name, let's make one from the path.
+ name = url.pathname.replace(/\/$/, "").split("/").slice(-1)[0];
+ }
+ if (!name) {
+ // That didn't work either, use the hostname.
+ name = url.hostname;
+ }
+ foundBooks.push({
+ url,
+ name,
+ create() {
+ let dirPrefId = MailServices.ab.newAddressBook(
+ this.name,
+ null,
+ Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE,
+ null
+ );
+ let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+ book.setStringValue("carddav.url", this.url);
+
+ if (!isWritable && isReadable) {
+ book.setBoolValue("readOnly", true);
+ }
+
+ if (oAuth) {
+ if (oAuth._isNew) {
+ log.log(`Saving refresh token for ${username}`);
+ let newLoginInfo = Cc[
+ "@mozilla.org/login-manager/loginInfo;1"
+ ].createInstance(Ci.nsILoginInfo);
+ newLoginInfo.init(
+ oAuth._loginOrigin,
+ null,
+ oAuth._scope,
+ username,
+ oAuth.refreshToken,
+ "",
+ ""
+ );
+ try {
+ Services.logins.addLogin(newLoginInfo);
+ } catch (ex) {
+ console.error(ex);
+ }
+ oAuth._isNew = false;
+ }
+ book.setStringValue("carddav.username", username);
+ } else if (callbacks.authInfo?.username) {
+ log.log(`Saving login info for ${callbacks.authInfo.username}`);
+ book.setStringValue(
+ "carddav.username",
+ callbacks.authInfo.username
+ );
+ callbacks.saveAuth();
+ }
+
+ let dir = lazy.CardDAVDirectory.forFile(book.fileName);
+ // Pass the context to the created address book. This prevents asking
+ // for a username/password again in the case that we didn't save it.
+ // The user won't be prompted again until Thunderbird is restarted.
+ dir._userContextId = userContextId;
+ dir.fetchAllFromServer();
+
+ return dir;
+ },
+ });
+ }
+ return foundBooks;
+ },
+};
+
+/**
+ * Passed to nsIChannel.notificationCallbacks in CardDAVDirectory.makeRequest.
+ * This handles HTTP authentication, prompting the user if necessary. It also
+ * ensures important headers are copied from one channel to another if a
+ * redirection occurs.
+ *
+ * @implements {nsIInterfaceRequestor}
+ * @implements {nsIAuthPrompt2}
+ * @implements {nsIChannelEventSink}
+ */
+class NotificationCallbacks {
+ /**
+ * @param {string} [username] - Used to pre-fill any auth dialogs.
+ * @param {string} [password] - Used to pre-fill any auth dialogs.
+ * @param {boolean} [forcePrompt] - Skips checking the password manager for
+ * a password, even if username is given. The user will be prompted.
+ */
+ constructor(username, password, forcePrompt) {
+ this.username = username;
+ this.password = password;
+ this.forcePrompt = forcePrompt;
+ }
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIAuthPrompt2",
+ "nsIChannelEventSink",
+ ]);
+ getInterface = ChromeUtils.generateQI([
+ "nsIAuthPrompt2",
+ "nsIChannelEventSink",
+ ]);
+ promptAuth(channel, level, authInfo) {
+ if (authInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) {
+ return false;
+ }
+
+ this.origin = channel.URI.prePath;
+ this.authInfo = authInfo;
+
+ if (!this.forcePrompt) {
+ if (this.username && this.password) {
+ authInfo.username = this.username;
+ authInfo.password = this.password;
+ this.shouldSaveAuth = true;
+ return true;
+ }
+
+ let logins = Services.logins.findLogins(channel.URI.prePath, null, "");
+ for (let l of logins) {
+ if (l.username == this.username) {
+ authInfo.username = l.username;
+ authInfo.password = l.password;
+ return true;
+ }
+ }
+ }
+
+ authInfo.username = this.username;
+ authInfo.password = this.password;
+
+ let savePasswordLabel = null;
+ let savePassword = {};
+ if (Services.prefs.getBoolPref("signon.rememberSignons", true)) {
+ savePasswordLabel = Services.strings
+ .createBundle("chrome://passwordmgr/locale/passwordmgr.properties")
+ .GetStringFromName("rememberPassword");
+ savePassword.value = true;
+ }
+
+ let returnValue = new lazy.MsgAuthPrompt().promptAuth(
+ channel,
+ level,
+ authInfo,
+ savePasswordLabel,
+ savePassword
+ );
+ if (returnValue) {
+ this.shouldSaveAuth = savePassword.value;
+ }
+ return returnValue;
+ }
+ saveAuth() {
+ if (this.shouldSaveAuth) {
+ let newLoginInfo = Cc[
+ "@mozilla.org/login-manager/loginInfo;1"
+ ].createInstance(Ci.nsILoginInfo);
+ newLoginInfo.init(
+ this.origin,
+ null,
+ this.authInfo.realm,
+ this.authInfo.username,
+ this.authInfo.password,
+ "",
+ ""
+ );
+ try {
+ Services.logins.addLogin(newLoginInfo);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+ asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+ /**
+ * Copy the given header from the old channel to the new one, ignoring missing headers
+ *
+ * @param {string} header - The header to copy
+ */
+ function copyHeader(header) {
+ try {
+ let headerValue = oldChannel.getRequestHeader(header);
+ if (headerValue) {
+ newChannel.setRequestHeader(header, headerValue, false);
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ // The header could possibly not be available, ignore that
+ // case but throw otherwise
+ throw e;
+ }
+ }
+ }
+
+ // Make sure we can get/set headers on both channels.
+ newChannel.QueryInterface(Ci.nsIHttpChannel);
+ oldChannel.QueryInterface(Ci.nsIHttpChannel);
+
+ // If any other header is used, it should be added here. We might want
+ // to just copy all headers over to the new channel.
+ copyHeader("Authorization");
+ copyHeader("Depth");
+ copyHeader("Originator");
+ copyHeader("Recipient");
+ copyHeader("If-None-Match");
+ copyHeader("If-Match");
+
+ newChannel.requestMethod = oldChannel.requestMethod;
+ callback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/LDAPClient.jsm b/comm/mailnews/addrbook/modules/LDAPClient.jsm
new file mode 100644
index 0000000000..e26b7b5fce
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPClient.jsm
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPClient"];
+
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+var { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+var {
+ AbandonRequest,
+ BindRequest,
+ UnbindRequest,
+ SearchRequest,
+ LDAPResponse,
+} = ChromeUtils.import("resource:///modules/LDAPMessage.jsm");
+
+class LDAPClient {
+ /**
+ * @param {string} host - The LDAP server host.
+ * @param {number} port - The LDAP server port.
+ * @param {boolean} useSecureTransport - Whether to use TLS connection.
+ */
+ constructor(host, port, useSecureTransport) {
+ this.onOpen = () => {};
+ this.onError = () => {};
+
+ this._host = host;
+ this._port = port;
+ this._useSecureTransport = useSecureTransport;
+
+ this._messageId = 1;
+ this._callbackMap = new Map();
+
+ this._logger = console.createInstance({
+ prefix: "mailnews.ldap",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mailnews.ldap.loglevel",
+ });
+
+ this._dataEventsQueue = [];
+ }
+
+ connect() {
+ let hostname = this._host.toLowerCase();
+ this._logger.debug(
+ `Connecting to ${
+ this._useSecureTransport ? "ldaps" : "ldap"
+ }://${hostname}:${this._port}`
+ );
+ this._socket = new TCPSocket(hostname, this._port, {
+ binaryType: "arraybuffer",
+ useSecureTransport: this._useSecureTransport,
+ });
+ this._socket.onopen = this._onOpen;
+ this._socket.onerror = this._onError;
+ }
+
+ /**
+ * Send a simple bind request to the server.
+ *
+ * @param {string} dn - The name to bind.
+ * @param {string} password - The password.
+ * @param {Function} callback - Callback function when receiving BindResponse.
+ * @returns {number} The id of the sent request.
+ */
+ bind(dn, password, callback) {
+ this._logger.debug(`Binding ${dn}`);
+ let req = new BindRequest(dn || "", password || "");
+ return this._send(req, callback);
+ }
+
+ /**
+ * Send a SASL bind request to the server.
+ *
+ * @param {string} service - The service host name to bind.
+ * @param {string} mechanism - The SASL mechanism to use, e.g. GSSAPI.
+ * @param {string} authModuleType - The auth module type, @see nsIMailAuthModule.
+ * @param {ArrayBuffer} serverCredentials - The challenge token returned from
+ * the server, which must be used to generate a new request token. Or
+ * undefined for the first request.
+ * @param {Function} callback - Callback function when receiving BindResponse.
+ * @returns {number} The id of the sent request.
+ */
+ saslBind(service, mechanism, authModuleType, serverCredentials, callback) {
+ this._logger.debug(`Binding ${service} using ${mechanism}`);
+ if (!this._authModule || this._authModuleType != authModuleType) {
+ this._authModuleType = authModuleType;
+ this._authModule = Cc["@mozilla.org/mail/auth-module;1"].createInstance(
+ Ci.nsIMailAuthModule
+ );
+ this._authModule.init(
+ authModuleType,
+ service,
+ 0, // nsIAuthModule::REQ_DEFAULT
+ null, // domain
+ null, // username
+ null // password
+ );
+ }
+ // getNextToken expects a base64 string.
+ let token = this._authModule.getNextToken(
+ serverCredentials
+ ? btoa(
+ CommonUtils.arrayBufferToByteString(
+ new Uint8Array(serverCredentials)
+ )
+ )
+ : ""
+ );
+ // token is a base64 string, convert it to Uint8Array.
+ let credentials = CommonUtils.byteStringToArrayBuffer(atob(token));
+ let req = new BindRequest("", "", { mechanism, credentials });
+ return this._send(req, callback);
+ }
+
+ /**
+ * Send an unbind request to the server.
+ */
+ unbind() {
+ return this._send(new UnbindRequest(), () => this._socket.close());
+ }
+
+ /**
+ * Send a search request to the server.
+ *
+ * @param {string} dn - The name to search.
+ * @param {number} scope - The scope to search.
+ * @param {string} filter - The filter string.
+ * @param {string} attributes - Attributes to include in the search result.
+ * @param {number} timeout - The seconds to wait.
+ * @param {number} limit - Maximum number of entries to return.
+ * @param {Function} callback - Callback function when receiving search responses.
+ * @returns {number} The id of the sent request.
+ */
+ search(dn, scope, filter, attributes, timeout, limit, callback) {
+ this._logger.debug(`Searching dn="${dn}" filter="${filter}"`);
+ let req = new SearchRequest(dn, scope, filter, attributes, timeout, limit);
+ return this._send(req, callback);
+ }
+
+ /**
+ * Send an abandon request to the server.
+ *
+ * @param {number} messageId - The id of the message to abandon.
+ */
+ abandon(messageId) {
+ this._logger.debug(`Abandoning ${messageId}`);
+ this._callbackMap.delete(messageId);
+ let req = new AbandonRequest(messageId);
+ this._send(req);
+ }
+
+ /**
+ * The open event handler.
+ */
+ _onOpen = () => {
+ this._logger.debug("Connected");
+ this._socket.ondata = this._onData;
+ this._socket.onclose = this._onClose;
+ this.onOpen();
+ };
+
+ /**
+ * The data event handler. Server may send multiple data events after a
+ * search, we want to handle them asynchonosly and in sequence.
+ *
+ * @param {TCPSocketEvent} event - The data event.
+ */
+ _onData = async event => {
+ if (this._processingData) {
+ this._dataEventsQueue.push(event);
+ return;
+ }
+ this._processingData = true;
+ let data = event.data;
+ if (this._buffer) {
+ // Concatenate left over data from the last event with the new data.
+ let arr = new Uint8Array(this._buffer.byteLength + data.byteLength);
+ arr.set(new Uint8Array(this._buffer));
+ arr.set(new Uint8Array(data), this._buffer.byteLength);
+ data = arr.buffer;
+ this._buffer = null;
+ }
+ let i = 0;
+ // The payload can contain multiple messages, parse it to the end.
+ while (data.byteLength) {
+ i++;
+ let res;
+ try {
+ res = LDAPResponse.fromBER(data);
+ if (typeof res == "number") {
+ data = data.slice(res);
+ continue;
+ }
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_CANNOT_CONVERT_DATA) {
+ // The remaining data doesn't form a valid LDAP message, save it for
+ // the next round.
+ this._buffer = data;
+ this._handleNextDataEvent();
+ return;
+ }
+ throw e;
+ }
+ this._logger.debug(
+ `S: [${res.messageId}] ${res.constructor.name}`,
+ res.result.resultCode >= 0
+ ? `resultCode=${res.result.resultCode} message="${res.result.diagnosticMessage}"`
+ : ""
+ );
+ if (res.constructor.name == "SearchResultReference") {
+ this._logger.debug("References=", res.result);
+ }
+ let callback = this._callbackMap.get(res.messageId);
+ if (callback) {
+ callback(res);
+ if (
+ !["SearchResultEntry", "SearchResultReference"].includes(
+ res.constructor.name
+ )
+ ) {
+ this._callbackMap.delete(res.messageId);
+ }
+ }
+ data = data.slice(res.byteLength);
+ if (i % 10 == 0) {
+ // Prevent blocking the main thread for too long.
+ await new Promise(resolve => setTimeout(resolve));
+ }
+ }
+ this._handleNextDataEvent();
+ };
+
+ /**
+ * Process a queued data event, if there is any.
+ */
+ _handleNextDataEvent() {
+ this._processingData = false;
+ let next = this._dataEventsQueue.shift();
+ if (next) {
+ this._onData(next);
+ }
+ }
+
+ /**
+ * The close event handler.
+ */
+ _onClose = () => {
+ this._logger.debug("Connection closed");
+ };
+
+ /**
+ * The error event handler.
+ *
+ * @param {TCPSocketErrorEvent} event - The error event.
+ */
+ _onError = async event => {
+ this._logger.error(event);
+ this._socket.close();
+ this.onError(
+ event.errorCode,
+ await event.target.transport?.tlsSocketControl?.asyncGetSecurityInfo()
+ );
+ };
+
+ /**
+ * Send a message to the server.
+ *
+ * @param {LDAPMessage} msg - The message to send.
+ * @param {Function} callback - Callback function when receiving server responses.
+ * @returns {number} The id of the sent message.
+ */
+ _send(msg, callback) {
+ if (callback) {
+ this._callbackMap.set(this._messageId, callback);
+ }
+ this._logger.debug(`C: [${this._messageId}] ${msg.constructor.name}`);
+ this._socket.send(msg.toBER(this._messageId));
+ return this._messageId++;
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/LDAPConnection.jsm b/comm/mailnews/addrbook/modules/LDAPConnection.jsm
new file mode 100644
index 0000000000..f9a66f47a7
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPConnection.jsm
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPConnection"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "LDAPClient",
+ "resource:///modules/LDAPClient.jsm"
+);
+
+/**
+ * A module to manage LDAP connection.
+ *
+ * @implements {nsILDAPConnection}
+ */
+class LDAPConnection {
+ QueryInterface = ChromeUtils.generateQI(["nsILDAPConnection"]);
+
+ get bindName() {
+ return this._bindName;
+ }
+
+ init(url, bindName, listener, closure, version) {
+ let useSecureTransport = url.scheme == "ldaps";
+ let port = url.port;
+ if (port == -1) {
+ // -1 corresponds to the protocol's default port.
+ port = useSecureTransport ? 636 : 389;
+ }
+ this.client = new lazy.LDAPClient(url.host, port, useSecureTransport);
+ this._url = url;
+ this._bindName = bindName;
+ this.client.onOpen = () => {
+ listener.onLDAPInit();
+ };
+ this.client.onError = (status, secInfo) => {
+ listener.onLDAPError(status, secInfo, `${url.host}:${port}`);
+ };
+ this.client.connect();
+ }
+
+ get wrappedJSObject() {
+ return this;
+ }
+}
+
+LDAPConnection.prototype.classID = Components.ID(
+ "{f87b71b5-2a0f-4b37-8e4f-3c899f6b8432}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPDirectory.jsm b/comm/mailnews/addrbook/modules/LDAPDirectory.jsm
new file mode 100644
index 0000000000..758cde2ed9
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPDirectory.jsm
@@ -0,0 +1,230 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPDirectory"];
+
+const { AddrBookDirectory } = ChromeUtils.import(
+ "resource:///modules/AddrBookDirectory.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ QueryStringToExpression: "resource:///modules/QueryStringToExpression.jsm",
+});
+
+/**
+ * @implements {nsIAbLDAPDirectory}
+ * @implements {nsIAbDirectory}
+ */
+
+class LDAPDirectory extends AddrBookDirectory {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIAbLDAPDirectory",
+ "nsIAbDirectory",
+ ]);
+
+ init(uri) {
+ this._uri = uri;
+
+ let searchIndex = uri.indexOf("?");
+ this._dirPrefId = uri.substr(
+ "moz-abldapdirectory://".length,
+ searchIndex == -1 ? undefined : searchIndex
+ );
+
+ super.init(uri);
+ }
+
+ get readOnly() {
+ return true;
+ }
+
+ get isRemote() {
+ return true;
+ }
+
+ get isSecure() {
+ return this.lDAPURL.scheme == "ldaps";
+ }
+
+ get propertiesChromeURI() {
+ return "chrome://messenger/content/addressbook/pref-directory-add.xhtml";
+ }
+
+ get dirType() {
+ return Ci.nsIAbManager.LDAP_DIRECTORY_TYPE;
+ }
+
+ get replicationFileName() {
+ return this.getStringValue("filename");
+ }
+
+ set replicationFileName(value) {
+ this.setStringValue("filename", value);
+ }
+
+ get replicationFile() {
+ return lazy.FileUtils.getFile("ProfD", [this.replicationFileName]);
+ }
+
+ get protocolVersion() {
+ return this.getStringValue("protocolVersion", "3") == "3"
+ ? Ci.nsILDAPConnection.VERSION3
+ : Ci.nsILDAPConnection.VERSION2;
+ }
+
+ set protocolVersion(value) {
+ this.setStringValue(
+ "protocolVersion",
+ value == Ci.nsILDAPConnection.VERSION3 ? "3" : "2"
+ );
+ }
+
+ get saslMechanism() {
+ return this.getStringValue("auth.saslmech");
+ }
+
+ set saslMechanism(value) {
+ this.setStringValue("auth.saslmech", value);
+ }
+
+ get authDn() {
+ return this.getStringValue("auth.dn");
+ }
+
+ set authDn(value) {
+ this.setStringValue("auth.dn", value);
+ }
+
+ get maxHits() {
+ return this.getIntValue("maxHits", 100);
+ }
+
+ set maxHits(value) {
+ this.setIntValue("maxHits", value);
+ }
+
+ get attributeMap() {
+ let mapSvc = Cc[
+ "@mozilla.org/addressbook/ldap-attribute-map-service;1"
+ ].createInstance(Ci.nsIAbLDAPAttributeMapService);
+ return mapSvc.getMapForPrefBranch(this._dirPrefId);
+ }
+
+ get lDAPURL() {
+ let uri = this.getStringValue("uri") || `ldap://${this._uri.slice(22)}`;
+ return Services.io.newURI(uri).QueryInterface(Ci.nsILDAPURL);
+ }
+
+ set lDAPURL(uri) {
+ this.setStringValue("uri", uri.spec);
+ }
+
+ get childCardCount() {
+ return 0;
+ }
+
+ get childCards() {
+ if (Services.io.offline) {
+ return this.replicationDB.childCards;
+ }
+ return super.childCards;
+ }
+
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get cards() {
+ return new Map();
+ }
+
+ /**
+ * @see {AddrBookDirectory}
+ */
+ get lists() {
+ return new Map();
+ }
+
+ get replicationDB() {
+ this._replicationDB?.cleanUp();
+ this._replicationDB = Cc[
+ "@mozilla.org/addressbook/directory;1?type=jsaddrbook"
+ ].createInstance(Ci.nsIAbDirectory);
+ this._replicationDB.init(`jsaddrbook://${this.replicationFileName}`);
+ return this._replicationDB;
+ }
+
+ getCardFromProperty(property, value, caseSensitive) {
+ return null;
+ }
+
+ search(queryString, searchString, listener) {
+ if (Services.io.offline) {
+ this.replicationDB.search(queryString, searchString, listener);
+ return;
+ }
+ this._query = Cc[
+ "@mozilla.org/addressbook/ldap-directory-query;1"
+ ].createInstance(Ci.nsIAbDirectoryQuery);
+
+ let args = Cc[
+ "@mozilla.org/addressbook/directory/query-arguments;1"
+ ].createInstance(Ci.nsIAbDirectoryQueryArguments);
+ args.expression = lazy.QueryStringToExpression.convert(queryString);
+ args.querySubDirectories = true;
+ args.typeSpecificArg = this.attributeMap;
+
+ this._query.doQuery(this, args, listener, this.maxHits, 0);
+ }
+
+ useForAutocomplete(identityKey) {
+ // If we're online, then don't allow search during local autocomplete - must
+ // use the separate LDAP autocomplete session due to the current interfaces
+ let useDirectory = Services.prefs.getBoolPref(
+ "ldap_2.autoComplete.useDirectory",
+ false
+ );
+ if (!Services.io.offline || (!useDirectory && !identityKey)) {
+ return false;
+ }
+
+ let prefName = "";
+ if (identityKey) {
+ // If we have an identity string, try and find out the required directory
+ // server.
+ let identity = MailServices.accounts.getIdentity(identityKey);
+ if (identity.overrideGlobalPref) {
+ prefName = identity.directoryServer;
+ }
+ if (!prefName && !useDirectory) {
+ return false;
+ }
+ }
+ if (!prefName) {
+ prefName = Services.prefs.getCharPref(
+ "ldap_2.autoComplete.directoryServer"
+ );
+ }
+ if (prefName == this.dirPrefId) {
+ return this.replicationFile.exists();
+ }
+
+ return false;
+ }
+}
+
+LDAPDirectory.prototype.classID = Components.ID(
+ "{8683e821-f1b0-476d-ac15-07771c79bb11}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm b/comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm
new file mode 100644
index 0000000000..88291cbaed
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPDirectoryQuery"];
+
+const { LDAPListenerBase } = ChromeUtils.import(
+ "resource:///modules/LDAPListenerBase.jsm"
+);
+
+/**
+ * Convert a nsIAbBooleanExpression to a filter string.
+ *
+ * @param {nsIAbLDAPAttributeMap} attrMap - A mapping between address book
+ * properties and ldap attributes.
+ * @param {nsIAbBooleanExpression} exp - The expression to convert.
+ * @returns {string}
+ */
+function boolExpressionToFilter(attrMap, exp) {
+ let filter = "(";
+ filter +=
+ {
+ [Ci.nsIAbBooleanOperationTypes.AND]: "&",
+ [Ci.nsIAbBooleanOperationTypes.OR]: "|",
+ [Ci.nsIAbBooleanOperationTypes.NOT]: "!",
+ }[exp.operation] || "";
+
+ if (exp.expressions) {
+ for (let childExp of exp.expressions) {
+ if (childExp instanceof Ci.nsIAbBooleanExpression) {
+ filter += boolExpressionToFilter(attrMap, childExp);
+ } else if (childExp instanceof Ci.nsIAbBooleanConditionString) {
+ filter += boolConditionToFilter(attrMap, childExp);
+ }
+ }
+ }
+
+ filter += ")";
+ return filter;
+}
+
+/**
+ * Convert a nsIAbBooleanConditionString to a filter string.
+ *
+ * @param {nsIAbLDAPAttributeMap} attrMap - A mapping between addressbook
+ * properties and ldap attributes.
+ * @param {nsIAbBooleanConditionString} exp - The expression to convert.
+ * @returns {string}
+ */
+function boolConditionToFilter(attrMap, exp) {
+ let attr = attrMap.getFirstAttribute(exp.name);
+ if (!attr) {
+ return "";
+ }
+ switch (exp.condition) {
+ case Ci.nsIAbBooleanConditionTypes.DoesNotExist:
+ return `(!(${attr}=*))`;
+ case Ci.nsIAbBooleanConditionTypes.Exists:
+ return `(${attr}=*)`;
+ case Ci.nsIAbBooleanConditionTypes.Contains:
+ return `(${attr}=*${exp.value}*)`;
+ case Ci.nsIAbBooleanConditionTypes.DoesNotContain:
+ return `(!(${attr}=*${exp.value}*))`;
+ case Ci.nsIAbBooleanConditionTypes.Is:
+ return `(${attr}=${exp.value})`;
+ case Ci.nsIAbBooleanConditionTypes.IsNot:
+ return `(!(${attr}=${exp.value}))`;
+ case Ci.nsIAbBooleanConditionTypes.BeginsWith:
+ return `(${attr}=${exp.value}*)`;
+ case Ci.nsIAbBooleanConditionTypes.EndsWith:
+ return `(${attr}=*${exp.value})`;
+ case Ci.nsIAbBooleanConditionTypes.LessThan:
+ return `(${attr}<=${exp.value})`;
+ case Ci.nsIAbBooleanConditionTypes.GreaterThan:
+ return `(${attr}>=${exp.value})`;
+ case Ci.nsIAbBooleanConditionTypes.SoundsLike:
+ return `(${attr}~=${exp.value})`;
+ default:
+ return "";
+ }
+}
+
+/**
+ * @implements {nsIAbDirectoryQuery}
+ * @implements {nsILDAPMessageListener}
+ */
+class LDAPDirectoryQuery extends LDAPListenerBase {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIAbDirectoryQuery",
+ "nsILDAPMessageListener",
+ ]);
+
+ i = 0;
+
+ doQuery(directory, args, listener, limit, timeout) {
+ this._directory = directory.QueryInterface(Ci.nsIAbLDAPDirectory);
+ this._listener = listener;
+ this._attrMap = args.typeSpecificArg;
+ this._filter =
+ args.filter || boolExpressionToFilter(this._attrMap, args.expression);
+ this._limit = limit;
+ this._timeout = timeout;
+
+ let urlFilter = this._directory.lDAPURL.filter;
+ // If urlFilter is empty or the default "(objectclass=*)", do nothing.
+ if (urlFilter && urlFilter != "(objectclass=*)") {
+ if (!urlFilter.startsWith("(")) {
+ urlFilter = `(${urlFilter})`;
+ }
+ this._filter = `(&${urlFilter}${this._filter})`;
+ }
+
+ this._connection = Cc[
+ "@mozilla.org/network/ldap-connection;1"
+ ].createInstance(Ci.nsILDAPConnection);
+ this._operation = Cc[
+ "@mozilla.org/network/ldap-operation;1"
+ ].createInstance(Ci.nsILDAPOperation);
+
+ this._connection.init(
+ directory.lDAPURL,
+ directory.authDn,
+ this,
+ null,
+ directory.protocolVersion
+ );
+ return this.i++;
+ }
+
+ stopQuery(contextId) {
+ this._operation?.abandonExt();
+ }
+
+ /**
+ * @see nsILDAPMessageListener
+ */
+ onLDAPMessage(msg) {
+ switch (msg.type) {
+ case Ci.nsILDAPMessage.RES_BIND:
+ this._onLDAPBind(msg);
+ break;
+ case Ci.nsILDAPMessage.RES_SEARCH_ENTRY:
+ this._onLDAPSearchEntry(msg);
+ break;
+ case Ci.nsILDAPMessage.RES_SEARCH_RESULT:
+ this._onLDAPSearchResult(msg);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * @see nsILDAPMessageListener
+ */
+ onLDAPError(status, secInfo, location) {
+ this._onSearchFinished(status, secInfo, location);
+ }
+
+ /**
+ * @see LDAPListenerBase
+ */
+ _actionOnBindSuccess() {
+ let ldapUrl = this._directory.lDAPURL;
+ this._operation.searchExt(
+ ldapUrl.dn,
+ ldapUrl.scope,
+ this._filter,
+ ldapUrl.attributes,
+ this._timeout,
+ this._limit
+ );
+ }
+
+ /**
+ * @see LDAPListenerBase
+ */
+ _actionOnBindFailure() {
+ this._onSearchFinished(Cr.NS_ERROR_FAILURE);
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_SEARCH_ENTRY message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ _onLDAPSearchEntry(msg) {
+ let newCard = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ this._attrMap.setCardPropertiesFromLDAPMessage(msg, newCard);
+ newCard.directoryUID = this._directory.UID;
+ this._listener.onSearchFoundCard(newCard);
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_SEARCH_RESULT message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ _onLDAPSearchResult(msg) {
+ this._onSearchFinished(
+ [Ci.nsILDAPErrors.SUCCESS, Ci.nsILDAPErrors.SIZELIMIT_EXCEEDED].includes(
+ msg.errorCode
+ )
+ ? Cr.NS_OK
+ : Cr.NS_ERROR_FAILURE
+ );
+ }
+
+ _onSearchFinished(status, secInfo, location) {
+ this._listener.onSearchFinished(status, false, secInfo, location);
+ }
+}
+
+LDAPDirectoryQuery.prototype.classID = Components.ID(
+ "{5ad5d311-1a50-43db-a03c-63d45f443903}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPListenerBase.jsm b/comm/mailnews/addrbook/modules/LDAPListenerBase.jsm
new file mode 100644
index 0000000000..486dcaffbe
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPListenerBase.jsm
@@ -0,0 +1,117 @@
+/* -*- Mode: JavaScript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPListenerBase"];
+
+/**
+ * @implements {nsILDAPMessageListener}
+ */
+class LDAPListenerBase {
+ /**
+ * @see nsILDAPMessageListener
+ */
+ async onLDAPInit() {
+ let outPassword = {};
+ if (this._directory.authDn && this._directory.saslMechanism != "GSSAPI") {
+ // If authDn is set, we're expected to use it to get a password.
+ let bundle = Services.strings.createBundle(
+ "chrome://mozldap/locale/ldap.properties"
+ );
+
+ let authPrompt = Services.ww.getNewAuthPrompter(
+ Services.wm.getMostRecentWindow(null)
+ );
+ await authPrompt.asyncPromptPassword(
+ bundle.GetStringFromName("authPromptTitle"),
+ bundle.formatStringFromName("authPromptText", [
+ this._directory.lDAPURL.host,
+ ]),
+ this._directory.lDAPURL.spec,
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY,
+ outPassword
+ );
+ }
+ this._operation.init(this._connection, this, null);
+
+ if (this._directory.saslMechanism != "GSSAPI") {
+ this._operation.simpleBind(outPassword.value);
+ return;
+ }
+
+ // Handle GSSAPI now.
+ this._operation.saslBind(
+ `ldap@${this._directory.lDAPURL.host}`,
+ "GSSAPI",
+ "sasl-gssapi"
+ );
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_BIND message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ _onLDAPBind(msg) {
+ let errCode = msg.errorCode;
+ if (
+ errCode == Ci.nsILDAPErrors.INAPPROPRIATE_AUTH ||
+ errCode == Ci.nsILDAPErrors.INVALID_CREDENTIALS
+ ) {
+ // Login failed, remove any existing login(s).
+ let ldapUrl = this._directory.lDAPURL;
+ let logins = Services.logins.findLogins(
+ ldapUrl.prePath,
+ "",
+ ldapUrl.spec
+ );
+ for (let login of logins) {
+ Services.logins.removeLogin(login);
+ }
+ // Trigger the auth prompt.
+ this.onLDAPInit();
+ return;
+ }
+ if (errCode != Ci.nsILDAPErrors.SUCCESS) {
+ this._actionOnBindFailure();
+ return;
+ }
+ this._actionOnBindSuccess();
+ }
+
+ /**
+ * @see nsILDAPMessageListener
+ * @abstract
+ */
+ onLDAPMessage() {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement onLDAPMessage.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+
+ /**
+ * Callback when BindResponse succeeded.
+ *
+ * @abstract
+ */
+ _actionOnBindSuccess() {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement _actionOnBindSuccess.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+
+ /**
+ * Callback when BindResponse failed.
+ *
+ * @abstract
+ */
+ _actionOnBindFailure() {
+ throw new Components.Exception(
+ `${this.constructor.name} does not implement _actionOnBindFailure.`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/LDAPMessage.jsm b/comm/mailnews/addrbook/modules/LDAPMessage.jsm
new file mode 100644
index 0000000000..6ee7574605
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPMessage.jsm
@@ -0,0 +1,632 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "AbandonRequest",
+ "BindRequest",
+ "UnbindRequest",
+ "SearchRequest",
+ "LDAPResponse",
+];
+
+var { asn1js } = ChromeUtils.importESModule("resource:///modules/asn1js.mjs");
+
+/**
+ * A base class for all LDAP request and response messages, see
+ * rfc4511#section-4.1.1.
+ *
+ * @property {number} messageId - The message id.
+ * @property {LocalBaseBlock} protocolOp - The message content, in a data
+ * structure provided by asn1js.
+ */
+class LDAPMessage {
+ /**
+ * Encode the current message by Basic Encoding Rules (BER).
+ *
+ * @param {number} messageId - The id of the current message.
+ * @returns {ArrayBuffer} BER encoded message.
+ */
+ toBER(messageId = this.messageId) {
+ let msg = new asn1js.Sequence({
+ value: [new asn1js.Integer({ value: messageId }), this.protocolOp],
+ });
+ return msg.toBER();
+ }
+
+ static TAG_CLASS_APPLICATION = 2;
+ static TAG_CLASS_CONTEXT = 3;
+
+ /**
+ * Get the idBlock of [APPLICATION n].
+ *
+ * @param {number} tagNumber - The tag number of this block.
+ */
+ _getApplicationId(tagNumber) {
+ return {
+ tagClass: LDAPMessage.TAG_CLASS_APPLICATION,
+ tagNumber,
+ };
+ }
+
+ /**
+ * Get the idBlock of context-specific [n].
+ *
+ * @param {number} tagNumber - The tag number of this block.
+ */
+ _getContextId(tagNumber) {
+ return {
+ tagClass: LDAPMessage.TAG_CLASS_CONTEXT,
+ tagNumber,
+ };
+ }
+
+ /**
+ * Create a string block with context-specific [n].
+ *
+ * @param {number} tagNumber - The tag number of this block.
+ * @param {string} value - The string value of this block.
+ * @returns {LocalBaseBlock}
+ */
+ _contextStringBlock(tagNumber, value) {
+ return new asn1js.Primitive({
+ idBlock: this._getContextId(tagNumber),
+ valueHex: new TextEncoder().encode(value),
+ });
+ }
+}
+
+class BindRequest extends LDAPMessage {
+ static APPLICATION = 0;
+
+ AUTH_SIMPLE = 0;
+ AUTH_SASL = 3;
+
+ /**
+ * @param {string} dn - The name to bind.
+ * @param {string} password - The password.
+ * @param {object} sasl - The SASL configs.
+ * @param {string} sasl.mechanism - The SASL mechanism e.g. sasl-gssapi.
+ * @param {Uint8Array} sasl.credentials - The credential token for the request.
+ */
+ constructor(dn, password, sasl) {
+ super();
+ let authBlock;
+ if (sasl) {
+ authBlock = new asn1js.Constructed({
+ idBlock: this._getContextId(this.AUTH_SASL),
+ value: [
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(sasl.mechanism),
+ }),
+ new asn1js.OctetString({
+ valueHex: sasl.credentials,
+ }),
+ ],
+ });
+ } else {
+ authBlock = new asn1js.Primitive({
+ idBlock: this._getContextId(this.AUTH_SIMPLE),
+ valueHex: new TextEncoder().encode(password),
+ });
+ }
+ this.protocolOp = new asn1js.Constructed({
+ // [APPLICATION 0]
+ idBlock: this._getApplicationId(BindRequest.APPLICATION),
+ value: [
+ // version
+ new asn1js.Integer({ value: 3 }),
+ // name
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(dn),
+ }),
+ // authentication
+ authBlock,
+ ],
+ });
+ }
+}
+
+class UnbindRequest extends LDAPMessage {
+ static APPLICATION = 2;
+
+ protocolOp = new asn1js.Primitive({
+ // [APPLICATION 2]
+ idBlock: this._getApplicationId(UnbindRequest.APPLICATION),
+ });
+}
+
+class SearchRequest extends LDAPMessage {
+ static APPLICATION = 3;
+
+ // Filter CHOICE.
+ FILTER_AND = 0;
+ FILTER_OR = 1;
+ FILTER_NOT = 2;
+ FILTER_EQUALITY_MATCH = 3;
+ FILTER_SUBSTRINGS = 4;
+ FILTER_GREATER_OR_EQUAL = 5;
+ FILTER_LESS_OR_EQUAL = 6;
+ FILTER_PRESENT = 7;
+ FILTER_APPROX_MATCH = 8;
+ FILTER_EXTENSIBLE_MATCH = 9;
+
+ // SubstringFilter SEQUENCE.
+ SUBSTRINGS_INITIAL = 0;
+ SUBSTRINGS_ANY = 1;
+ SUBSTRINGS_FINAL = 2;
+
+ // MatchingRuleAssertion SEQUENCE.
+ MATCHING_RULE = 1; // optional
+ MATCHING_TYPE = 2; // optional
+ MATCHING_VALUE = 3;
+ MATCHING_DN = 4; // default to FALSE
+
+ /**
+ * @param {string} dn - The name to search.
+ * @param {number} scope - The scope to search.
+ * @param {string} filter - The filter string, e.g. "(&(|(k1=v1)(k2=v2)))".
+ * @param {string} attributes - Attributes to include in the search result.
+ * @param {number} timeout - The seconds to wait.
+ * @param {number} limit - Maximum number of entries to return.
+ */
+ constructor(dn, scope, filter, attributes, timeout, limit) {
+ super();
+ this.protocolOp = new asn1js.Constructed({
+ // [APPLICATION 3]
+ idBlock: this._getApplicationId(SearchRequest.APPLICATION),
+ value: [
+ // base DN
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(dn),
+ }),
+ // scope
+ new asn1js.Enumerated({
+ value: scope,
+ }),
+ // derefAliases
+ new asn1js.Enumerated({
+ value: 0,
+ }),
+ // sizeLimit
+ new asn1js.Integer({ value: limit }),
+ // timeLimit
+ new asn1js.Integer({ value: timeout }),
+ // typesOnly
+ new asn1js.Boolean({ value: false }),
+ // filter
+ this._convertFilterToBlock(filter),
+ // attributes
+ new asn1js.Sequence({
+ value: attributes
+ .split(",")
+ .filter(Boolean)
+ .map(
+ attr =>
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(attr),
+ })
+ ),
+ }),
+ ],
+ });
+ }
+
+ /**
+ * Parse a single filter value "key=value" to [filterId, key, value].
+ *
+ * @param {string} filter - A single filter value without parentheses.
+ * @returns {(number|string)[]} An array [filterId, key, value] as
+ * [number, string, string]
+ */
+ _parseFilterValue(filter) {
+ for (let cond of [">=", "<=", "~=", ":=", "="]) {
+ let index = filter.indexOf(cond);
+ if (index > 0) {
+ let k = filter.slice(0, index);
+ let v = filter.slice(index + cond.length);
+ let filterId = {
+ ">=": this.FILTER_GREATER_OR_EQUAL,
+ "<=": this.FILTER_LESS_OR_EQUAL,
+ "~=": this.FILTER_APPROX_MATCH,
+ ":=": this.FILTER_EXTENSIBLE_MATCH,
+ }[cond];
+ if (!filterId) {
+ if (v == "*") {
+ filterId = this.FILTER_PRESENT;
+ } else if (!v.includes("*")) {
+ filterId = this.FILTER_EQUALITY_MATCH;
+ } else {
+ filterId = this.FILTER_SUBSTRINGS;
+ v = v.split("*");
+ }
+ }
+ return [filterId, k, v];
+ }
+ }
+ throw Components.Exception(
+ `Invalid filter: ${filter}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ /**
+ * Parse a full filter string to an array of tokens.
+ *
+ * @param {string} filter - The full filter string to parse.
+ * @param {number} depth - The depth of a token.
+ * @param {object[]} tokens - The tokens to return.
+ * @param {"op"|"field"} tokens[].type - The token type.
+ * @param {number} tokens[].depth - The token depth.
+ * @param {string|string[]} tokens[].value - The token value.
+ */
+ _parseFilter(filter, depth = 0, tokens = []) {
+ while (filter[0] == ")" && depth > 0) {
+ depth--;
+ filter = filter.slice(1);
+ }
+ if (filter.length == 0) {
+ // End of input.
+ return tokens;
+ }
+ if (filter[0] != "(") {
+ throw Components.Exception(
+ `Invalid filter: ${filter}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ filter = filter.slice(1);
+ let nextOpen = filter.indexOf("(");
+ let nextClose = filter.indexOf(")");
+
+ if (nextOpen != -1 && nextOpen < nextClose) {
+ // Case: "OP("
+ depth++;
+ tokens.push({
+ type: "op",
+ depth,
+ value: {
+ "&": this.FILTER_AND,
+ "|": this.FILTER_OR,
+ "!": this.FILTER_NOT,
+ }[filter.slice(0, nextOpen)],
+ });
+ this._parseFilter(filter.slice(nextOpen), depth, tokens);
+ } else if (nextClose != -1) {
+ // Case: "key=value)"
+ tokens.push({
+ type: "field",
+ depth,
+ value: this._parseFilterValue(filter.slice(0, nextClose)),
+ });
+ this._parseFilter(filter.slice(nextClose + 1), depth, tokens);
+ }
+ return tokens;
+ }
+
+ /**
+ * Parse a filter string to a LocalBaseBlock.
+ *
+ * @param {string} filter - The filter string to parse.
+ * @returns {LocalBaseBlock}
+ */
+ _convertFilterToBlock(filter) {
+ if (!filter.startsWith("(")) {
+ // Make sure filter is wrapped in parens, see rfc2254#section-4.
+ filter = `(${filter})`;
+ }
+ let tokens = this._parseFilter(filter);
+ let stack = [];
+ for (let { type, depth, value } of tokens) {
+ while (depth < stack.length) {
+ // We are done with the current block, go one level up.
+ stack.pop();
+ }
+ if (type == "op") {
+ if (depth == stack.length) {
+ // We are done with the current block, go one level up.
+ stack.pop();
+ }
+ // Found a new block, go one level down.
+ let parent = stack.slice(-1)[0];
+ let curBlock = new asn1js.Constructed({
+ idBlock: this._getContextId(value),
+ });
+ stack.push(curBlock);
+ if (parent) {
+ parent.valueBlock.value.push(curBlock);
+ }
+ } else if (type == "field") {
+ let [tagNumber, field, fieldValue] = value;
+ let block;
+ let idBlock = this._getContextId(tagNumber);
+ if (tagNumber == this.FILTER_PRESENT) {
+ // A present filter.
+ block = new asn1js.Primitive({
+ idBlock,
+ valueHex: new TextEncoder().encode(field),
+ });
+ } else if (tagNumber == this.FILTER_EXTENSIBLE_MATCH) {
+ // An extensibleMatch filter is in the form of
+ // <type>:dn:<rule>:=<value>. We need to further parse the field.
+ let parts = field.split(":");
+ let value = [];
+ if (parts.length == 3) {
+ // field is <type>:dn:<rule>.
+ if (parts[2]) {
+ value.push(
+ this._contextStringBlock(this.MATCHING_RULE, parts[2])
+ );
+ }
+ if (parts[0]) {
+ value.push(
+ this._contextStringBlock(this.MATCHING_TYPE, parts[0])
+ );
+ }
+ value.push(
+ this._contextStringBlock(this.MATCHING_VALUE, fieldValue)
+ );
+ if (parts[1] == "dn") {
+ let dn = new asn1js.Boolean({
+ value: true,
+ });
+ dn.idBlock.tagClass = LDAPMessage.TAG_CLASS_CONTEXT;
+ dn.idBlock.tagNumber = this.MATCHING_DN;
+ value.push(dn);
+ }
+ } else if (parts.length == 2) {
+ // field is <type>:<rule>.
+ if (parts[1]) {
+ value.push(
+ this._contextStringBlock(this.MATCHING_RULE, parts[1])
+ );
+ }
+
+ if (parts[0]) {
+ value.push(
+ this._contextStringBlock(this.MATCHING_TYPE, parts[0])
+ );
+ }
+ value.push(
+ this._contextStringBlock(this.MATCHING_VALUE, fieldValue)
+ );
+ } else {
+ // field is <type>.
+ value = [
+ this._contextStringBlock(this.MATCHING_TYPE, field),
+ this._contextStringBlock(this.MATCHING_VALUE, fieldValue),
+ ];
+ }
+ block = new asn1js.Constructed({
+ idBlock,
+ value,
+ });
+ } else if (tagNumber != this.FILTER_SUBSTRINGS) {
+ // A filter that is not substrings filter.
+ block = new asn1js.Constructed({
+ idBlock,
+ value: [
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(field),
+ }),
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(fieldValue),
+ }),
+ ],
+ });
+ } else {
+ // A substrings filter.
+ let substringsSeq = new asn1js.Sequence();
+ block = new asn1js.Constructed({
+ idBlock,
+ value: [
+ new asn1js.OctetString({
+ valueHex: new TextEncoder().encode(field),
+ }),
+ substringsSeq,
+ ],
+ });
+ for (let i = 0; i < fieldValue.length; i++) {
+ let v = fieldValue[i];
+ if (!v.length) {
+ // Case: *
+ continue;
+ } else if (i < fieldValue.length - 1) {
+ // Case: abc*
+ substringsSeq.valueBlock.value.push(
+ new asn1js.Primitive({
+ idBlock: this._getContextId(
+ i == 0 ? this.SUBSTRINGS_INITIAL : this.SUBSTRINGS_ANY
+ ),
+ valueHex: new TextEncoder().encode(v),
+ })
+ );
+ } else {
+ // Case: *abc
+ substringsSeq.valueBlock.value.push(
+ new asn1js.Primitive({
+ idBlock: this._getContextId(this.SUBSTRINGS_FINAL),
+ valueHex: new TextEncoder().encode(v),
+ })
+ );
+ }
+ }
+ }
+ let curBlock = stack.slice(-1)[0];
+ if (curBlock) {
+ curBlock.valueBlock.value.push(block);
+ } else {
+ stack.push(block);
+ }
+ }
+ }
+
+ return stack[0];
+ }
+}
+
+class AbandonRequest extends LDAPMessage {
+ static APPLICATION = 16;
+
+ /**
+ * @param {string} messageId - The messageId to abandon.
+ */
+ constructor(messageId) {
+ super();
+ this.protocolOp = new asn1js.Integer({ value: messageId });
+ // [APPLICATION 16]
+ this.protocolOp.idBlock.tagClass = LDAPMessage.TAG_CLASS_APPLICATION;
+ this.protocolOp.idBlock.tagNumber = AbandonRequest.APPLICATION;
+ }
+}
+
+class LDAPResult {
+ /**
+ * @param {number} resultCode - The result code.
+ * @param {string} matchedDN - For certain result codes, matchedDN is the last entry used.
+ * @param {string} diagnosticMessage - A diagnostic message returned by the server.
+ */
+ constructor(resultCode, matchedDN, diagnosticMessage) {
+ this.resultCode = resultCode;
+ this.matchedDN = matchedDN;
+ this.diagnosticMessage = diagnosticMessage;
+ }
+}
+
+/**
+ * A base class for all LDAP response messages.
+ *
+ * @property {LDAPResult} result - The result of a response.
+ */
+class LDAPResponse extends LDAPMessage {
+ /**
+ * @param {number} messageId - The message id.
+ * @param {LocalBaseBlock} protocolOp - The message content.
+ * @param {number} byteLength - The byte size of this message in raw BER form.
+ */
+ constructor(messageId, protocolOp, byteLength) {
+ super();
+ this.messageId = messageId;
+ this.protocolOp = protocolOp;
+ this.byteLength = byteLength;
+ }
+
+ /**
+ * Find the corresponding response class name from a tag number.
+ *
+ * @param {number} tagNumber - The tag number of a block.
+ * @returns {LDAPResponse}
+ */
+ static _getResponseClassFromTagNumber(tagNumber) {
+ return [
+ SearchResultEntry,
+ SearchResultDone,
+ SearchResultReference,
+ BindResponse,
+ ExtendedResponse,
+ ].find(x => x.APPLICATION == tagNumber);
+ }
+
+ /**
+ * Decode a raw server response to LDAPResponse instance.
+ *
+ * @param {ArrayBuffer} buffer - The raw message received from the server.
+ * @returns {LDAPResponse} A concrete instance of LDAPResponse subclass.
+ */
+ static fromBER(buffer) {
+ let decoded = asn1js.fromBER(buffer);
+ if (decoded.offset == -1 || decoded.result.error) {
+ throw Components.Exception(
+ decoded.result.error,
+ Cr.NS_ERROR_CANNOT_CONVERT_DATA
+ );
+ }
+ let value = decoded.result.valueBlock.value;
+ let protocolOp = value[1];
+ if (protocolOp.idBlock.tagClass != this.TAG_CLASS_APPLICATION) {
+ throw Components.Exception(
+ `Unexpected tagClass ${protocolOp.idBlock.tagClass}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let ProtocolOp = this._getResponseClassFromTagNumber(
+ protocolOp.idBlock.tagNumber
+ );
+ if (!ProtocolOp) {
+ throw Components.Exception(
+ `Unexpected tagNumber ${protocolOp.idBlock.tagNumber}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let op = new ProtocolOp(
+ value[0].valueBlock.valueDec,
+ protocolOp,
+ decoded.offset
+ );
+ op.parse();
+ return op;
+ }
+
+ /**
+ * Parse the protocolOp part of a LDAPMessage to LDAPResult. For LDAP
+ * responses that are simply LDAPResult, reuse this function. Other responses
+ * need to implement this function.
+ */
+ parse() {
+ let value = this.protocolOp.valueBlock.value;
+ let resultCode = value[0].valueBlock.valueDec;
+ let matchedDN = new TextDecoder().decode(value[1].valueBlock.valueHex);
+ let diagnosticMessage = new TextDecoder().decode(
+ value[2].valueBlock.valueHex
+ );
+ this.result = new LDAPResult(resultCode, matchedDN, diagnosticMessage);
+ }
+}
+
+class BindResponse extends LDAPResponse {
+ static APPLICATION = 1;
+
+ parse() {
+ super.parse();
+ let serverSaslCredsBlock = this.protocolOp.valueBlock.value[3];
+ if (serverSaslCredsBlock) {
+ this.result.serverSaslCreds = serverSaslCredsBlock.valueBlock.valueHex;
+ }
+ }
+}
+
+class SearchResultEntry extends LDAPResponse {
+ static APPLICATION = 4;
+
+ parse() {
+ let value = this.protocolOp.valueBlock.value;
+ let objectName = new TextDecoder().decode(value[0].valueBlock.valueHex);
+ let attributes = {};
+ for (let attr of value[1].valueBlock.value) {
+ let attrValue = attr.valueBlock.value;
+ let type = new TextDecoder().decode(attrValue[0].valueBlock.valueHex);
+ let vals = attrValue[1].valueBlock.value.map(v => v.valueBlock.valueHex);
+ attributes[type] = vals;
+ }
+ this.result = { objectName, attributes };
+ }
+}
+
+class SearchResultDone extends LDAPResponse {
+ static APPLICATION = 5;
+}
+
+class SearchResultReference extends LDAPResponse {
+ static APPLICATION = 19;
+
+ parse() {
+ let value = this.protocolOp.valueBlock.value;
+ this.result = value.map(block =>
+ new TextDecoder().decode(block.valueBlock.valueHex)
+ );
+ }
+}
+
+class ExtendedResponse extends LDAPResponse {
+ static APPLICATION = 24;
+}
diff --git a/comm/mailnews/addrbook/modules/LDAPOperation.jsm b/comm/mailnews/addrbook/modules/LDAPOperation.jsm
new file mode 100644
index 0000000000..d0e2d64a54
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPOperation.jsm
@@ -0,0 +1,198 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPOperation"];
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "LDAPClient",
+ "resource:///modules/LDAPClient.jsm"
+);
+
+/**
+ * A module to manage LDAP operation.
+ *
+ * @implements {nsILDAPOperation}
+ */
+class LDAPOperation {
+ QueryInterface = ChromeUtils.generateQI(["nsILDAPOperation"]);
+
+ init(connection, listener, closure) {
+ this._listener = listener;
+ this._connection = connection;
+ this._client = connection.wrappedJSObject.client;
+
+ this._referenceUrls = [];
+
+ // Cache request arguments to use when searching references.
+ this._simpleBindPassword = null;
+ this._saslBindArgs = null;
+ this._searchArgs = null;
+ }
+
+ simpleBind(password) {
+ this._password = password;
+ try {
+ this._messageId = this._client.bind(
+ this._connection.bindName,
+ password,
+ res => this._onBindSuccess(res.result.resultCode)
+ );
+ } catch (e) {
+ this._listener.onLDAPError(e.result, null, "");
+ }
+ }
+
+ saslBind(service, mechanism, authModuleType, serverCredentials) {
+ this._saslBindArgs = [service, mechanism, authModuleType];
+ try {
+ this._client.saslBind(
+ service,
+ mechanism,
+ authModuleType,
+ serverCredentials,
+ res => {
+ if (res.result.resultCode == Ci.nsILDAPErrors.SASL_BIND_IN_PROGRESS) {
+ this.saslBind(
+ service,
+ mechanism,
+ authModuleType,
+ res.result.serverSaslCreds
+ );
+ } else if (res.result.resultCode == Ci.nsILDAPErrors.SUCCESS) {
+ this._onBindSuccess(res.result.resultCode);
+ }
+ }
+ );
+ } catch (e) {
+ this._listener.onLDAPError(e.result, null, "");
+ }
+ }
+
+ searchExt(baseDN, scope, filter, attributes, timeout, limit) {
+ this._searchArgs = [baseDN, scope, filter, attributes, timeout, limit];
+ try {
+ this._messageId = this._client.search(
+ baseDN,
+ scope,
+ filter,
+ attributes,
+ timeout,
+ limit,
+ res => {
+ if (res.constructor.name == "SearchResultEntry") {
+ this._listener.onLDAPMessage({
+ QueryInterface: ChromeUtils.generateQI(["nsILDAPMessage"]),
+ errorCode: 0,
+ type: Ci.nsILDAPMessage.RES_SEARCH_ENTRY,
+ getAttributes() {
+ return Object.keys(res.result.attributes);
+ },
+ // Find the matching attribute name while ignoring the case.
+ _getAttribute(attr) {
+ attr = attr.toLowerCase();
+ return this.getAttributes().find(x => x.toLowerCase() == attr);
+ },
+ getValues(attr) {
+ attr = this._getAttribute(attr);
+ return res.result.attributes[attr]?.map(v =>
+ new TextDecoder().decode(v)
+ );
+ },
+ getBinaryValues(attr) {
+ attr = this._getAttribute(attr);
+ return res.result.attributes[attr]?.map(v => ({
+ // @see nsILDAPBERValue
+ get: () => new Uint8Array(v),
+ }));
+ },
+ });
+ } else if (res.constructor.name == "SearchResultReference") {
+ this._referenceUrls.push(...res.result);
+ } else if (res.constructor.name == "SearchResultDone") {
+ // NOTE: we create a new connection for every search, can be changed
+ // to reuse connections.
+ this._client.onError = () => {};
+ this._client.unbind();
+ this._messageId = null;
+ if (this._referenceUrls.length) {
+ this._searchReference(this._referenceUrls.shift());
+ } else {
+ this._listener.onLDAPMessage({
+ errorCode: res.result.resultCode,
+ type: Ci.nsILDAPMessage.RES_SEARCH_RESULT,
+ });
+ }
+ }
+ }
+ );
+ } catch (e) {
+ this._listener.onLDAPError(e.result, null, "");
+ }
+ }
+
+ abandonExt() {
+ if (this._messageId) {
+ this._client.abandon(this._messageId);
+ }
+ }
+
+ /**
+ * Decide what to do on bind success. When searching a reference url, trigger
+ * a new search. Otherwise, emit a message to this._listener.
+ *
+ * @param {number} errorCode - The result code of BindResponse.
+ */
+ _onBindSuccess(errorCode) {
+ if (this._searchingReference) {
+ this.searchExt(...this._searchArgs);
+ } else {
+ this._listener.onLDAPMessage({
+ errorCode,
+ type: Ci.nsILDAPMessage.RES_BIND,
+ });
+ }
+ }
+
+ /**
+ * Connect to a reference url and continue the search.
+ *
+ * @param {string} urlStr - A url string we get from SearchResultReference.
+ */
+ _searchReference(urlStr) {
+ this._searchingReference = true;
+ let urlParser = Cc["@mozilla.org/network/ldap-url-parser;1"].createInstance(
+ Ci.nsILDAPURLParser
+ );
+ let url;
+ try {
+ url = urlParser.parse(urlStr);
+ } catch (e) {
+ console.error(e);
+ return;
+ }
+ this._client = new lazy.LDAPClient(
+ url.host,
+ url.port,
+ url.options & Ci.nsILDAPURL.OPT_SECURE
+ );
+ this._client.onOpen = () => {
+ if (this._password) {
+ this.simpleBind(this._password);
+ } else {
+ this.saslBind(...this._saslBindData);
+ }
+ };
+ this._client.onError = (status, secInfo) => {
+ this._listener.onLDAPError(status, secInfo, `${url.host}:${url.port}`);
+ };
+ this._client.connect();
+ }
+}
+
+LDAPOperation.prototype.classID = Components.ID(
+ "{a6f94ca4-cd2d-4983-bcf2-fe936190955c}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm b/comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm
new file mode 100644
index 0000000000..c9d84f2d99
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["LDAPProtocolHandler", "LDAPSProtocolHandler"];
+
+/**
+ * @implements {nsIProtocolHandler}
+ */
+class LDAPProtocolHandler {
+ QueryInterface = ChromeUtils.generateQI(["nsIProtocolHandler"]);
+
+ scheme = "ldap";
+
+ newChannel(aURI, aLoadInfo) {
+ let channel = Cc["@mozilla.org/network/ldap-channel;1"].createInstance(
+ Ci.nsIChannel
+ );
+ channel.init(aURI);
+ channel.loadInfo = aLoadInfo;
+ return channel;
+ }
+
+ allowPort(port, scheme) {
+ return port == 389;
+ }
+}
+LDAPProtocolHandler.prototype.classID = Components.ID(
+ "{b3de9249-b0e5-4c12-8d91-c9a434fd80f5}"
+);
+
+class LDAPSProtocolHandler extends LDAPProtocolHandler {
+ scheme = "ldaps";
+
+ allowPort(port, scheme) {
+ return port == 636;
+ }
+}
+LDAPSProtocolHandler.prototype.classID = Components.ID(
+ "{c85a5ef2-9c56-445f-b029-76889f2dd29b}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPReplicationService.jsm b/comm/mailnews/addrbook/modules/LDAPReplicationService.jsm
new file mode 100644
index 0000000000..2a11d15eee
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPReplicationService.jsm
@@ -0,0 +1,233 @@
+/* -*- Mode: JavaScript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPReplicationService"];
+
+const { LDAPListenerBase } = ChromeUtils.import(
+ "resource:///modules/LDAPListenerBase.jsm"
+);
+var { SQLiteDirectory } = ChromeUtils.import(
+ "resource:///modules/SQLiteDirectory.jsm"
+);
+
+/**
+ * A service to replicate a LDAP directory to a local SQLite db.
+ *
+ * @implements {nsIAbLDAPReplicationService}
+ * @implements {nsILDAPMessageListener}
+ */
+class LDAPReplicationService extends LDAPListenerBase {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIAbLDAPReplicationService",
+ "nsILDAPMessageListener",
+ ]);
+
+ /**
+ * @see nsIAbLDAPReplicationService
+ */
+ startReplication(directory, progressListener) {
+ this._directory = directory;
+ this._listener = progressListener;
+ this._attrMap = directory.attributeMap;
+ this._count = 0;
+ this._cards = [];
+ this._connection = Cc[
+ "@mozilla.org/network/ldap-connection;1"
+ ].createInstance(Ci.nsILDAPConnection);
+ this._operation = Cc[
+ "@mozilla.org/network/ldap-operation;1"
+ ].createInstance(Ci.nsILDAPOperation);
+
+ this._connection.init(
+ directory.lDAPURL,
+ directory.authDn,
+ this,
+ null,
+ directory.protocolVersion
+ );
+ }
+
+ /**
+ * @see nsIAbLDAPReplicationService
+ */
+ cancelReplication(directory) {
+ this._operation.abandonExt();
+ this.done(false);
+ }
+
+ /**
+ * @see nsIAbLDAPReplicationService
+ */
+ done(success) {
+ this._done(success);
+ }
+
+ /**
+ * @see nsILDAPMessageListener
+ */
+ onLDAPMessage(msg) {
+ switch (msg.type) {
+ case Ci.nsILDAPMessage.RES_BIND:
+ this._onLDAPBind(msg);
+ break;
+ case Ci.nsILDAPMessage.RES_SEARCH_ENTRY:
+ this._onLDAPSearchEntry(msg);
+ break;
+ case Ci.nsILDAPMessage.RES_SEARCH_RESULT:
+ this._onLDAPSearchResult(msg);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * @see nsILDAPMessageListener
+ */
+ onLDAPError(status, secInfo, location) {
+ this.done(false);
+ }
+
+ /**
+ * @see LDAPListenerBase
+ */
+ _actionOnBindSuccess() {
+ this._openABForReplicationDir();
+ let ldapUrl = this._directory.lDAPURL;
+ this._operation.init(this._connection, this, null);
+ this._listener.onStateChange(
+ null,
+ null,
+ Ci.nsIWebProgressListener.STATE_START,
+ Cr.NS_OK
+ );
+ this._operation.searchExt(
+ ldapUrl.dn,
+ ldapUrl.scope,
+ ldapUrl.filter,
+ ldapUrl.attributes,
+ 0,
+ 0
+ );
+ }
+
+ /**
+ * @see LDAPListenerBase
+ */
+ _actionOnBindFailure() {
+ this._done(false);
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_SEARCH_ENTRY message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ async _onLDAPSearchEntry(msg) {
+ let newCard = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+ Ci.nsIAbCard
+ );
+ this._attrMap.setCardPropertiesFromLDAPMessage(msg, newCard);
+ this._cards.push(newCard);
+ this._count++;
+ if (this._count % 10 == 0) {
+ // inform the listener every 10 entries
+ this._listener.onProgressChange(
+ null,
+ null,
+ this._count,
+ -1,
+ this._count,
+ -1
+ );
+ }
+ if (this._count % 100 == 0 && !this._writePromise) {
+ // Write to the db to release some memory.
+ this._writePromise = this._replicationDB.bulkAddCards(this._cards);
+ this._cards = [];
+ await this._writePromise;
+ this._writePromise = null;
+ }
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_SEARCH_RESULT message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ async _onLDAPSearchResult(msg) {
+ if (
+ msg.errorCode == Ci.nsILDAPErrors.SUCCESS ||
+ msg.errorCode == Ci.nsILDAPErrors.SIZELIMIT_EXCEEDED
+ ) {
+ if (this._writePromise) {
+ await this._writePromise;
+ }
+ await this._replicationDB.bulkAddCards(this._cards);
+ this.done(true);
+ return;
+ }
+ this.done(false);
+ }
+
+ /**
+ * Init a jsaddrbook from the replicationFileName of the current LDAP directory.
+ */
+ _openABForReplicationDir() {
+ this._oldReplicationFileName = this._directory.replicationFileName;
+ this._replicationFile = this._directory.replicationFile;
+ if (this._replicationFile.exists()) {
+ // If the database file already exists, create a new one here, and replace
+ // the old file in _done when success.
+ this._replicationFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+ // What we need is the unique file name, _replicationDB will create an
+ // empty database file.
+ this._replicationFile.remove(false);
+ // Set replicationFileName to the new db file name, so that _replicationDB
+ // works correctly.
+ this._directory.replicationFileName = this._replicationFile.leafName;
+ }
+
+ this._replicationDB = new SQLiteDirectory();
+ this._replicationDB.init(`jsaddrbook://${this._replicationFile.leafName}`);
+ }
+
+ /**
+ * Clean up depending on whether replication succeeded or failed, emit
+ * STATE_STOP event.
+ *
+ * @param {bool} success - Replication succeeded or failed.
+ */
+ async _done(success) {
+ this._cards = [];
+ if (this._replicationDB) {
+ // Close the db.
+ await this._replicationDB.cleanUp();
+ }
+ if (success) {
+ // Replace the old db file with new db file.
+ this._replicationFile.moveTo(null, this._oldReplicationFileName);
+ } else if (
+ this._replicationFile &&
+ this._replicationFile.path != this._oldReplicationFileName
+ ) {
+ this._replicationFile.remove(false);
+ }
+ if (this._oldReplicationFileName) {
+ // Reset replicationFileName to the old db file name.
+ this._directory.replicationFileName = this._oldReplicationFileName;
+ }
+ this._listener.onStateChange(
+ null,
+ null,
+ Ci.nsIWebProgressListener.STATE_STOP,
+ success ? Cr.NS_OK : Cr.NS_ERROR_FAILURE
+ );
+ }
+}
+
+LDAPReplicationService.prototype.classID = Components.ID(
+ "{dbe204e8-ae09-11eb-b4c8-a7e4b3e6e82e}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPService.jsm b/comm/mailnews/addrbook/modules/LDAPService.jsm
new file mode 100644
index 0000000000..d1def67afc
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPService.jsm
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPService"];
+
+/**
+ * @implements {nsILDAPService}
+ */
+class LDAPService {
+ QueryInterface = ChromeUtils.generateQI(["nsILDAPService"]);
+
+ createFilter(maxSize, pattern, prefix, suffix, attr, value) {
+ let words = value.split(" ");
+ // Get the Mth to Nth words.
+ function getMtoN(m, n) {
+ n = n || m;
+ return words.slice(m - 1, n).join(" ");
+ }
+
+ let filter = prefix;
+ pattern.replaceAll("%a", attr);
+ while (pattern) {
+ let index = pattern.indexOf("%v");
+ if (index == -1) {
+ filter += pattern;
+ pattern = "";
+ } else {
+ filter += pattern.slice(0, index);
+ // Get the three characters after %v.
+ let [c1, c2, c3] = pattern.slice(index + 2, index + 5);
+ if (c1 >= "1" && c1 <= "9") {
+ if (c2 == "$") {
+ // %v$: means the last word
+ filter += getMtoN(words.length);
+ pattern = pattern.slice(index + 3);
+ } else if (c2 == "-") {
+ if (c3 >= "1" && c3 <= "9") {
+ // %vM-N: means from the Mth to the Nth word
+ filter += getMtoN(c1, c3);
+ pattern = pattern.slice(index + 5);
+ } else {
+ // %vN-: means from the Nth to the last word
+ filter += getMtoN(c1, words.length);
+ pattern = pattern.slice(index + 4);
+ }
+ } else {
+ // %vN: means the Nth word
+ filter += getMtoN(c1);
+ pattern = pattern.slice(index + 3);
+ }
+ } else {
+ // %v: means the entire search value
+ filter += value;
+ pattern = pattern.slice(index + 2);
+ }
+ }
+ }
+ filter += suffix;
+ return filter.length > maxSize ? "" : filter;
+ }
+}
+
+LDAPService.prototype.classID = Components.ID(
+ "{e8b59b32-f83f-4d5f-8eb5-e3c1e5de0d47}"
+);
diff --git a/comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm b/comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm
new file mode 100644
index 0000000000..d92fea191f
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPSyncQuery"];
+
+/**
+ * @implements {nsILDAPMessageListener}
+ * @implements {nsILDAPSyncQuery}
+ */
+class LDAPSyncQuery {
+ QueryInterface = ChromeUtils.generateQI([
+ "nsILDAPMessageListener",
+ "nsILDAPSyncQuery",
+ ]);
+
+ /** @see nsILDAPMessageListener */
+ onLDAPInit() {
+ this._operation = Cc[
+ "@mozilla.org/network/ldap-operation;1"
+ ].createInstance(Ci.nsILDAPOperation);
+ this._operation.init(this._connection, this, null);
+ this._operation.simpleBind("");
+ }
+
+ onLDAPMessage(msg) {
+ switch (msg.type) {
+ case Ci.nsILDAPMessage.RES_BIND:
+ this._onLDAPBind(msg);
+ break;
+ case Ci.nsILDAPMessage.RES_SEARCH_ENTRY:
+ this._onLDAPSearchEntry(msg);
+ break;
+ case Ci.nsILDAPMessage.RES_SEARCH_RESULT:
+ this._onLDAPSearchResult(msg);
+ break;
+ default:
+ break;
+ }
+ }
+
+ onLDAPError(status, secInfo, location) {
+ this._statusCode = status;
+ this._finished = true;
+ }
+
+ /** @see nsILDAPSyncQuery */
+ getQueryResults(ldapUrl, protocolVersion) {
+ this._ldapUrl = ldapUrl;
+ this._connection = Cc[
+ "@mozilla.org/network/ldap-connection;1"
+ ].createInstance(Ci.nsILDAPConnection);
+ this._connection.init(ldapUrl, "", this, null, protocolVersion);
+
+ this._statusCode = 0;
+ this._result = "";
+ this._finished = false;
+
+ Services.tm.spinEventLoopUntil(
+ "getQueryResults is a sync function",
+ () => this._finished
+ );
+ if (this._statusCode) {
+ throw Components.Exception("getQueryResults failed", this._statusCode);
+ }
+ return this._result;
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_BIND message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ _onLDAPBind(msg) {
+ if (msg.errorCode != Ci.nsILDAPErrors.SUCCESS) {
+ this._statusCode = msg.errorCode;
+ this._finished = true;
+ return;
+ }
+ this._operation.init(this._connection, this, null);
+ this._operation.searchExt(
+ this._ldapUrl.dn,
+ this._ldapUrl.scope,
+ this._ldapUrl.filter,
+ this._ldapUrl.attributes,
+ 0,
+ 0
+ );
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_SEARCH_ENTRY message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ _onLDAPSearchEntry(msg) {
+ for (let attr of msg.getAttributes()) {
+ for (let value of msg.getValues(attr)) {
+ this._result += `\n${attr}=${value}`;
+ }
+ }
+ }
+
+ /**
+ * Handler of nsILDAPMessage.RES_SEARCH_RESULT message.
+ *
+ * @param {nsILDAPMessage} msg - The received LDAP message.
+ */
+ _onLDAPSearchResult(msg) {
+ this._finished = true;
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/LDAPURLParser.jsm b/comm/mailnews/addrbook/modules/LDAPURLParser.jsm
new file mode 100644
index 0000000000..2c19be1386
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/LDAPURLParser.jsm
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["LDAPURLParser"];
+
+/**
+ * @implements {nsILDAPURLParser}
+ */
+class LDAPURLParser {
+ QueryInterface = ChromeUtils.generateQI(["nsILDAPURLParser"]);
+
+ parse(spec) {
+ // The url is in the form of scheme://hostport/dn?attributes?scope?filter,
+ // see RFC2255.
+ let matches =
+ /^(ldaps?):\/\/\[?([^\s\]/]+)\]?:?(\d*)\/([^\s?]*)\??(.*)$/.exec(spec);
+ if (!matches) {
+ throw Components.Exception(
+ `Invalid LDAP URL: ${spec}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let [, scheme, host, port, dn, query] = matches;
+ let [attributes, scopeString, filter] = query.split("?");
+ let scope =
+ {
+ one: Ci.nsILDAPURL.SCOPE_ONELEVEL,
+ sub: Ci.nsILDAPURL.SCOPE_SUBTREE,
+ }[scopeString] || Ci.nsILDAPURL.SCOPE_BASE;
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsILDAPURLParserResult"]),
+ host,
+ port,
+ dn: decodeURIComponent(dn),
+ attributes,
+ scope,
+ filter: filter ? decodeURIComponent(filter) : "(objectclass=*)",
+ options: scheme == "ldaps" ? Ci.nsILDAPURL.OPT_SECURE : 0,
+ };
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/QueryStringToExpression.jsm b/comm/mailnews/addrbook/modules/QueryStringToExpression.jsm
new file mode 100644
index 0000000000..0129d2e3d3
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/QueryStringToExpression.jsm
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["QueryStringToExpression"];
+
+/**
+ * A module to parse a query string to a nsIAbBooleanExpression. A valid query
+ * string is in this form:
+ *
+ * (OP1(FIELD1,COND1,VALUE1)..(FIELDn,CONDn,VALUEn)(BOOL2(FIELD1,COND1,VALUE1)..)..)
+ *
+ * OPn A boolean operator joining subsequent terms delimited by ().
+ *
+ * @see {nsIAbBooleanOperationTypes}.
+ * FIELDn An addressbook card data field.
+ * CONDn A condition to compare FIELDn with VALUEn.
+ * @see {nsIAbBooleanConditionTypes}.
+ * VALUEn The value to be matched in the FIELDn via the CONDn.
+ * The value must be URL encoded by the caller, if it contains any
+ * special characters including '(' and ')'.
+ */
+var QueryStringToExpression = {
+ /**
+ * Convert a query string to a nsIAbBooleanExpression.
+ *
+ * @param {string} qs - The query string to convert.
+ * @returns {nsIAbBooleanExpression}
+ */
+ convert(qs) {
+ let tokens = this.parse(qs);
+
+ // An array of nsIAbBooleanExpression, the first element is the root exp,
+ // the last element is the current operating exp.
+ let stack = [];
+ for (let { type, depth, value } of tokens) {
+ while (depth < stack.length) {
+ // We are done with the current exp, go one level up.
+ stack.pop();
+ }
+ if (type == "op") {
+ if (depth == stack.length) {
+ // We are done with the current exp, go one level up.
+ stack.pop();
+ }
+ // Found a new exp, go one level down.
+ let parent = stack.slice(-1)[0];
+ let exp = this.createBooleanExpression(value);
+ stack.push(exp);
+ if (parent) {
+ parent.expressions = [...parent.expressions, exp];
+ }
+ } else if (type == "field") {
+ // Add a new nsIAbBooleanConditionString to the current exp.
+ let condition = this.createBooleanConditionString(...value);
+ let exp = stack.slice(-1)[0];
+ exp.expressions = [...exp.expressions, condition];
+ }
+ }
+
+ return stack[0];
+ },
+
+ /**
+ * Parse a query string to an array of tokens.
+ *
+ * @param {string} qs - The query string to parse.
+ * @param {number} depth - The depth of a token.
+ * @param {object[]} tokens - The tokens to return.
+ * @param {"op"|"field"} tokens[].type - The token type.
+ * @param {number} tokens[].depth - The token depth.
+ * @param {string|string[]} tokens[].value - The token value.
+ */
+ parse(qs, depth = 0, tokens = []) {
+ if (qs[0] == "?") {
+ qs = qs.slice(1);
+ }
+ while (qs[0] == ")" && depth > 0) {
+ depth--;
+ qs = qs.slice(1);
+ }
+ if (qs.length == 0) {
+ // End of input.
+ return tokens;
+ }
+ if (qs[0] != "(") {
+ throw Components.Exception(
+ `Invalid query string: ${qs}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ qs = qs.slice(1);
+ let nextOpen = qs.indexOf("(");
+ let nextClose = qs.indexOf(")");
+
+ if (nextOpen != -1 && nextOpen < nextClose) {
+ // Case: "OP("
+ depth++;
+ tokens.push({
+ type: "op",
+ depth,
+ value: qs.slice(0, nextOpen),
+ });
+ this.parse(qs.slice(nextOpen), depth, tokens);
+ } else if (nextClose != -1) {
+ // Case: "FIELD, COND, VALUE)"
+ tokens.push({
+ type: "field",
+ depth,
+ value: qs.slice(0, nextClose).split(","),
+ });
+ this.parse(qs.slice(nextClose + 1), depth, tokens);
+ }
+ return tokens;
+ },
+
+ /**
+ * Create a nsIAbBooleanExpression from a string.
+ *
+ * @param {string} operation - The operation string.
+ * @returns {nsIAbBooleanExpression}
+ */
+ createBooleanExpression(operation) {
+ let op = {
+ and: Ci.nsIAbBooleanOperationTypes.AND,
+ or: Ci.nsIAbBooleanOperationTypes.OR,
+ not: Ci.nsIAbBooleanOperationTypes.NOT,
+ }[operation];
+ if (op == undefined) {
+ throw Components.Exception(
+ `Invalid operation: ${operation}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let exp = Cc["@mozilla.org/boolean-expression/n-peer;1"].createInstance(
+ Ci.nsIAbBooleanExpression
+ );
+ exp.operation = op;
+ return exp;
+ },
+
+ /**
+ * Create a nsIAbBooleanConditionString.
+ *
+ * @param {string} name - The field name.
+ * @param {nsIAbBooleanConditionTypes} condition - The condition.
+ * @param {string} value - The value string.
+ * @returns {nsIAbBooleanConditionString}
+ */
+ createBooleanConditionString(name, condition, value) {
+ value = decodeURIComponent(value);
+ let cond = {
+ "=": Ci.nsIAbBooleanConditionTypes.Is,
+ "!=": Ci.nsIAbBooleanConditionTypes.IsNot,
+ lt: Ci.nsIAbBooleanConditionTypes.LessThan,
+ gt: Ci.nsIAbBooleanConditionTypes.GreaterThan,
+ bw: Ci.nsIAbBooleanConditionTypes.BeginsWith,
+ ew: Ci.nsIAbBooleanConditionTypes.EndsWith,
+ c: Ci.nsIAbBooleanConditionTypes.Contains,
+ "!c": Ci.nsIAbBooleanConditionTypes.DoesNotContain,
+ "~=": Ci.nsIAbBooleanConditionTypes.SoundsLike,
+ regex: Ci.nsIAbBooleanConditionTypes.RegExp,
+ ex: Ci.nsIAbBooleanConditionTypes.Exists,
+ "!ex": Ci.nsIAbBooleanConditionTypes.DoesNotExist,
+ }[condition];
+ if (name == "" || condition == "" || value == "" || cond == undefined) {
+ throw Components.Exception(
+ `Failed to create condition string from name=${name}, condition=${condition}, value=${value}, cond=${cond}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ let cs = Cc[
+ "@mozilla.org/boolean-expression/condition-string;1"
+ ].createInstance(Ci.nsIAbBooleanConditionString);
+ cs.condition = cond;
+
+ try {
+ cs.name = Services.textToSubURI.unEscapeAndConvert("UTF-8", name);
+ cs.value = Services.textToSubURI.unEscapeAndConvert("UTF-8", value);
+ } catch (e) {
+ cs.name = name;
+ cs.value = value;
+ }
+ return cs;
+ },
+};
diff --git a/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm b/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm
new file mode 100644
index 0000000000..a89f2880d7
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm
@@ -0,0 +1,474 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["SQLiteDirectory"];
+
+const { AddrBookDirectory } = ChromeUtils.import(
+ "resource:///modules/AddrBookDirectory.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+const { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ newUID: "resource:///modules/AddrBookUtils.jsm",
+});
+
+var log = console.createInstance({
+ prefix: "mail.addr_book",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "mail.addr_book.loglevel",
+});
+
+// Track all directories by filename, for SQLiteDirectory.forFile.
+var directories = new Map();
+
+// Keep track of all database connections, and close them at shutdown, since
+// nothing else ever tells us to close them.
+var connections = new Map();
+
+/**
+ * Opens an SQLite connection to `file`, caches the connection, and upgrades
+ * the database schema if necessary.
+ */
+function openConnectionTo(file) {
+ const CURRENT_VERSION = 4;
+
+ let connection = connections.get(file.path);
+ if (!connection) {
+ connection = Services.storage.openDatabase(file);
+ let fileVersion = connection.schemaVersion;
+
+ // If we're upgrading the version, first create a backup.
+ if (fileVersion > 0 && fileVersion < CURRENT_VERSION) {
+ let backupFile = file.clone();
+ backupFile.leafName = backupFile.leafName.replace(
+ /\.sqlite$/,
+ `.v${fileVersion}.sqlite`
+ );
+ backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+
+ log.warn(`Backing up ${file.leafName} to ${backupFile.leafName}`);
+ file.copyTo(null, backupFile.leafName);
+ }
+
+ switch (fileVersion) {
+ case 0:
+ connection.executeSimpleSQL("PRAGMA journal_mode=WAL");
+ connection.executeSimpleSQL(
+ "CREATE TABLE properties (card TEXT, name TEXT, value TEXT)"
+ );
+ connection.executeSimpleSQL(
+ "CREATE TABLE lists (uid TEXT PRIMARY KEY, name TEXT, nickName TEXT, description TEXT)"
+ );
+ connection.executeSimpleSQL(
+ "CREATE TABLE list_cards (list TEXT, card TEXT, PRIMARY KEY(list, card))"
+ );
+ // Falls through.
+ case 1:
+ connection.executeSimpleSQL(
+ "CREATE INDEX properties_card ON properties(card)"
+ );
+ connection.executeSimpleSQL(
+ "CREATE INDEX properties_name ON properties(name)"
+ );
+ // Falls through.
+ case 2:
+ connection.executeSimpleSQL("DROP TABLE IF EXISTS cards");
+ // The lists table may have a localId column we no longer use, but
+ // since SQLite can't drop columns it's not worth effort to remove it.
+ // Falls through.
+ case 3:
+ // This version exists only to create an automatic backup before cards
+ // are transitioned to vCard.
+ connection.schemaVersion = CURRENT_VERSION;
+ break;
+ }
+ connections.set(file.path, connection);
+ }
+ return connection;
+}
+
+/**
+ * Closes the SQLite connection to `file` and removes it from the cache.
+ */
+function closeConnectionTo(file) {
+ let connection = connections.get(file.path);
+ if (connection) {
+ return new Promise(resolve => {
+ connection.asyncClose({
+ complete() {
+ resolve();
+ },
+ });
+ connections.delete(file.path);
+ });
+ }
+ return Promise.resolve();
+}
+
+// Close all open connections at shut down time.
+AsyncShutdown.profileBeforeChange.addBlocker(
+ "Address Book: closing databases",
+ async () => {
+ let promises = [];
+ for (let directory of directories.values()) {
+ promises.push(directory.cleanUp());
+ }
+ await Promise.allSettled(promises);
+ }
+);
+
+// Close a connection on demand. This serves as an escape hatch from C++ code.
+Services.obs.addObserver(async file => {
+ file.QueryInterface(Ci.nsIFile);
+ await closeConnectionTo(file);
+ Services.obs.notifyObservers(file, "addrbook-close-ab-complete");
+}, "addrbook-close-ab");
+
+/**
+ * Adds SQLite storage to AddrBookDirectory.
+ */
+class SQLiteDirectory extends AddrBookDirectory {
+ init(uri) {
+ let uriParts = /^[\w-]+:\/\/([\w\.-]+\.\w+)$/.exec(uri);
+ if (!uriParts) {
+ throw new Components.Exception(
+ `Unexpected uri: ${uri}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ this._uri = uri;
+ let fileName = uriParts[1];
+ if (fileName.includes("/")) {
+ fileName = fileName.substring(0, fileName.indexOf("/"));
+ }
+
+ for (let child of Services.prefs.getChildList("ldap_2.servers.")) {
+ if (
+ child.endsWith(".filename") &&
+ Services.prefs.getStringPref(child) == fileName
+ ) {
+ this._dirPrefId = child.substring(0, child.length - ".filename".length);
+ break;
+ }
+ }
+ if (!this._dirPrefId) {
+ throw Components.Exception(
+ `Couldn't grab dirPrefId for uri=${uri}, fileName=${fileName}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ // Make sure we always have a file. If a file is not created, the
+ // filename may be accidentally reused.
+ let file = lazy.FileUtils.getFile("ProfD", [fileName]);
+ if (!file.exists()) {
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+ }
+
+ this._fileName = fileName;
+
+ super.init(uri);
+
+ directories.set(fileName, this);
+ // Create the DB connection here already, to let init() throw on corrupt SQLite files.
+ this._dbConnection;
+ }
+ async cleanUp() {
+ await super.cleanUp();
+
+ if (this.hasOwnProperty("_file")) {
+ await closeConnectionTo(this._file);
+ delete this._file;
+ }
+
+ directories.delete(this._fileName);
+ }
+
+ get _dbConnection() {
+ this._file = lazy.FileUtils.getFile("ProfD", [this.fileName]);
+ let connection = openConnectionTo(this._file);
+
+ // SQLite cache size can be set by the cacheSize preference, in KiB.
+ // The default is 5 MiB but this can be lowered to 1 MiB if wanted.
+ // There is no maximum size.
+ let cacheSize = this.getIntValue("cacheSize", 5120); // 5 MiB
+ cacheSize = Math.max(cacheSize, 1024); // 1 MiB
+ connection.executeSimpleSQL(`PRAGMA cache_size=-${cacheSize}`);
+
+ Object.defineProperty(this, "_dbConnection", {
+ enumerable: true,
+ value: connection,
+ writable: false,
+ });
+ return connection;
+ }
+ get lists() {
+ let listCache = new Map();
+ let selectStatement = this._dbConnection.createStatement(
+ "SELECT uid, name, nickName, description FROM lists"
+ );
+ while (selectStatement.executeStep()) {
+ listCache.set(selectStatement.row.uid, {
+ uid: selectStatement.row.uid,
+ name: selectStatement.row.name,
+ nickName: selectStatement.row.nickName,
+ description: selectStatement.row.description,
+ });
+ }
+ selectStatement.finalize();
+
+ Object.defineProperty(this, "lists", {
+ enumerable: true,
+ value: listCache,
+ writable: false,
+ });
+ return listCache;
+ }
+ get cards() {
+ let cardCache = new Map();
+ let propertiesStatement = this._dbConnection.createStatement(
+ "SELECT card, name, value FROM properties"
+ );
+ while (propertiesStatement.executeStep()) {
+ let uid = propertiesStatement.row.card;
+ if (!cardCache.has(uid)) {
+ cardCache.set(uid, new Map());
+ }
+ let card = cardCache.get(uid);
+ if (card) {
+ card.set(propertiesStatement.row.name, propertiesStatement.row.value);
+ }
+ }
+ propertiesStatement.finalize();
+
+ Object.defineProperty(this, "cards", {
+ enumerable: true,
+ value: cardCache,
+ writable: false,
+ });
+ return cardCache;
+ }
+
+ loadCardProperties(uid) {
+ if (this.hasOwnProperty("cards")) {
+ let cachedCard = this.cards.get(uid);
+ if (cachedCard) {
+ return new Map(cachedCard);
+ }
+ }
+ let properties = new Map();
+ let propertyStatement = this._dbConnection.createStatement(
+ "SELECT name, value FROM properties WHERE card = :card"
+ );
+ propertyStatement.params.card = uid;
+ while (propertyStatement.executeStep()) {
+ properties.set(propertyStatement.row.name, propertyStatement.row.value);
+ }
+ propertyStatement.finalize();
+ return properties;
+ }
+ saveCardProperties(uid, properties) {
+ try {
+ this._dbConnection.beginTransaction();
+ let deleteStatement = this._dbConnection.createStatement(
+ "DELETE FROM properties WHERE card = :card"
+ );
+ deleteStatement.params.card = uid;
+ deleteStatement.execute();
+ let insertStatement = this._dbConnection.createStatement(
+ "INSERT INTO properties VALUES (:card, :name, :value)"
+ );
+
+ for (let [name, value] of properties) {
+ if (value !== null && value !== undefined && value !== "") {
+ insertStatement.params.card = uid;
+ insertStatement.params.name = name;
+ insertStatement.params.value = value;
+ insertStatement.execute();
+ insertStatement.reset();
+ }
+ }
+
+ this._dbConnection.commitTransaction();
+ deleteStatement.finalize();
+ insertStatement.finalize();
+ } catch (ex) {
+ this._dbConnection.rollbackTransaction();
+ throw ex;
+ }
+ }
+ deleteCard(uid) {
+ let deleteStatement = this._dbConnection.createStatement(
+ "DELETE FROM properties WHERE card = :cardUID"
+ );
+ deleteStatement.params.cardUID = uid;
+ deleteStatement.execute();
+ deleteStatement.finalize();
+ }
+ saveList(list) {
+ // Ensure list cache exists.
+ this.lists;
+
+ let replaceStatement = this._dbConnection.createStatement(
+ "REPLACE INTO lists (uid, name, nickName, description) " +
+ "VALUES (:uid, :name, :nickName, :description)"
+ );
+ replaceStatement.params.uid = list._uid;
+ replaceStatement.params.name = list._name;
+ replaceStatement.params.nickName = list._nickName;
+ replaceStatement.params.description = list._description;
+ replaceStatement.execute();
+ replaceStatement.finalize();
+
+ this.lists.set(list._uid, {
+ uid: list._uid,
+ name: list._name,
+ nickName: list._nickName,
+ description: list._description,
+ });
+ }
+ deleteList(uid) {
+ let deleteListStatement = this._dbConnection.createStatement(
+ "DELETE FROM lists WHERE uid = :uid"
+ );
+ deleteListStatement.params.uid = uid;
+ deleteListStatement.execute();
+ deleteListStatement.finalize();
+
+ if (this.hasOwnProperty("lists")) {
+ this.lists.delete(uid);
+ }
+
+ this._dbConnection.executeSimpleSQL(
+ "DELETE FROM list_cards WHERE list NOT IN (SELECT DISTINCT uid FROM lists)"
+ );
+ }
+ async bulkAddCards(cards) {
+ if (cards.length == 0) {
+ return;
+ }
+
+ let usedUIDs = new Set();
+ let propertiesStatement = this._dbConnection.createStatement(
+ "INSERT INTO properties VALUES (:card, :name, :value)"
+ );
+ let propertiesArray = propertiesStatement.newBindingParamsArray();
+ for (let card of cards) {
+ let uid = card.UID;
+ if (!uid || usedUIDs.has(uid)) {
+ // A card cannot have the same UID as one that already exists.
+ // Assign a new UID to avoid losing data.
+ uid = lazy.newUID();
+ }
+ usedUIDs.add(uid);
+
+ let cachedCard;
+ if (this.hasOwnProperty("cards")) {
+ cachedCard = new Map();
+ this.cards.set(uid, cachedCard);
+ }
+
+ for (let [name, value] of this.prepareToSaveCard(card)) {
+ let propertiesParams = propertiesArray.newBindingParams();
+ propertiesParams.bindByName("card", uid);
+ propertiesParams.bindByName("name", name);
+ propertiesParams.bindByName("value", value);
+ propertiesArray.addParams(propertiesParams);
+
+ if (cachedCard) {
+ cachedCard.set(name, value);
+ }
+ }
+ }
+ try {
+ this._dbConnection.beginTransaction();
+ if (propertiesArray.length > 0) {
+ propertiesStatement.bindParameters(propertiesArray);
+ await new Promise((resolve, reject) => {
+ propertiesStatement.executeAsync({
+ handleError(error) {
+ this._error = error;
+ },
+ handleCompletion(status) {
+ if (status == Ci.mozIStorageStatementCallback.REASON_ERROR) {
+ reject(
+ Components.Exception(this._error.message, Cr.NS_ERROR_FAILURE)
+ );
+ } else {
+ resolve();
+ }
+ },
+ });
+ });
+ propertiesStatement.finalize();
+ }
+ this._dbConnection.commitTransaction();
+
+ Services.obs.notifyObservers(this, "addrbook-directory-invalidated");
+ } catch (ex) {
+ this._dbConnection.rollbackTransaction();
+ throw ex;
+ }
+ }
+
+ /* nsIAbDirectory */
+
+ get childCardCount() {
+ let countStatement = this._dbConnection.createStatement(
+ "SELECT COUNT(DISTINCT card) AS card_count FROM properties"
+ );
+ countStatement.executeStep();
+ let count = countStatement.row.card_count;
+ countStatement.finalize();
+ return count;
+ }
+ getCardFromProperty(property, value, caseSensitive) {
+ let sql = caseSensitive
+ ? "SELECT card FROM properties WHERE name = :name AND value = :value LIMIT 1"
+ : "SELECT card FROM properties WHERE name = :name AND LOWER(value) = LOWER(:value) LIMIT 1";
+ let selectStatement = this._dbConnection.createStatement(sql);
+ selectStatement.params.name = property;
+ selectStatement.params.value = value;
+ let result = null;
+ if (selectStatement.executeStep()) {
+ result = this.getCard(selectStatement.row.card);
+ }
+ selectStatement.finalize();
+ return result;
+ }
+ getCardsFromProperty(property, value, caseSensitive) {
+ let sql = caseSensitive
+ ? "SELECT card FROM properties WHERE name = :name AND value = :value"
+ : "SELECT card FROM properties WHERE name = :name AND LOWER(value) = LOWER(:value)";
+ let selectStatement = this._dbConnection.createStatement(sql);
+ selectStatement.params.name = property;
+ selectStatement.params.value = value;
+ let results = [];
+ while (selectStatement.executeStep()) {
+ results.push(this.getCard(selectStatement.row.card));
+ }
+ selectStatement.finalize();
+ return results;
+ }
+
+ static forFile(fileName) {
+ return directories.get(fileName);
+ }
+}
+SQLiteDirectory.prototype.classID = Components.ID(
+ "{e96ee804-0bd3-472f-81a6-8a9d65277ad3}"
+);
diff --git a/comm/mailnews/addrbook/modules/VCardUtils.jsm b/comm/mailnews/addrbook/modules/VCardUtils.jsm
new file mode 100644
index 0000000000..a3ff0f5e14
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/VCardUtils.jsm
@@ -0,0 +1,973 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "VCardService",
+ "VCardMimeConverter",
+ "VCardProperties",
+ "VCardPropertyEntry",
+ "VCardUtils",
+ "BANISHED_PROPERTIES",
+];
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+});
+
+/**
+ * Utilities for working with vCard data. This file uses ICAL.js as parser and
+ * formatter to avoid reinventing the wheel.
+ *
+ * @see RFC 6350.
+ */
+
+var VCardUtils = {
+ _decodeQuotedPrintable(value) {
+ let bytes = [];
+ for (let b = 0; b < value.length; b++) {
+ if (value[b] == "=") {
+ bytes.push(parseInt(value.substr(b + 1, 2), 16));
+ b += 2;
+ } else {
+ bytes.push(value.charCodeAt(b));
+ }
+ }
+ return new TextDecoder().decode(new Uint8Array(bytes));
+ },
+ _parse(vProps) {
+ let vPropMap = new Map();
+ for (let index = 0; index < vProps.length; index++) {
+ let { name, params, value } = vProps[index];
+
+ // Work out which type in typeMap, if any, this property belongs to.
+
+ // To make the next piece easier, the type param must always be an array
+ // of lower-case strings.
+ let type = params.type || [];
+ if (type) {
+ if (Array.isArray(type)) {
+ type = type.map(t => t.toLowerCase());
+ } else {
+ type = [type.toLowerCase()];
+ }
+ }
+
+ // Special cases for address and telephone types.
+ if (name == "adr") {
+ name = type.includes("home") ? "adr.home" : "adr.work";
+ }
+ if (name == "tel") {
+ name = "tel.work";
+ for (let t of type) {
+ if (["home", "work", "cell", "pager", "fax"].includes(t)) {
+ name = `tel.${t}`;
+ break;
+ }
+ }
+ }
+ // Preserve URL if no URL with type work is given take for `url.work` the URL without any type.
+ if (name == "url") {
+ name = type.includes("home") ? "url.home" : name;
+ name = type.includes("work") ? "url.work" : name;
+ }
+
+ // Special treatment for `url`, which is not in the typeMap.
+ if (!(name in typeMap) && name != "url") {
+ continue;
+ }
+
+ // The preference param is 1-100, lower numbers indicate higher
+ // preference. If not specified, the value is least preferred.
+ let pref = parseInt(params.pref, 10) || 101;
+
+ if (!vPropMap.has(name)) {
+ vPropMap.set(name, []);
+ }
+ vPropMap.get(name).push({ index, pref, value });
+ }
+
+ // If no URL with type is specified assume its the Work Web Page (WebPage 1).
+ if (vPropMap.has("url") && !vPropMap.has("url.work")) {
+ vPropMap.set("url.work", vPropMap.get("url"));
+ }
+ // AbCard only supports Work Web Page or Home Web Page. Get rid of the URL without type.
+ vPropMap.delete("url");
+
+ for (let props of vPropMap.values()) {
+ // Sort the properties by preference, or by the order they appeared.
+ props.sort((a, b) => {
+ if (a.pref == b.pref) {
+ return a.index - b.index;
+ }
+ return a.pref - b.pref;
+ });
+ }
+ return vPropMap;
+ },
+ /**
+ * ICAL.js's parser only supports vCard 3.0 and 4.0. To maintain
+ * interoperability with other applications, here we convert vCard 2.1
+ * cards into a "good-enough" mimic of vCard 4.0 so that the parser will
+ * read it without throwing an error.
+ *
+ * @param {string} vCard
+ * @returns {string}
+ */
+ translateVCard21(vCard) {
+ if (!/\bVERSION:2.1\b/i.test(vCard)) {
+ return vCard;
+ }
+
+ // Convert known type parameters to valid vCard 4.0, ignore unknown ones.
+ vCard = vCard.replace(/\n(([A-Z]+)(;[\w-]*)+):/gi, (match, key) => {
+ let parts = key.split(";");
+ let newParts = [parts[0]];
+ for (let i = 1; i < parts.length; i++) {
+ if (parts[i] == "") {
+ continue;
+ }
+ if (
+ ["HOME", "WORK", "FAX", "PAGER", "CELL"].includes(
+ parts[i].toUpperCase()
+ )
+ ) {
+ newParts.push(`TYPE=${parts[i]}`);
+ } else if (parts[i].toUpperCase() == "PREF") {
+ newParts.push("PREF=1");
+ } else if (parts[i].toUpperCase() == "QUOTED-PRINTABLE") {
+ newParts.push("ENCODING=QUOTED-PRINTABLE");
+ }
+ }
+ return "\n" + newParts.join(";") + ":";
+ });
+
+ // Join quoted-printable wrapped lines together. This regular expression
+ // only matches lines that are quoted-printable and end with `=`.
+ let quotedNewLineRegExp = /(;ENCODING=QUOTED-PRINTABLE[;:][^\r\n]*)=\r?\n/i;
+ while (vCard.match(quotedNewLineRegExp)) {
+ vCard = vCard.replace(quotedNewLineRegExp, "$1");
+ }
+
+ // Strip the version.
+ return vCard.replace(/(\r?\n)VERSION:2.1\r?\n/i, "$1");
+ },
+ /**
+ * Return a new AddrBookCard from the provided vCard string.
+ *
+ * @param {string} vCard - The vCard string.
+ * @param {string} [uid] - An optional UID to be used for the new card,
+ * overriding any UID specified in the vCard string.
+ * @returns {AddrBookCard}
+ */
+ vCardToAbCard(vCard, uid) {
+ vCard = this.translateVCard21(vCard);
+
+ let abCard = new lazy.AddrBookCard();
+ abCard.setProperty("_vCard", vCard);
+
+ let vCardUID = abCard.vCardProperties.getFirstValue("uid");
+ if (uid || vCardUID) {
+ abCard.UID = uid || vCardUID;
+ if (abCard.UID != vCardUID) {
+ abCard.vCardProperties.clearValues("uid");
+ abCard.vCardProperties.addValue("uid", abCard.UID);
+ }
+ }
+
+ return abCard;
+ },
+ abCardToVCard(abCard, version) {
+ if (abCard.supportsVCard && abCard.getProperty("_vCard")) {
+ return abCard.vCardProperties.toVCard();
+ }
+
+ // Collect all of the AB card properties into a Map.
+ let abProps = new Map(
+ Array.from(abCard.properties, p => [p.name, p.value])
+ );
+ abProps.set("UID", abCard.UID);
+
+ return this.propertyMapToVCard(abProps, version);
+ },
+ propertyMapToVCard(abProps, version = "4.0") {
+ let vProps = [["version", {}, "text", version]];
+
+ // Add the properties to the vCard.
+ for (let vPropName of Object.keys(typeMap)) {
+ for (let vProp of typeMap[vPropName].fromAbCard(abProps, vPropName)) {
+ if (vProp[3] !== null && vProp[3] !== undefined && vProp[3] !== "") {
+ vProps.push(vProp);
+ }
+ }
+ }
+
+ // If there's only one address or telephone number, don't specify type.
+ let adrProps = vProps.filter(p => p[0] == "adr");
+ if (adrProps.length == 1) {
+ delete adrProps[0][1].type;
+ }
+ let telProps = vProps.filter(p => p[0] == "tel");
+ if (telProps.length == 1) {
+ delete telProps[0][1].type;
+ }
+
+ if (abProps.has("UID")) {
+ vProps.push(["uid", {}, "text", abProps.get("UID")]);
+ }
+ return ICAL.stringify(["vcard", vProps]);
+ },
+};
+
+function VCardService() {}
+VCardService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgVCardService"]),
+ classID: Components.ID("{e2e0f615-bc5a-4441-a16b-a26e75949376}"),
+
+ vCardToAbCard(vCard) {
+ return vCard ? VCardUtils.vCardToAbCard(vCard) : null;
+ },
+ escapedVCardToAbCard(vCard) {
+ return vCard ? VCardUtils.vCardToAbCard(decodeURIComponent(vCard)) : null;
+ },
+ abCardToEscapedVCard(abCard) {
+ return abCard ? encodeURIComponent(VCardUtils.abCardToVCard(abCard)) : null;
+ },
+};
+
+function VCardMimeConverter() {}
+VCardMimeConverter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleMimeConverter"]),
+ classID: Components.ID("{dafab386-bd4c-4238-bb48-228fbc98ba29}"),
+
+ mailChannel: null,
+ uri: null,
+ convertToHTML(contentType, data) {
+ function escapeHTML(template, ...parts) {
+ let arr = [];
+ for (let i = 0; i < parts.length; i++) {
+ arr.push(template[i]);
+ arr.push(
+ parts[i]
+ .replace(/&/g, "&amp;")
+ .replace(/"/g, "&quot;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ );
+ }
+ arr.push(template[template.length - 1]);
+ return arr.join("");
+ }
+
+ let abCard;
+ try {
+ abCard = VCardUtils.vCardToAbCard(data);
+ } catch (e) {
+ // We were given invalid vcard data.
+ return "";
+ }
+
+ let escapedVCard = encodeURIComponent(data);
+
+ let propertiesTable = `<table class="moz-vcard-properties-table">`;
+ propertiesTable += escapeHTML`<tr><td class="moz-vcard-title-property">${abCard.displayName}`;
+ if (abCard.primaryEmail) {
+ propertiesTable += escapeHTML`&nbsp;&lt;<a href="mailto:${abCard.primaryEmail}" private>${abCard.primaryEmail}</a>&gt;`;
+ }
+ propertiesTable += `</td></tr>`;
+ for (let propName of ["JobTitle", "Department", "Company"]) {
+ let propValue = abCard.getProperty(propName, "");
+ if (propValue) {
+ propertiesTable += escapeHTML`<tr><td class="moz-vcard-property">${propValue}</td></tr>`;
+ }
+ }
+ propertiesTable += `</table>`;
+
+ // VCardChild.jsm and VCardParent.jsm handle clicking on this link.
+ return `<html>
+ <body>
+ <table class="moz-vcard-table">
+ <tr>
+ <td valign="top"><a class="moz-vcard-badge" href="data:text/vcard,${escapedVCard}"></a></td>
+ <td>
+ ${propertiesTable}
+ </td>
+ </tr>
+ </table>
+ </body>
+ </html>`;
+ },
+};
+
+const BANISHED_PROPERTIES = [
+ "UID",
+ "PrimaryEmail",
+ "SecondEmail",
+ "DisplayName",
+ "NickName",
+ "Notes",
+ "Company",
+ "Department",
+ "JobTitle",
+ "BirthDay",
+ "BirthMonth",
+ "BirthYear",
+ "AnniversaryDay",
+ "AnniversaryMonth",
+ "AnniversaryYear",
+ "LastName",
+ "FirstName",
+ "AdditionalNames",
+ "NamePrefix",
+ "NameSuffix",
+ "HomePOBox",
+ "HomeAddress2",
+ "HomeAddress",
+ "HomeCity",
+ "HomeState",
+ "HomeZipCode",
+ "HomeCountry",
+ "WorkPOBox",
+ "WorkAddress2",
+ "WorkAddress",
+ "WorkCity",
+ "WorkState",
+ "WorkZipCode",
+ "WorkCountry",
+ "HomePhone",
+ "WorkPhone",
+ "FaxNumber",
+ "PagerNumber",
+ "CellularNumber",
+ "WebPage1",
+ "WebPage2",
+ "Custom1",
+ "Custom2",
+ "Custom3",
+ "Custom4",
+];
+
+/** Helper functions for typeMap. */
+
+function singleTextProperty(
+ abPropName,
+ vPropName,
+ vPropParams = {},
+ vPropType = "text"
+) {
+ return {
+ /**
+ * Formats nsIAbCard properties into an array for use by ICAL.js.
+ *
+ * @param {Map} map - A map of address book properties to map.
+ * @yields {Array} - Values in a jCard array for use with ICAL.js.
+ */
+ *fromAbCard(map) {
+ yield [vPropName, { ...vPropParams }, vPropType, map.get(abPropName)];
+ },
+ /**
+ * Parses a vCard value into properties usable by nsIAbCard.
+ *
+ * @param {string} value - vCard string to map to an address book card property.
+ * @yields {string[]} - Any number of key, value pairs to set on the nsIAbCard.
+ */
+ *toAbCard(value) {
+ if (typeof value != "string") {
+ console.warn(`Unexpected value for ${vPropName}: ${value}`);
+ return;
+ }
+ yield [abPropName, value];
+ },
+ };
+}
+function dateProperty(abCardPrefix, vPropName) {
+ return {
+ *fromAbCard(map) {
+ let year = map.get(`${abCardPrefix}Year`);
+ let month = map.get(`${abCardPrefix}Month`);
+ let day = map.get(`${abCardPrefix}Day`);
+
+ if (!year && !month && !day) {
+ return;
+ }
+
+ let dateValue = new ICAL.VCardTime({}, null, "date");
+ // Set the properties directly instead of using the VCardTime
+ // constructor argument, which causes null values to become 0.
+ dateValue.year = year ? Number(year) : null;
+ dateValue.month = month ? Number(month) : null;
+ dateValue.day = day ? Number(day) : null;
+
+ yield [vPropName, {}, "date", dateValue.toString()];
+ },
+ *toAbCard(value) {
+ try {
+ let dateValue = ICAL.VCardTime.fromDateAndOrTimeString(value);
+ yield [`${abCardPrefix}Year`, String(dateValue.year ?? "")];
+ yield [`${abCardPrefix}Month`, String(dateValue.month ?? "")];
+ yield [`${abCardPrefix}Day`, String(dateValue.day ?? "")];
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+ };
+}
+function multiTextProperty(abPropNames, vPropName, vPropParams = {}) {
+ return {
+ *fromAbCard(map) {
+ if (abPropNames.every(name => !map.has(name))) {
+ return;
+ }
+ let vPropValues = abPropNames.map(name => map.get(name) || "");
+ if (vPropValues.some(Boolean)) {
+ yield [vPropName, { ...vPropParams }, "text", vPropValues];
+ }
+ },
+ *toAbCard(value) {
+ if (Array.isArray(value)) {
+ for (let abPropName of abPropNames) {
+ let valuePart = value.shift();
+ if (abPropName && valuePart) {
+ yield [
+ abPropName,
+ Array.isArray(valuePart) ? valuePart.join(" ") : valuePart,
+ ];
+ }
+ }
+ } else if (typeof value == "string") {
+ // Only one value was given.
+ yield [abPropNames[0], value];
+ } else {
+ console.warn(`Unexpected value for ${vPropName}: ${value}`);
+ }
+ },
+ };
+}
+
+/**
+ * Properties we support for conversion between nsIAbCard and vCard.
+ *
+ * Keys correspond to vCard property keys, with the type appended where more
+ * than one type is supported (e.g. work and home).
+ *
+ * Values are objects with toAbCard and fromAbCard functions which convert
+ * property values in each direction. See the docs on the object returned by
+ * singleTextProperty.
+ */
+var typeMap = {
+ fn: singleTextProperty("DisplayName", "fn"),
+ email: {
+ *fromAbCard(map) {
+ yield ["email", { pref: "1" }, "text", map.get("PrimaryEmail")];
+ yield ["email", {}, "text", map.get("SecondEmail")];
+ },
+ toAbCard: singleTextProperty("PrimaryEmail", "email", { pref: "1" })
+ .toAbCard,
+ },
+ nickname: singleTextProperty("NickName", "nickname"),
+ note: singleTextProperty("Notes", "note"),
+ org: multiTextProperty(["Company", "Department"], "org"),
+ title: singleTextProperty("JobTitle", "title"),
+ bday: dateProperty("Birth", "bday"),
+ anniversary: dateProperty("Anniversary", "anniversary"),
+ n: multiTextProperty(
+ ["LastName", "FirstName", "AdditionalNames", "NamePrefix", "NameSuffix"],
+ "n"
+ ),
+ "adr.home": multiTextProperty(
+ [
+ "HomePOBox",
+ "HomeAddress2",
+ "HomeAddress",
+ "HomeCity",
+ "HomeState",
+ "HomeZipCode",
+ "HomeCountry",
+ ],
+ "adr",
+ { type: "home" }
+ ),
+ "adr.work": multiTextProperty(
+ [
+ "WorkPOBox",
+ "WorkAddress2",
+ "WorkAddress",
+ "WorkCity",
+ "WorkState",
+ "WorkZipCode",
+ "WorkCountry",
+ ],
+ "adr",
+ { type: "work" }
+ ),
+ "tel.home": singleTextProperty("HomePhone", "tel", { type: "home" }),
+ "tel.work": singleTextProperty("WorkPhone", "tel", { type: "work" }),
+ "tel.fax": singleTextProperty("FaxNumber", "tel", { type: "fax" }),
+ "tel.pager": singleTextProperty("PagerNumber", "tel", { type: "pager" }),
+ "tel.cell": singleTextProperty("CellularNumber", "tel", { type: "cell" }),
+ "url.work": singleTextProperty("WebPage1", "url", { type: "work" }, "url"),
+ "url.home": singleTextProperty("WebPage2", "url", { type: "home" }, "url"),
+ "x-custom1": singleTextProperty("Custom1", "x-custom1"),
+ "x-custom2": singleTextProperty("Custom2", "x-custom2"),
+ "x-custom3": singleTextProperty("Custom3", "x-custom3"),
+ "x-custom4": singleTextProperty("Custom4", "x-custom4"),
+};
+
+/**
+ * Any value that can be represented in a vCard. A value can be a boolean,
+ * number, string, or an array, depending on the data. A top-level array might
+ * contain primitives and/or second-level arrays of primitives.
+ *
+ * @see ICAL.design
+ * @see RFC6350
+ *
+ * @typedef {boolean|number|string|vCardValue[]} vCardValue
+ */
+
+/**
+ * Represents a single entry in a vCard ("contentline" in RFC6350 terms).
+ * The name, params, type and value are as returned by ICAL.
+ */
+class VCardPropertyEntry {
+ #name = null;
+ #params = null;
+ #type = null;
+ #value = null;
+ _original = null;
+
+ /**
+ * @param {string} name
+ * @param {object} params
+ * @param {string} type
+ * @param {vCardValue} value
+ */
+ constructor(name, params, type, value) {
+ this.#name = name;
+ this.#params = params;
+ this.#type = type;
+ if (params.encoding?.toUpperCase() == "QUOTED-PRINTABLE") {
+ if (Array.isArray(value)) {
+ value = value.map(VCardUtils._decodeQuotedPrintable);
+ } else {
+ value = VCardUtils._decodeQuotedPrintable(value);
+ }
+ delete params.encoding;
+ delete params.charset;
+ }
+ this.#value = value;
+ this._original = this;
+ }
+
+ /**
+ * @type {string}
+ */
+ get name() {
+ return this.#name;
+ }
+
+ /**
+ * @type {object}
+ */
+ get params() {
+ return this.#params;
+ }
+
+ /**
+ * @type {string}
+ */
+ get type() {
+ return this.#type;
+ }
+ set type(type) {
+ this.#type = type;
+ }
+
+ /**
+ * @type {vCardValue}
+ */
+ get value() {
+ return this.#value;
+ }
+ set value(value) {
+ this.#value = value;
+ }
+
+ /**
+ * Clone this object.
+ *
+ * @returns {VCardPropertyEntry}
+ */
+ clone() {
+ let cloneValue;
+ if (Array.isArray(this.#value)) {
+ cloneValue = this.#value.map(v => (Array.isArray(v) ? v.slice() : v));
+ } else {
+ cloneValue = this.#value;
+ }
+
+ let clone = new VCardPropertyEntry(
+ this.#name,
+ { ...this.#params },
+ this.#type,
+ cloneValue
+ );
+ clone._original = this;
+ return clone;
+ }
+
+ /**
+ * @param {VCardPropertyEntry} other
+ */
+ equals(other) {
+ if (other.constructor.name != "VCardPropertyEntry") {
+ return false;
+ }
+ return this._original == other._original;
+ }
+}
+
+/**
+ * Represents an entire vCard as a collection of `VCardPropertyEntry` objects.
+ */
+class VCardProperties {
+ /**
+ * All of the vCard entries in this object.
+ *
+ * @type {VCardPropertyEntry[]}
+ */
+ entries = [];
+
+ /**
+ * @param {?string} version - The version of vCard to use. Valid values are
+ * "3.0" and "4.0". If unspecified, vCard 3.0 will be used.
+ */
+ constructor(version) {
+ if (version) {
+ if (!["3.0", "4.0"].includes(version)) {
+ throw new Error(`Unsupported vCard version: ${version}`);
+ }
+ this.addEntry(new VCardPropertyEntry("version", {}, "text", version));
+ }
+ }
+
+ /**
+ * Parse a vCard into a VCardProperties object.
+ *
+ * @param {string} vCard
+ * @returns {VCardProperties}
+ */
+ static fromVCard(vCard, { isGoogleCardDAV = false } = {}) {
+ vCard = VCardUtils.translateVCard21(vCard);
+
+ let rv = new VCardProperties();
+ let [, properties] = ICAL.parse(vCard);
+ for (let property of properties) {
+ let [name, params, type, value] = property;
+ if (property.length > 4) {
+ // The jCal format stores multiple values as the 4th...nth items.
+ // VCardPropertyEntry has only one place for a value, so store an
+ // array instead. This applies to CATEGORIES and NICKNAME types in
+ // vCard 4 and also NOTE in vCard 3.
+ value = property.slice(3);
+ }
+ if (isGoogleCardDAV) {
+ // Google escapes the characters \r : , ; and \ unnecessarily, in
+ // violation of RFC6350. Removing the escaping at this point means no
+ // other code requires a special case for it.
+ if (Array.isArray(value)) {
+ value = value.map(v => v.replace(/\\r/g, "\r").replace(/\\:/g, ":"));
+ } else {
+ value = value.replace(/\\r/g, "\r").replace(/\\:/g, ":");
+ if (["phone-number", "uri"].includes(type)) {
+ value = value.replace(/\\([,;\\])/g, "$1");
+ }
+ }
+ }
+ rv.addEntry(new VCardPropertyEntry(name, params, type, value));
+ }
+ return rv;
+ }
+
+ /**
+ * Parse a Map of Address Book properties into a VCardProperties object.
+ *
+ * @param {Map<string, string>} propertyMap
+ * @param {string} [version="4.0"]
+ * @returns {VCardProperties}
+ */
+ static fromPropertyMap(propertyMap, version = "4.0") {
+ let rv = new VCardProperties(version);
+
+ for (let vPropName of Object.keys(typeMap)) {
+ for (let vProp of typeMap[vPropName].fromAbCard(propertyMap, vPropName)) {
+ if (vProp[3] !== null && vProp[3] !== undefined && vProp[3] !== "") {
+ rv.addEntry(new VCardPropertyEntry(...vProp));
+ }
+ }
+ }
+
+ return rv;
+ }
+
+ /**
+ * Used to determine the default value type when adding values.
+ * Either `ICAL.design.vcard` for (vCard 4.0) or `ICAL.design.vcard3` (3.0).
+ *
+ * @type {ICAL.design.designSet}
+ */
+ designSet = ICAL.design.vcard3;
+
+ /**
+ * Add an entry to this object.
+ *
+ * @param {VCardPropertyEntry} entry - The entry to add.
+ * @returns {boolean} - If the entry was added.
+ */
+ addEntry(entry) {
+ if (entry.constructor.name != "VCardPropertyEntry") {
+ throw new Error("Not a VCardPropertyEntry");
+ }
+
+ if (this.entries.find(e => e.equals(entry))) {
+ return false;
+ }
+
+ if (entry.name == "version") {
+ if (entry.value == "3.0") {
+ this.designSet = ICAL.design.vcard3;
+ } else if (entry.value == "4.0") {
+ this.designSet = ICAL.design.vcard;
+ } else {
+ throw new Error(`Unsupported vCard version: ${entry.value}`);
+ }
+ // Version must be the first entry, so clear out any existing values
+ // and add it to the start of the collection.
+ this.clearValues("version");
+ this.entries.unshift(entry);
+ return true;
+ }
+
+ this.entries.push(entry);
+ return true;
+ }
+
+ /**
+ * Add an entry to this object by name and value.
+ *
+ * @param {string} name
+ * @param {string} value
+ * @returns {VCardPropertyEntry}
+ */
+ addValue(name, value) {
+ for (let entry of this.getAllEntries(name)) {
+ if (entry.value == value) {
+ return entry;
+ }
+ }
+
+ let newEntry = new VCardPropertyEntry(
+ name,
+ {},
+ this.designSet.property[name].defaultType,
+ value
+ );
+ this.entries.push(newEntry);
+ return newEntry;
+ }
+
+ /**
+ * Remove an entry from this object.
+ *
+ * @param {VCardPropertyEntry} entry - The entry to remove.
+ * @returns {boolean} - If an entry was found and removed.
+ */
+ removeEntry(entry) {
+ if (entry.constructor.name != "VCardPropertyEntry") {
+ throw new Error("Not a VCardPropertyEntry");
+ }
+
+ let index = this.entries.findIndex(e => e.equals(entry));
+ if (index >= 0) {
+ this.entries.splice(index, 1);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Remove entries from this object by name and value. All entries matching
+ * the name and value will be removed.
+ *
+ * @param {string} name
+ * @param {string} value
+ */
+ removeValue(name, value) {
+ for (let entry of this.getAllEntries(name)) {
+ if (entry.value == value) {
+ this.removeEntry(entry);
+ }
+ }
+ }
+
+ /**
+ * Remove entries from this object by name. All entries matching the name
+ * will be removed.
+ *
+ * @param {string} name
+ */
+ clearValues(name) {
+ for (let entry of this.getAllEntries(name)) {
+ this.removeEntry(entry);
+ }
+ }
+
+ /**
+ * Get the first value matching the given name, or null if no entry matches.
+ *
+ * @param {string} name
+ * @returns {?vCardValue}
+ */
+ getFirstValue(name) {
+ let entry = this.entries.find(e => e.name == name);
+ if (entry) {
+ return entry.value;
+ }
+ return null;
+ }
+
+ /**
+ * Get all values matching the given name.
+ *
+ * @param {string} name
+ * @returns {vCardValue[]}
+ */
+ getAllValues(name) {
+ return this.getAllEntries(name).map(e => e.value);
+ }
+
+ /**
+ * Get all values matching the given name, sorted in order of preference.
+ * Preference is determined by the `pref` parameter if it exists, then by
+ * the position in `entries`.
+ *
+ * @param {string} name
+ * @returns {vCardValue[]}
+ */
+ getAllValuesSorted(name) {
+ return this.getAllEntriesSorted(name).map(e => e.value);
+ }
+
+ /**
+ * Get the first entry matching the given name, or null if no entry matches.
+ *
+ * @param {string} name
+ * @returns {?VCardPropertyEntry}
+ */
+ getFirstEntry(name) {
+ return this.entries.find(e => e.name == name) ?? null;
+ }
+
+ /**
+ * Get all entries matching the given name.
+ *
+ * @param {string} name
+ * @returns {VCardPropertyEntry[]}
+ */
+ getAllEntries(name) {
+ return this.entries.filter(e => e.name == name);
+ }
+
+ /**
+ * Get all entries matching the given name, sorted in order of preference.
+ * Preference is determined by the `pref` parameter if it exists, then by
+ * the position in `entries`.
+ *
+ * @param {string} name
+ * @returns {VCardPropertyEntry[]}
+ */
+ getAllEntriesSorted(name) {
+ let nextPref = 101;
+ let entries = this.getAllEntries(name).map(e => {
+ return { entry: e, pref: e.params.pref || nextPref++ };
+ });
+ entries.sort((a, b) => a.pref - b.pref);
+ return entries.map(e => e.entry);
+ }
+
+ /**
+ * Get all entries matching the given group.
+ *
+ * @param {string} group
+ * @returns {VCardPropertyEntry[]}
+ */
+ getGroupedEntries(group) {
+ return this.entries.filter(e => e.params.group == group);
+ }
+
+ /**
+ * Clone this object.
+ *
+ * @returns {VCardProperties}
+ */
+ clone() {
+ let copy = new VCardProperties();
+ copy.entries = this.entries.map(e => e.clone());
+ return copy;
+ }
+
+ /**
+ * Get a Map of Address Book properties from this object.
+ *
+ * @returns {Map<string, string>} propertyMap
+ */
+ toPropertyMap() {
+ let vPropMap = VCardUtils._parse(this.entries.map(e => e.clone()));
+ let propertyMap = new Map();
+
+ for (let [name, props] of vPropMap) {
+ // Store the value(s) on the abCard.
+ for (let [abPropName, abPropValue] of typeMap[name].toAbCard(
+ props[0].value
+ )) {
+ if (abPropValue) {
+ propertyMap.set(abPropName, abPropValue);
+ }
+ }
+ // Special case for email, which can also have a second preference.
+ if (name == "email" && props.length > 1) {
+ propertyMap.set("SecondEmail", props[1].value);
+ }
+ }
+
+ return propertyMap;
+ }
+
+ /**
+ * Serialize this object into a vCard.
+ *
+ * @returns {string} vCard
+ */
+ toVCard() {
+ let jCal = this.entries.map(e => {
+ if (Array.isArray(e.value)) {
+ let design = this.designSet.property[e.name];
+ if (design.multiValue == "," && !design.structuredValue) {
+ // The jCal format stores multiple values as the 4th...nth items,
+ // but VCardPropertyEntry stores them as an array. This applies to
+ // CATEGORIES and NICKNAME types in vCard 4 and also NOTE in vCard 3.
+ return [e.name, e.params, e.type, ...e.value];
+ }
+ }
+ return [e.name, e.params, e.type, e.value];
+ });
+ return ICAL.stringify(["vcard", jCal]);
+ }
+}
diff --git a/comm/mailnews/addrbook/modules/components.conf b/comm/mailnews/addrbook/modules/components.conf
new file mode 100644
index 0000000000..137150d06f
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/components.conf
@@ -0,0 +1,136 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ "cid": "{e96ee804-0bd3-472f-81a6-8a9d65277ad3}",
+ "contract_ids": ["@mozilla.org/addressbook/directory;1?type=jsaddrbook"],
+ "jsm": "resource:///modules/SQLiteDirectory.jsm",
+ "constructor": "SQLiteDirectory",
+ },
+ {
+ "cid": "{1143991d-31cd-4ea6-9c97-c587d990d724}",
+ "contract_ids": ["@mozilla.org/addressbook/jsaddrbookcard;1"],
+ "jsm": "resource:///modules/AddrBookCard.jsm",
+ "constructor": "AddrBookCard",
+ },
+ {
+ "cid": "{224d3ef9-d81c-4d94-8826-a79a5835af93}",
+ "contract_ids": ["@mozilla.org/abmanager;1"],
+ "jsm": "resource:///modules/AddrBookManager.jsm",
+ "constructor": "AddrBookManager",
+ "name": "AbManager",
+ "interfaces": ["nsIAbManager"],
+ },
+ {
+ "cid": "{1fa9941a-07d5-4a6f-9673-15327fc2b9ab}",
+ "contract_ids": ["@mozilla.org/addressbook/directory;1?type=jscarddav"],
+ "jsm": "resource:///modules/CardDAVDirectory.jsm",
+ "constructor": "CardDAVDirectory",
+ },
+ {
+ "cid": "{e2e0f615-bc5a-4441-a16b-a26e75949376}",
+ "contract_ids": ["@mozilla.org/addressbook/msgvcardservice;1"],
+ "jsm": "resource:///modules/VCardUtils.jsm",
+ "constructor": "VCardService",
+ },
+ {
+ "cid": "{e9fb36ec-c980-4a77-9f68-0eb10491eda8}",
+ "contract_ids": ["@mozilla.org/mimecth;1?type=text/vcard"],
+ "jsm": "resource:///modules/VCardUtils.jsm",
+ "constructor": "VCardMimeConverter",
+ "categories": {"simple-mime-converters": "text/vcard"},
+ },
+ {
+ "cid": "{dafab386-bd4c-4238-bb48-228fbc98ba29}",
+ "contract_ids": ["@mozilla.org/mimecth;1?type=text/x-vcard"],
+ "jsm": "resource:///modules/VCardUtils.jsm",
+ "constructor": "VCardMimeConverter",
+ "categories": {"simple-mime-converters": "text/x-vcard"},
+ },
+ {
+ "cid": "{f87b71b5-2a0f-4b37-8e4f-3c899f6b8432}",
+ "contract_ids": ["@mozilla.org/network/ldap-connection;1"],
+ "jsm": "resource:///modules/LDAPConnection.jsm",
+ "constructor": "LDAPConnection",
+ },
+ {
+ "cid": "{a6f94ca4-cd2d-4983-bcf2-fe936190955c}",
+ "contract_ids": ["@mozilla.org/network/ldap-operation;1"],
+ "jsm": "resource:///modules/LDAPOperation.jsm",
+ "constructor": "LDAPOperation",
+ },
+ {
+ "cid": "{8683e821-f1b0-476d-ac15-07771c79bb11}",
+ "contract_ids": [
+ "@mozilla.org/addressbook/directory;1?type=moz-abldapdirectory"
+ ],
+ "jsm": "resource:///modules/LDAPDirectory.jsm",
+ "constructor": "LDAPDirectory",
+ },
+ {
+ "cid": "{5ad5d311-1a50-43db-a03c-63d45f443903}",
+ "contract_ids": ["@mozilla.org/addressbook/ldap-directory-query;1"],
+ "jsm": "resource:///modules/LDAPDirectoryQuery.jsm",
+ "constructor": "LDAPDirectoryQuery",
+ },
+ {
+ "cid": "{dbe204e8-ae09-11eb-b4c8-a7e4b3e6e82e}",
+ "contract_ids": ["@mozilla.org/addressbook/ldap-replication-service;1"],
+ "jsm": "resource:///modules/LDAPReplicationService.jsm",
+ "constructor": "LDAPReplicationService",
+ },
+ {
+ "cid": "{e8b59b32-f83f-4d5f-8eb5-e3c1e5de0d47}",
+ "contract_ids": ["@mozilla.org/network/ldap-service;1"],
+ "jsm": "resource:///modules/LDAPService.jsm",
+ "constructor": "LDAPService",
+ },
+ {
+ "cid": "{50ca73fa-7deb-42b9-9eec-e219e31e6d4b}",
+ "contract_ids": ["@mozilla.org/network/ldap-url-parser;1"],
+ "jsm": "resource:///modules/LDAPURLParser.jsm",
+ "constructor": "LDAPURLParser",
+ },
+ {
+ "cid": "{b3de9249-b0e5-4c12-8d91-c9a434fd80f5}",
+ "contract_ids": ["@mozilla.org/network/protocol;1?name=ldap"],
+ "jsm": "resource:///modules/LDAPProtocolHandler.jsm",
+ "constructor": "LDAPProtocolHandler",
+ "protocol_config": {
+ "scheme": "ldap",
+ "flags": [
+ "URI_NORELATIVE",
+ "URI_DANGEROUS_TO_LOAD",
+ "ALLOWS_PROXY",
+ ],
+ "default_port": 389,
+ },
+ },
+ {
+ "cid": "{c85a5ef2-9c56-445f-b029-76889f2dd29b}",
+ "contract_ids": ["@mozilla.org/network/protocol;1?name=ldaps"],
+ "jsm": "resource:///modules/LDAPProtocolHandler.jsm",
+ "constructor": "LDAPSProtocolHandler",
+ "protocol_config": {
+ "scheme": "ldaps",
+ "flags": [
+ "URI_NORELATIVE",
+ "URI_DANGEROUS_TO_LOAD",
+ "ALLOWS_PROXY",
+ ],
+ "default_port": 636,
+ },
+ },
+]
+
+if buildconfig.substs["MOZ_PREF_EXTENSIONS"]:
+ Classes += [
+ {
+ "cid": "{53d16809-1114-44e2-b585-41a2abb18f66}",
+ "contract_ids": ["@mozilla.org/ldapsyncquery;1"],
+ "jsm": "resource:///modules/LDAPSyncQuery.jsm",
+ "constructor": "LDAPSyncQuery",
+ },
+ ]
diff --git a/comm/mailnews/addrbook/modules/moz.build b/comm/mailnews/addrbook/modules/moz.build
new file mode 100644
index 0000000000..8b86aa9555
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/moz.build
@@ -0,0 +1,34 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "AddrBookCard.jsm",
+ "AddrBookDirectory.jsm",
+ "AddrBookMailingList.jsm",
+ "AddrBookManager.jsm",
+ "AddrBookUtils.jsm",
+ "CardDAVDirectory.jsm",
+ "CardDAVUtils.jsm",
+ "LDAPClient.jsm",
+ "LDAPConnection.jsm",
+ "LDAPDirectory.jsm",
+ "LDAPDirectoryQuery.jsm",
+ "LDAPListenerBase.jsm",
+ "LDAPMessage.jsm",
+ "LDAPOperation.jsm",
+ "LDAPProtocolHandler.jsm",
+ "LDAPReplicationService.jsm",
+ "LDAPService.jsm",
+ "LDAPURLParser.jsm",
+ "QueryStringToExpression.jsm",
+ "SQLiteDirectory.jsm",
+ "VCardUtils.jsm",
+]
+
+if CONFIG["MOZ_PREF_EXTENSIONS"]:
+ EXTRA_JS_MODULES += ["LDAPSyncQuery.jsm"]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]