mail/components/extensions/parent/ext-addressBook.js
author Magnus Melin <mkmelin+mozilla@iki.fi>
Tue, 08 Jul 2025 07:11:58 +0000 (2 days ago)
changeset 45424 6da700026e3fc60a8e3cc1419e7a09b67705a72c
parent 43675 c359139e4304f3829be3cffb2bbff853578ef5d8
permissions -rw-r--r--
Bug 1947052 - Support WebP photos from v3 vCards. r=tobyp Differential Revision: https://phabricator.services.mozilla.com/D256395
/* 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 { MailServices } = ChromeUtils.importESModule(
  "resource:///modules/MailServices.sys.mjs"
);

var { AddrBookDirectory } = ChromeUtils.importESModule(
  "resource:///modules/AddrBookDirectory.sys.mjs"
);

XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "File", "FileReader"]);

ChromeUtils.defineESModuleGetters(this, {
  AddrBookCard: "resource:///modules/AddrBookCard.sys.mjs",
  BANISHED_PROPERTIES: "resource:///modules/VCardUtils.sys.mjs",
  VCardProperties: "resource:///modules/VCardUtils.sys.mjs",
  VCardPropertyEntry: "resource:///modules/VCardUtils.sys.mjs",
  VCardUtils: "resource:///modules/VCardUtils.sys.mjs",
  newUID: "resource:///modules/AddrBookUtils.sys.mjs",
});

// nsIAbCard.idl contains a list of properties that Thunderbird uses. Extensions are not
// restricted to using only these properties, but the following properties cannot
// be modified by an extension.
const hiddenProperties = [
  "DbRowID",
  "LowercasePrimaryEmail",
  "LastModifiedDate",
  "PopularityIndex",
  "RecordKey",
  "UID",
  "_etag",
  "_href",
  "_vCard",
  "vCard",
  "PhotoName",
  "PhotoURL",
  "PhotoType",
];

/**
 * Reads a DOM File and returns a Promise for its dataUrl.
 *
 * @param {File} file
 * @returns {string}
 */
function getDataUrl(file) {
  return new Promise((resolve, reject) => {
    var reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = function () {
      resolve(reader.result);
    };
    reader.onerror = function (error) {
      reject(new ExtensionError(error));
    };
  });
}

/**
 * Returns the image type of the given contentType string, or throws if the
 * contentType is not an image type supported by the address book.
 *
 * @param {string} contentType - The contentType of a photo.
 * @returns {string} - Either "png" or "jpeg". Throws otherwise.
 */
function getImageType(contentType) {
  const typeParts = contentType.toLowerCase().split("/");
  if (typeParts[0] != "image" || !["jpeg", "png"].includes(typeParts[1])) {
    throw new ExtensionError(`Unsupported image format: ${contentType}`);
  }
  return typeParts[1];
}

/**
 * Adds a PHOTO VCardPropertyEntry for the given photo file.
 *
 * @param {VCardProperties} vCardProperties
 * @param {File} photoFile
 * @returns {VCardPropertyEntry}
 */
async function addVCardPhotoEntry(vCardProperties, photoFile) {
  const dataUrl = await getDataUrl(photoFile);
  if (vCardProperties.getFirstValue("version") == "4.0") {
    vCardProperties.addEntry(
      new VCardPropertyEntry("photo", {}, "url", dataUrl)
    );
  } else {
    // If vCard version is not 4.0, default to 3.0.
    vCardProperties.addEntry(
      new VCardPropertyEntry(
        "photo",
        { encoding: "B", type: getImageType(photoFile.type).toUpperCase() },
        "binary",
        dataUrl.substring(dataUrl.indexOf(",") + 1)
      )
    );
  }
}

/**
 * Returns a DOM File object for the contact photo of the given contact.
 *
 * @param {string} id - The id of the contact
 * @returns {File} The photo of the contact, or null.
 */
async function getPhotoFile(id) {
  const { item } = addressBookCache.findContactById(id);
  const photoUrl = item.photoURL;
  if (!photoUrl) {
    return null;
  }

  try {
    if (photoUrl.startsWith("file://")) {
      const realFile = Services.io
        .newURI(photoUrl)
        .QueryInterface(Ci.nsIFileURL).file;
      const file = await File.createFromNsIFile(realFile);
      const type = getImageType(file.type);
      // Clone the File object to be able to give it the correct name, matching
      // the dataUrl/webUrl code path below.
      return new File([file], `${id}.${type}`, { type: `image/${type}` });
    }

    // Retrieve dataUrls or webUrls.
    const result = await fetch(photoUrl);
    const type = getImageType(result.headers.get("content-type"));
    const blob = await result.blob();
    return new File([blob], `${id}.${type}`, { type: `image/${type}` });
  } catch (ex) {
    console.error(`Failed to read photo information for ${id}: ` + ex);
  }

  return null;
}

/**
 * Sets the provided file as the primary photo of the given contact.
 *
 * @param {string} id - The id of the contact
 * @param {File} file - The new photo
 */
async function setPhotoFile(id, file) {
  const node = addressBookCache.findContactById(id);
  const vCardProperties = vCardPropertiesFromCard(node.item);

  try {
    const type = getImageType(file.type);

    // If the contact already has a photoUrl, replace it with the same url type.
    // Otherwise save the photo as a local file, except for CardDAV contacts.
    const photoUrl = node.item.photoURL;
    const parentNode = addressBookCache.findAddressBookById(node.parentId);
    const useFile = photoUrl
      ? photoUrl.startsWith("file://")
      : parentNode.item.dirType != Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE;

    if (useFile) {
      let oldPhotoFile;
      if (photoUrl) {
        try {
          oldPhotoFile = Services.io
            .newURI(photoUrl)
            .QueryInterface(Ci.nsIFileURL).file;
        } catch (ex) {
          console.error(`Ignoring invalid photoUrl ${photoUrl}: ` + ex);
        }
      }
      const pathPhotoFile = await IOUtils.createUniqueFile(
        PathUtils.join(PathUtils.profileDir, "Photos"),
        `${id}.${type}`,
        0o600
      );

      if (file.mozFullPath) {
        // The file object was created by selecting a real file through a file
        // picker and is directly linked to a local file. Do a low level copy.
        await IOUtils.copy(file.mozFullPath, pathPhotoFile);
      } else {
        // The file object is a data blob. Dump it into a real file.
        const buffer = await file.arrayBuffer();
        await IOUtils.write(pathPhotoFile, new Uint8Array(buffer));
      }

      // Set the PhotoName.
      node.item.setProperty("PhotoName", PathUtils.filename(pathPhotoFile));

      // Delete the old photo file.
      if (oldPhotoFile?.exists()) {
        try {
          await IOUtils.remove(oldPhotoFile.path);
        } catch (ex) {
          console.error(`Failed to delete old photo file for ${id}: ` + ex);
        }
      }
    } else {
      // Follow the UI and replace the entire entry.
      vCardProperties.clearValues("photo");
      await addVCardPhotoEntry(vCardProperties, file);
    }
    parentNode.item.modifyCard(node.item);
  } catch (ex) {
    throw new ExtensionError(
      `Failed to read new photo information for ${id}: ` + ex
    );
  }
}

/**
 * Gets the VCardProperties of the given card either directly or by reconstructing
 * from a set of flat standard properties.
 *
 * @param {nsIAbCard|AddrBookCard} card
 * @returns {VCardProperties}
 */
function vCardPropertiesFromCard(card) {
  if (card.supportsVCard) {
    return card.vCardProperties;
  }
  return VCardProperties.fromPropertyMap(
    new Map(Array.from(card.properties, p => [p.name, p.value]))
  );
}

/**
 * Creates a new AddrBookCard from a set of flat standard properties.
 *
 * @param {ContactProperties} properties - A key/value properties object.
 * @param {string} [uid] - Optional UID for the card.
 * @returns {AddrBookCard}
 */
function flatPropertiesToAbCard(properties, uid) {
  // Do not use VCardUtils.propertyMapToVCard().
  const vCard = VCardProperties.fromPropertyMap(
    new Map(Object.entries(properties))
  ).toVCard();
  return VCardUtils.vCardToAbCard(vCard, uid);
}

/**
 * Checks if the given property is a custom contact property, which can be exposed
 * to WebExtensions.
 *
 * @param {string} name - Property name.
 * @returns {boolean}
 */
function isCustomProperty(name) {
  return (
    !hiddenProperties.includes(name) &&
    !BANISHED_PROPERTIES.includes(name) &&
    name.match(/^\w+$/)
  );
}

/**
 * Adds the provided originalProperties to the card, adjusted by the changes
 * given in updateProperties. All banished properties are skipped and the
 *  updated properties must be valid according to isCustomProperty().
 *
 * @param {AddrBookCard} card - A card to receive the provided properties.
 * @param {ContactProperties} updateProperties - A key/value object with properties.
 *   to update the provided originalProperties
 * @param {nsIProperties} originalProperties - Properties to be cloned onto
 *   the provided card.
 */
function addProperties(card, updateProperties, originalProperties) {
  const updates = Object.entries(updateProperties).filter(e =>
    isCustomProperty(e[0])
  );
  const mergedProperties = originalProperties
    ? new Map([
        ...Array.from(originalProperties, p => [p.name, p.value]),
        ...updates,
      ])
    : new Map(updates);

  for (const [name, value] of mergedProperties) {
    if (
      !BANISHED_PROPERTIES.includes(name) &&
      value != "" &&
      value != null &&
      value != undefined
    ) {
      card.setProperty(name, value);
    }
  }
}

/**
 * Address book that supports finding cards only for a search (like LDAP).
 *
 * @implements {nsIAbDirectory}
 */
class ExtSearchBook extends AddrBookDirectory {
  constructor(extension, args = {}) {
    super();
    this._readOnly = true;
    this._isSecure = Boolean(args.isSecure);
    this._dirName = String(args.addressBookName ?? extension.name);
    this._fileName = "";
    this._uid = String(args.id ?? newUID());
    this._uri = "searchaddr://" + this.UID;
    this.lastModifiedDate = 0;
    this.isMailList = false;
    this.listNickName = "";
    this.description = "";
    this._dirPrefId = "";
  }
  /**
   * @see {AddrBookDirectory}
   */
  get lists() {
    return new Map();
  }
  /**
   * @see {AddrBookDirectory}
   */
  get cards() {
    return new Map();
  }
  // nsIAbDirectory
  get isRemote() {
    return true;
  }
  get isSecure() {
    return this._isSecure;
  }
  getCardFromProperty() {
    return null;
  }
  getCardsFromProperty() {
    return [];
  }
  get dirType() {
    return Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE;
  }
  get position() {
    return 0;
  }
  get childCardCount() {
    return 0;
  }
  useForAutocomplete() {
    // AddrBookDirectory defaults to true
    return false;
  }
  get supportsMailingLists() {
    return false;
  }
  setLocalizedStringValue() {}
  async search(aQuery, aSearchString, aListener) {
    addressBookCache.emit(
      `provider-search-request-${this.UID}`,
      aQuery,
      aSearchString,
      aListener
    );
  }
}

/**
 * Cache of items in the address book "tree".
 *
 * @implements {nsIObserver}
 */
var addressBookCache = new (class extends EventEmitter {
  constructor() {
    super();
    this.listenerCount = 0;
    this.flush();
  }
  _makeContactNode(contact, parent) {
    contact.QueryInterface(Ci.nsIAbCard);
    return {
      id: contact.UID,
      parentId: parent.UID,
      type: "contact",
      item: contact,
    };
  }
  _makeDirectoryNode(directory, parent = null) {
    directory.QueryInterface(Ci.nsIAbDirectory);
    const node = {
      id: directory.UID,
      type: directory.isMailList ? "mailingList" : "addressBook",
      item: directory,
    };
    if (parent) {
      node.parentId = parent.UID;
    }
    return node;
  }
  _populateListContacts(mailingList) {
    mailingList.contacts = new Map();
    for (const contact of mailingList.item.childCards) {
      const newNode = this._makeContactNode(contact, mailingList.item);
      mailingList.contacts.set(newNode.id, newNode);
    }
  }
  getListContacts(mailingList) {
    if (!mailingList.contacts) {
      this._populateListContacts(mailingList);
    }
    return [...mailingList.contacts.values()];
  }
  _populateContacts(addressBook) {
    addressBook.contacts = new Map();
    for (const contact of addressBook.item.childCards) {
      if (!contact.isMailList) {
        const newNode = this._makeContactNode(contact, addressBook.item);
        this._contacts.set(newNode.id, newNode);
        addressBook.contacts.set(newNode.id, newNode);
      }
    }
  }
  getContacts(addressBook) {
    if (!addressBook.contacts) {
      this._populateContacts(addressBook);
    }
    return [...addressBook.contacts.values()];
  }
  _populateMailingLists(parent) {
    parent.mailingLists = new Map();
    for (const mailingList of parent.item.childNodes) {
      const newNode = this._makeDirectoryNode(mailingList, parent.item);
      this._mailingLists.set(newNode.id, newNode);
      parent.mailingLists.set(newNode.id, newNode);
    }
  }
  getMailingLists(parent) {
    if (!parent.mailingLists) {
      this._populateMailingLists(parent);
    }
    return [...parent.mailingLists.values()];
  }
  get addressBooks() {
    if (!this._addressBooks) {
      this._addressBooks = new Map();
      for (const tld of MailServices.ab.directories) {
        this._addressBooks.set(tld.UID, this._makeDirectoryNode(tld));
      }
    }
    return this._addressBooks;
  }
  flush() {
    this._contacts = new Map();
    this._mailingLists = new Map();
    this._addressBooks = null;
  }
  findAddressBookById(id) {
    const addressBook = this.addressBooks.get(id);
    if (addressBook) {
      return addressBook;
    }
    throw new ExtensionUtils.ExtensionError(
      `addressBook with id=${id} could not be found.`
    );
  }
  findMailingListById(id) {
    if (this._mailingLists.has(id)) {
      return this._mailingLists.get(id);
    }
    for (const addressBook of this.addressBooks.values()) {
      if (!addressBook.mailingLists) {
        this._populateMailingLists(addressBook);
        if (addressBook.mailingLists.has(id)) {
          return addressBook.mailingLists.get(id);
        }
      }
    }
    throw new ExtensionUtils.ExtensionError(
      `mailingList with id=${id} could not be found.`
    );
  }
  findContactById(id, bookHint) {
    if (this._contacts.has(id)) {
      return this._contacts.get(id);
    }
    if (bookHint && !bookHint.contacts) {
      this._populateContacts(bookHint);
      if (bookHint.contacts.has(id)) {
        return bookHint.contacts.get(id);
      }
    }
    for (const addressBook of this.addressBooks.values()) {
      if (!addressBook.contacts) {
        this._populateContacts(addressBook);
        if (addressBook.contacts.has(id)) {
          return addressBook.contacts.get(id);
        }
      }
    }
    throw new ExtensionUtils.ExtensionError(
      `contact with id=${id} could not be found.`
    );
  }
  async convert(node, extension, complete) {
    if (node === null) {
      return node;
    }
    if (Array.isArray(node)) {
      const cards = await Promise.allSettled(
        node.map(i => this.convert(i, extension, complete))
      );
      return cards.filter(card => card.value).map(card => card.value);
    }

    const copy = {};
    for (const key of ["id", "parentId", "type"]) {
      if (key in node) {
        copy[key] = node[key];
      }
    }

    if (complete) {
      if (node.type == "addressBook") {
        copy.mailingLists = await this.convert(
          this.getMailingLists(node),
          extension,
          true
        );
        copy.contacts = await this.convert(
          this.getContacts(node),
          extension,
          true
        );
      }
      if (node.type == "mailingList") {
        copy.contacts = await this.convert(
          this.getListContacts(node),
          extension,
          true
        );
      }
    }

    switch (node.type) {
      case "addressBook":
        copy.name = node.item.dirName;
        copy.readOnly = node.item.readOnly;
        copy.remote = node.item.isRemote;
        break;
      case "contact": {
        // Clone the vCardProperties of this contact, so we can manipulate them
        // for the WebExtension, but do not actually change the stored data.
        const vCardProperties = vCardPropertiesFromCard(node.item).clone();
        const properties = {};

        // Build a flat property list from vCardProperties.
        for (const [name, value] of vCardProperties.toPropertyMap()) {
          properties[name] = "" + value;
        }

        // Return all other exposed properties stored in the nodes property bag.
        for (const property of Array.from(node.item.properties).filter(e =>
          isCustomProperty(e.name)
        )) {
          properties[property.name] = "" + property.value;
        }

        // If this card has no photo vCard entry, but a local photo, add it to its vCard: Thunderbird
        // does not store photos of local address books in the internal _vCard property, to reduce
        // the amount of data stored in its database.
        const photoName = node.item.getProperty("PhotoName", "");
        const vCardPhoto = vCardProperties.getFirstValue("photo");
        if (!vCardPhoto && photoName) {
          try {
            const realPhotoFile = Services.dirsvc.get("ProfD", Ci.nsIFile);
            realPhotoFile.append("Photos");
            realPhotoFile.append(photoName);
            const photoFile = await File.createFromNsIFile(realPhotoFile);
            await addVCardPhotoEntry(vCardProperties, photoFile);
          } catch (ex) {
            console.error(
              `Failed to read photo information for ${node.id}: ` + ex
            );
          }
        }
        // Add the vCard.
        properties.vCard = vCardProperties.toVCard();

        if (extension.manifest.manifest_version < 3) {
          copy.properties = properties;
        } else {
          copy.vCard = properties.vCard;
        }

        let parentNode;
        try {
          parentNode = this.findAddressBookById(node.parentId);
        } catch (ex) {
          // Parent might be a mailing list.
          parentNode = this.findMailingListById(node.parentId);
        }
        copy.readOnly = parentNode.item.readOnly;
        copy.remote = parentNode.item.isRemote;
        break;
      }
      case "mailingList": {
        copy.name = node.item.dirName;
        copy.nickName = node.item.listNickName;
        copy.description = node.item.description;
        const parentNode = this.findAddressBookById(node.parentId);
        copy.readOnly = parentNode.item.readOnly;
        copy.remote = parentNode.item.isRemote;
        break;
      }
    }

    return copy;
  }

  // nsIObserver
  _notifications = [
    "addrbook-directory-created",
    "addrbook-directory-updated",
    "addrbook-directory-deleted",
    "addrbook-contact-created",
    "addrbook-contact-properties-updated",
    "addrbook-contact-deleted",
    "addrbook-list-created",
    "addrbook-list-updated",
    "addrbook-list-deleted",
    "addrbook-list-member-added",
    "addrbook-list-member-removed",
  ];

  observe(subject, topic, data) {
    switch (topic) {
      case "addrbook-directory-created": {
        subject.QueryInterface(Ci.nsIAbDirectory);

        const newNode = this._makeDirectoryNode(subject);
        if (this._addressBooks) {
          this._addressBooks.set(newNode.id, newNode);
        }

        this.emit("address-book-created", newNode);
        break;
      }
      case "addrbook-directory-updated": {
        subject.QueryInterface(Ci.nsIAbDirectory);

        this.emit("address-book-updated", this._makeDirectoryNode(subject));
        break;
      }
      case "addrbook-directory-deleted": {
        subject.QueryInterface(Ci.nsIAbDirectory);

        const uid = subject.UID;
        if (this._addressBooks?.has(uid)) {
          const parentNode = this._addressBooks.get(uid);
          if (parentNode.contacts) {
            for (const id of parentNode.contacts.keys()) {
              this._contacts.delete(id);
            }
          }
          if (parentNode.mailingLists) {
            for (const id of parentNode.mailingLists.keys()) {
              this._mailingLists.delete(id);
            }
          }
          this._addressBooks.delete(uid);
        }

        this.emit("address-book-deleted", uid);
        break;
      }
      case "addrbook-contact-created": {
        subject.QueryInterface(Ci.nsIAbCard);

        const parent = MailServices.ab.getDirectoryFromUID(data);
        const newNode = this._makeContactNode(subject, parent);
        if (this._addressBooks?.has(data)) {
          const parentNode = this._addressBooks.get(data);
          if (parentNode.contacts) {
            parentNode.contacts.set(newNode.id, newNode);
          }
          this._contacts.set(newNode.id, newNode);
        }

        this.emit("contact-created", newNode);
        break;
      }
      case "addrbook-contact-properties-updated": {
        subject.QueryInterface(Ci.nsIAbCard);

        const parentUID = subject.directoryUID;
        const parent = MailServices.ab.getDirectoryFromUID(parentUID);
        const newNode = this._makeContactNode(subject, parent);
        if (this._addressBooks?.has(parentUID)) {
          const parentNode = this._addressBooks.get(parentUID);
          if (parentNode.contacts) {
            parentNode.contacts.set(newNode.id, newNode);
            this._contacts.set(newNode.id, newNode);
          }
          if (parentNode.mailingLists) {
            for (const mailingList of parentNode.mailingLists.values()) {
              if (
                mailingList.contacts &&
                mailingList.contacts.has(newNode.id)
              ) {
                mailingList.contacts.get(newNode.id).item = subject;
              }
            }
          }
        }

        this.emit("contact-updated", newNode, JSON.parse(data));
        break;
      }
      case "addrbook-contact-deleted": {
        subject.QueryInterface(Ci.nsIAbCard);

        const uid = subject.UID;
        this._contacts.delete(uid);
        if (this._addressBooks?.has(data)) {
          const parentNode = this._addressBooks.get(data);
          if (parentNode.contacts) {
            parentNode.contacts.delete(uid);
          }
        }

        this.emit("contact-deleted", data, uid);
        break;
      }
      case "addrbook-list-created": {
        subject.QueryInterface(Ci.nsIAbDirectory);

        const parent = MailServices.ab.getDirectoryFromUID(data);
        const newNode = this._makeDirectoryNode(subject, parent);
        if (this._addressBooks?.has(data)) {
          const parentNode = this._addressBooks.get(data);
          if (parentNode.mailingLists) {
            parentNode.mailingLists.set(newNode.id, newNode);
          }
          this._mailingLists.set(newNode.id, newNode);
        }

        this.emit("mailing-list-created", newNode);
        break;
      }
      case "addrbook-list-updated": {
        subject.QueryInterface(Ci.nsIAbDirectory);

        const listNode = this.findMailingListById(subject.UID);
        listNode.item = subject;

        this.emit("mailing-list-updated", listNode);
        break;
      }
      case "addrbook-list-deleted": {
        subject.QueryInterface(Ci.nsIAbDirectory);

        const uid = subject.UID;
        this._mailingLists.delete(uid);
        if (this._addressBooks?.has(data)) {
          const parentNode = this._addressBooks.get(data);
          if (parentNode.mailingLists) {
            parentNode.mailingLists.delete(uid);
          }
        }

        this.emit("mailing-list-deleted", data, uid);
        break;
      }
      case "addrbook-list-member-added": {
        subject.QueryInterface(Ci.nsIAbCard);

        const parentNode = this.findMailingListById(data);
        const newNode = this._makeContactNode(subject, parentNode.item);
        if (
          this._mailingLists.has(data) &&
          this._mailingLists.get(data).contacts
        ) {
          this._mailingLists.get(data).contacts.set(newNode.id, newNode);
        }
        this.emit("mailing-list-member-added", newNode);
        break;
      }
      case "addrbook-list-member-removed": {
        subject.QueryInterface(Ci.nsIAbCard);

        const uid = subject.UID;
        if (this._mailingLists.has(data)) {
          const parentNode = this._mailingLists.get(data);
          if (parentNode.contacts) {
            parentNode.contacts.delete(uid);
          }
        }

        this.emit("mailing-list-member-removed", data, uid);
        break;
      }
    }
  }

  incrementListeners() {
    this.listenerCount++;
    if (this.listenerCount == 1) {
      for (const topic of this._notifications) {
        Services.obs.addObserver(this, topic);
      }
    }
  }
  decrementListeners() {
    this.listenerCount--;
    if (this.listenerCount == 0) {
      for (const topic of this._notifications) {
        Services.obs.removeObserver(this, topic);
      }

      this.flush();
    }
  }
})();

this.addressBook = class extends ExtensionAPIPersistent {
  persistentSearchBooks = [];
  hasBeenTerminated = false;

  PERSISTENT_EVENTS = {
    // For primed persistent events (deactivated background), the context is only
    // available after fire.wakeup() has fulfilled (ensuring the convert() function
    // has been called).

    // addressBooks.*
    onAddressBookCreated({ fire, context }) {
      const listener = async (event, node) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(await addressBookCache.convert(node, context.extension));
      };
      addressBookCache.on("address-book-created", listener);
      return {
        unregister: () => {
          addressBookCache.off("address-book-created", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onAddressBookUpdated({ fire, context }) {
      const listener = async (event, node) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(await addressBookCache.convert(node, context.extension));
      };
      addressBookCache.on("address-book-updated", listener);
      return {
        unregister: () => {
          addressBookCache.off("address-book-updated", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onAddressBookDeleted({ fire }) {
      const listener = async (event, itemUID) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(itemUID);
      };
      addressBookCache.on("address-book-deleted", listener);
      return {
        unregister: () => {
          addressBookCache.off("address-book-deleted", listener);
        },
        convert(newFire) {
          fire = newFire;
        },
      };
    },

    // contacts.*
    onContactCreated({ fire, context }) {
      const listener = async (event, node) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(await addressBookCache.convert(node, context.extension));
      };
      addressBookCache.on("contact-created", listener);
      return {
        unregister: () => {
          addressBookCache.off("contact-created", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onContactUpdated({ fire, context }) {
      const listener = async (event, node, changes) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        const filteredChanges = {};
        // For MV2, report individual changed flat properties stored in the vCard
        // and in the property bag of the card. MV3 only sees the actual vCard.
        if (context.extension.manifest.manifest_version < 3) {
          if (changes.hasOwnProperty("_vCard")) {
            const oldVCardProperties = VCardProperties.fromVCard(
              changes._vCard.oldValue
            ).toPropertyMap();
            const newVCardProperties = VCardProperties.fromVCard(
              changes._vCard.newValue
            ).toPropertyMap();
            for (const [name, value] of oldVCardProperties) {
              if (newVCardProperties.get(name) != value) {
                filteredChanges[name] = {
                  oldValue: value,
                  newValue: newVCardProperties.get(name) ?? null,
                };
              }
            }
            for (const [name, value] of newVCardProperties) {
              if (
                !filteredChanges.hasOwnProperty(name) &&
                oldVCardProperties.get(name) != value
              ) {
                filteredChanges[name] = {
                  oldValue: oldVCardProperties.get(name) ?? null,
                  newValue: value,
                };
              }
            }
          }
          for (const [name, value] of Object.entries(changes)) {
            if (
              !filteredChanges.hasOwnProperty(name) &&
              isCustomProperty(name)
            ) {
              filteredChanges[name] = value;
            }
          }
          fire.sync(
            await addressBookCache.convert(node, context.extension),
            filteredChanges
          );
        } else if (changes.hasOwnProperty("_vCard")) {
          fire.sync(
            await addressBookCache.convert(node, context.extension),
            changes._vCard.oldValue
          );
        }
      };
      addressBookCache.on("contact-updated", listener);
      return {
        unregister: () => {
          addressBookCache.off("contact-updated", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onContactDeleted({ fire }) {
      const listener = async (event, parentUID, itemUID) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(parentUID, itemUID);
      };
      addressBookCache.on("contact-deleted", listener);
      return {
        unregister: () => {
          addressBookCache.off("contact-deleted", listener);
        },
        convert(newFire) {
          fire = newFire;
        },
      };
    },

    // mailingLists.*
    onMailingListCreated({ fire, context }) {
      const listener = async (event, node) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(await addressBookCache.convert(node, context.extension));
      };
      addressBookCache.on("mailing-list-created", listener);
      return {
        unregister: () => {
          addressBookCache.off("mailing-list-created", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onMailingListUpdated({ fire, context }) {
      const listener = async (event, node) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(await addressBookCache.convert(node, context.extension));
      };
      addressBookCache.on("mailing-list-updated", listener);
      return {
        unregister: () => {
          addressBookCache.off("mailing-list-updated", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onMailingListDeleted({ fire }) {
      const listener = async (event, parentUID, itemUID) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(parentUID, itemUID);
      };
      addressBookCache.on("mailing-list-deleted", listener);
      return {
        unregister: () => {
          addressBookCache.off("mailing-list-deleted", listener);
        },
        convert(newFire) {
          fire = newFire;
        },
      };
    },
    onMemberAdded({ fire, context }) {
      const listener = async (event, node) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(await addressBookCache.convert(node, context.extension));
      };
      addressBookCache.on("mailing-list-member-added", listener);
      return {
        unregister: () => {
          addressBookCache.off("mailing-list-member-added", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
    onMemberRemoved({ fire }) {
      const listener = async (event, parentUID, itemUID) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }
        fire.sync(parentUID, itemUID);
      };
      addressBookCache.on("mailing-list-member-removed", listener);
      return {
        unregister: () => {
          addressBookCache.off("mailing-list-member-removed", listener);
        },
        convert(newFire) {
          fire = newFire;
        },
      };
    },

    // provider.*
    onSearchRequest({ fire }, [args]) {
      const { extension } = this;
      const isStarting = extension.backgroundState == "starting";
      const isStopped = extension.backgroundState == "stopped";
      let dir;

      // The handling of event listeners depends on the current background state
      // during which the listeners are registered or unregistered (Manifest V3).
      // starting:
      //   Event listeners registered in this phase are in top-level background
      //   code and will be remembered as persistent listeners. They will resume
      //   the background script, if it has been terminated.
      //   When the background script is re-run after being resumed, all event
      //   listeners registered in this phase are usually skipped, except if their
      //   parameter configuration has changed. In that case the changed listener
      //   is re-registered with the new parameter configuration, and the old one
      //   is unregistered during the "running" phase.
      //
      // running:
      //   Event listeners are not registered in top-level code (but at any later
      //   time) and will not be remembered as persistent listeners. They will
      //   not resume the background script, and no longer work after background
      //   termination (except another persistent listener causes the background
      //   to resume, then it will be re-registered during re-execution of the
      //   background script).
      //
      // suspending:
      //   All event listeners will be unregistered when the background is being
      //   terminated during this phase. All listeners remembered as persistent
      //   will be immediately re-registered in the following "stopped" phase.
      //
      // stopped:
      //   Event listeners registered during this phase are called "primed". The
      //   background is not running, but these listeners will still be active and
      //   resume the background script.

      if (isStarting && this.hasBeenTerminated) {
        throw new ExtensionError(
          `Re-registering a persistent onSearchRequest listener with different arguments, id=${args.id}.`
        );
      }

      const listener = async (event, aQuery, aSearchString, aListener) => {
        if (fire.wakeup) {
          await fire.wakeup();
        }

        try {
          const { results, isCompleteResult } = await fire.async(
            await addressBookCache.convert(
              addressBookCache.addressBooks.get(dir.UID),
              extension
            ),
            aSearchString,
            aQuery
          );

          for (const resultData of results) {
            let card;
            // A specified vCard is winning over any individual standard property.
            // MV3 no longer supports flat properties.
            if (extension.manifest.manifest_version > 2 || resultData.vCard) {
              const vCard =
                extension.manifest.manifest_version > 2
                  ? resultData
                  : resultData.vCard;
              try {
                card = VCardUtils.vCardToAbCard(vCard);
              } catch (ex) {
                throw new ExtensionError(`Invalid vCard data: ${vCard}.`);
              }
            } else {
              card = flatPropertiesToAbCard(resultData);
            }
            // Add custom properties to the property bag.
            addProperties(card, resultData);
            card.directoryUID = dir.UID;
            aListener.onSearchFoundCard(card);
          }
          aListener.onSearchFinished(Cr.NS_OK, isCompleteResult, null, "");
        } catch (ex) {
          aListener.onSearchFinished(
            ex.result || Cr.NS_ERROR_FAILURE,
            true,
            null,
            ""
          );
        }
      };

      if (isStopped) {
        // This is registering a primed listener (re-executing the exact same
        // register request with the same arguments), after the background script
        // has been terminated. Use the already existing persistent directory.
        dir = this.persistentSearchBooks.shift();
        // Remember that we have been terminated, to prevent re-registrations of
        // persistent listeners with changed parameter configurations.
        this.hasBeenTerminated = true;
      } else {
        dir = new ExtSearchBook(extension, args);
        // Keep track of books of persistent listeners, which must not be removed
        // during background termination.
        dir.persistent = isStarting;
        if (addressBookCache.addressBooks.has(dir.UID)) {
          throw new ExtensionUtils.ExtensionError(
            `addressBook with id=${dir.UID} already exists.`
          );
        }

        dir.init();
        MailServices.ab.addAddressBook(dir);
      }

      addressBookCache.on(`provider-search-request-${dir.UID}`, listener);

      return {
        unregister: () => {
          addressBookCache.off(`provider-search-request-${dir.UID}`, listener);

          if (extension.backgroundState == "suspending" && dir.persistent) {
            // During background termination, all listeners are unregistered. All
            // persistent listeners will be immediately re-registered as primed
            // listeners and we should not remove the corresponding address books.
            this.persistentSearchBooks.push(dir);
          } else {
            MailServices.ab.deleteAddressBook(dir.URI);
          }
        },
        convert(newFire) {
          fire = newFire;
        },
      };
    },
  };

  constructor(...args) {
    super(...args);
    addressBookCache.incrementListeners();
  }

  onShutdown() {
    addressBookCache.decrementListeners();
  }

  getAPI(context) {
    const { extension } = context;
    const { tabManager } = extension;

    const getContactsApi = () => ({
      list(parentId) {
        const parentNode = addressBookCache.findAddressBookById(parentId);
        return addressBookCache.convert(
          addressBookCache.getContacts(parentNode),
          extension,
          false
        );
      },
      async query(queryInfo) {
        const { getSearchTokens, getModelQuery, generateQueryURI } =
          ChromeUtils.importESModule(
            "resource:///modules/ABQueryUtils.sys.mjs"
          );
        const searchString = queryInfo.searchString || "";
        const searchWords = getSearchTokens(searchString);
        if (searchWords.length == 0) {
          return [];
        }
        const searchFormat = getModelQuery(
          "mail.addr_book.quicksearchquery.format"
        );
        const searchQuery = generateQueryURI(searchFormat, searchWords);

        let booksToSearch;
        if (queryInfo.parentId == null) {
          booksToSearch = [...addressBookCache.addressBooks.values()];
        } else {
          booksToSearch = [
            addressBookCache.findAddressBookById(queryInfo.parentId),
          ];
        }

        const results = [];
        const promises = [];
        for (const book of booksToSearch) {
          if (
            (book.item.isRemote && !queryInfo.includeRemote) ||
            (!book.item.isRemote && !queryInfo.includeLocal) ||
            (book.item.readOnly && !queryInfo.includeReadOnly) ||
            (!book.item.readOnly && !queryInfo.includeReadWrite)
          ) {
            continue;
          }
          promises.push(
            new Promise(resolve => {
              book.item.search(searchQuery, searchString, {
                onSearchFinished() {
                  resolve();
                },
                onSearchFoundCard(contact) {
                  if (contact.isMailList) {
                    return;
                  }
                  results.push(
                    addressBookCache._makeContactNode(contact, book.item)
                  );
                },
              });
            })
          );
        }
        await Promise.all(promises);

        return addressBookCache.convert(results, extension, false);
      },
      async quickSearch(parentId, queryInfo) {
        if (typeof queryInfo == "string") {
          const searchString = queryInfo;
          queryInfo = {
            searchString,
            includeRemote: true,
            includeLocal: true,
            includeReadOnly: true,
            includeReadWrite: true,
          };
        }
        return this.query({ ...queryInfo, parentId });
      },
      get(id) {
        return addressBookCache.convert(
          addressBookCache.findContactById(id),
          extension,
          false
        );
      },
      async getPhoto(id) {
        return getPhotoFile(id);
      },
      async setPhoto(id, file) {
        return setPhotoFile(id, file);
      },
      create(arg1, arg2, arg3) {
        // Manifest V2 and V3 have different parameter configuration.
        let parentId, id, createData;
        if (extension.manifest.manifest_version > 2) {
          parentId = arg1;
          createData = arg2;
        } else {
          parentId = arg1;
          id = arg2;
          createData = arg3;
        }

        const parentNode = addressBookCache.findAddressBookById(parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot create a contact in a read-only address book"
          );
        }

        let card;
        // A specified vCard is winning over any individual standard property.
        // MV3 no longer supports flat properties.
        if (extension.manifest.manifest_version > 2 || createData.vCard) {
          const vCard =
            extension.manifest.manifest_version > 2
              ? createData
              : createData.vCard;
          try {
            card = VCardUtils.vCardToAbCard(vCard, id);
          } catch (ex) {
            throw new ExtensionError(`Invalid vCard data: ${vCard}.`);
          }
        } else {
          card = flatPropertiesToAbCard(createData, id);
        }
        // Add custom properties to the property bag.
        addProperties(card, createData);

        // Check if the new card has an enforced UID.
        if (card.vCardProperties.getFirstValue("uid")) {
          let duplicateExists = false;
          try {
            // Second argument is only a hint, all address books are checked.
            addressBookCache.findContactById(card.UID, parentId);
            duplicateExists = true;
          } catch (ex) {
            // Do nothing. We want this to throw because no contact was found.
          }
          if (duplicateExists) {
            throw new ExtensionError(`Duplicate contact id: ${card.UID}`);
          }
        }

        const newCard = parentNode.item.addCard(card);
        return newCard.UID;
      },
      update(id, updateData) {
        const node = addressBookCache.findContactById(id);
        const parentNode = addressBookCache.findAddressBookById(node.parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot modify a contact in a read-only address book"
          );
        }

        // A specified vCard is winning over any individual standard property.
        // While a vCard is replacing the entire contact, specified standard
        // properties only update single entries (setting a value to null
        // clears it / promotes the next value of the same kind).
        // MV3 no longer supports flat properties.
        let card;
        if (extension.manifest.manifest_version > 2 || updateData.vCard) {
          const vCard =
            extension.manifest.manifest_version > 2
              ? updateData
              : updateData.vCard;
          let vCardUID;
          try {
            card = new AddrBookCard();
            card.UID = node.item.UID;
            card.setProperty("_vCard", VCardUtils.translateVCard21(vCard));
            vCardUID = card.vCardProperties.getFirstValue("uid");
          } catch (ex) {
            throw new ExtensionError(`Invalid vCard data: ${vCard}.`);
          }
          if (vCardUID && vCardUID != node.item.UID) {
            throw new ExtensionError(
              `The card's UID ${node.item.UID} may not be changed: ${vCard}.`
            );
          }
        } else {
          // Get the current vCardProperties, build a propertyMap and create
          // vCardParsed which allows to identify all currently exposed entries
          // based on the typeName used in VCardUtils.sys.mjs (e.g. adr.work).
          const vCardProperties = vCardPropertiesFromCard(node.item);
          const vCardParsed = VCardUtils._parse(vCardProperties.entries);
          const propertyMap = vCardProperties.toPropertyMap();

          // Save the old exposed state.
          const oldProperties = VCardProperties.fromPropertyMap(propertyMap);
          const oldParsed = VCardUtils._parse(oldProperties.entries);
          // Update the propertyMap.
          for (const [name, value] of Object.entries(updateData)) {
            propertyMap.set(name, value);
          }
          // Save the new exposed state.
          const newProperties = VCardProperties.fromPropertyMap(propertyMap);
          const newParsed = VCardUtils._parse(newProperties.entries);

          // Evaluate the differences and update the still existing entries,
          // mark removed items for deletion.
          const deleteLog = [];
          for (const typeName of oldParsed.keys()) {
            if (typeName == "version") {
              continue;
            }
            for (let idx = 0; idx < oldParsed.get(typeName).length; idx++) {
              if (
                newParsed.has(typeName) &&
                idx < newParsed.get(typeName).length
              ) {
                const originalIndex = vCardParsed.get(typeName)[idx].index;
                const newEntryIndex = newParsed.get(typeName)[idx].index;
                vCardProperties.entries[originalIndex] =
                  newProperties.entries[newEntryIndex];
                // Mark this item as handled.
                newParsed.get(typeName)[idx] = null;
              } else {
                deleteLog.push(vCardParsed.get(typeName)[idx].index);
              }
            }
          }

          // Remove entries which have been marked for deletion.
          for (const deleteIndex of deleteLog.sort((a, b) => a < b)) {
            vCardProperties.entries.splice(deleteIndex, 1);
          }

          // Add new entries.
          for (const typeName of newParsed.keys()) {
            if (typeName == "version") {
              continue;
            }
            for (const newEntry of newParsed.get(typeName)) {
              if (newEntry) {
                vCardProperties.addEntry(newProperties.entries[newEntry.index]);
              }
            }
          }

          // Create a new card with the original UID from the updated vCardProperties.
          card = VCardUtils.vCardToAbCard(
            vCardProperties.toVCard(),
            node.item.UID
          );
        }

        // Clone original properties and update custom properties.
        addProperties(card, updateData, node.item.properties);

        parentNode.item.modifyCard(card);
      },
      delete(id) {
        const node = addressBookCache.findContactById(id);
        const parentNode = addressBookCache.findAddressBookById(node.parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot delete a contact in a read-only address book"
          );
        }

        parentNode.item.deleteCards([node.item]);
      },

      // The module name is addressBook as defined in ext-mail.json.
      onCreated: new EventManager({
        context,
        module: "addressBook",
        event: "onContactCreated",
        extensionApi: this,
      }).api(),
      onUpdated: new EventManager({
        context,
        module: "addressBook",
        event: "onContactUpdated",
        extensionApi: this,
      }).api(),
      onDeleted: new EventManager({
        context,
        module: "addressBook",
        event: "onContactDeleted",
        extensionApi: this,
      }).api(),
    });

    const getMailingListsApi = () => ({
      list(parentId) {
        const parentNode = addressBookCache.findAddressBookById(parentId);
        return addressBookCache.convert(
          addressBookCache.getMailingLists(parentNode),
          extension,
          false
        );
      },
      get(id) {
        return addressBookCache.convert(
          addressBookCache.findMailingListById(id),
          extension,
          false
        );
      },
      create(parentId, { name, nickName, description }) {
        const parentNode = addressBookCache.findAddressBookById(parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot create a mailing list in a read-only address book"
          );
        }
        const mailList = Cc[
          "@mozilla.org/addressbook/directoryproperty;1"
        ].createInstance(Ci.nsIAbDirectory);
        mailList.isMailList = true;
        mailList.dirName = name;
        mailList.listNickName = nickName === null ? "" : nickName;
        mailList.description = description === null ? "" : description;

        const newMailList = parentNode.item.addMailList(mailList);
        return newMailList.UID;
      },
      update(id, { name, nickName, description }) {
        const node = addressBookCache.findMailingListById(id);
        const parentNode = addressBookCache.findAddressBookById(node.parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot modify a mailing list in a read-only address book"
          );
        }
        node.item.dirName = name;
        node.item.listNickName = nickName === null ? "" : nickName;
        node.item.description = description === null ? "" : description;
        node.item.editMailListToDatabase(null);
      },
      delete(id) {
        const node = addressBookCache.findMailingListById(id);
        const parentNode = addressBookCache.findAddressBookById(node.parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot delete a mailing list in a read-only address book"
          );
        }
        parentNode.item.deleteDirectory(node.item);
      },

      listMembers(id) {
        const node = addressBookCache.findMailingListById(id);
        return addressBookCache.convert(
          addressBookCache.getListContacts(node),
          extension,
          false
        );
      },
      addMember(id, contactId) {
        const node = addressBookCache.findMailingListById(id);
        const parentNode = addressBookCache.findAddressBookById(node.parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot add to a mailing list in a read-only address book"
          );
        }
        const contactNode = addressBookCache.findContactById(contactId);
        node.item.addCard(contactNode.item);
      },
      removeMember(id, contactId) {
        const node = addressBookCache.findMailingListById(id);
        const parentNode = addressBookCache.findAddressBookById(node.parentId);
        if (parentNode.item.readOnly) {
          throw new ExtensionUtils.ExtensionError(
            "Cannot remove from a mailing list in a read-only address book"
          );
        }
        const contactNode = addressBookCache.findContactById(contactId);

        node.item.deleteCards([contactNode.item]);
      },

      // The module name is addressBook as defined in ext-mail.json.
      onCreated: new EventManager({
        context,
        module: "addressBook",
        event: "onMailingListCreated",
        extensionApi: this,
      }).api(),
      onUpdated: new EventManager({
        context,
        module: "addressBook",
        event: "onMailingListUpdated",
        extensionApi: this,
      }).api(),
      onDeleted: new EventManager({
        context,
        module: "addressBook",
        event: "onMailingListDeleted",
        extensionApi: this,
      }).api(),
      onMemberAdded: new EventManager({
        context,
        module: "addressBook",
        event: "onMemberAdded",
        extensionApi: this,
      }).api(),
      onMemberRemoved: new EventManager({
        context,
        module: "addressBook",
        event: "onMemberRemoved",
        extensionApi: this,
      }).api(),
    });

    return {
      addressBooks: {
        async openUI() {
          const messengerWindow = windowTracker.topNormalWindow;
          const abWindow = await messengerWindow.toAddressBook();
          await new Promise(resolve => abWindow.setTimeout(resolve));
          const abTab = messengerWindow.document
            .getElementById("tabmail")
            .tabInfo.find(t => t.mode.name == "addressBookTab");
          return tabManager.convert(abTab);
        },
        async closeUI() {
          for (const win of Services.wm.getEnumerator("mail:3pane")) {
            const tabmail = win.document.getElementById("tabmail");
            for (const tab of tabmail.tabInfo.slice()) {
              if (tab.browser?.currentURI.spec == "about:addressbook") {
                tabmail.closeTab(tab);
              }
            }
          }
        },

        list(complete = false) {
          return addressBookCache.convert(
            [...addressBookCache.addressBooks.values()],
            extension,
            complete
          );
        },
        get(id, complete = false) {
          return addressBookCache.convert(
            addressBookCache.findAddressBookById(id),
            extension,
            complete
          );
        },
        create({ name }) {
          const dirName = MailServices.ab.newAddressBook(
            name,
            "",
            Ci.nsIAbManager.JS_DIRECTORY_TYPE
          );
          const directory = MailServices.ab.getDirectoryFromId(dirName);
          return directory.UID;
        },
        update(id, { name }) {
          const node = addressBookCache.findAddressBookById(id);
          node.item.dirName = name;
        },
        async delete(id) {
          const node = addressBookCache.findAddressBookById(id);
          const deletePromise = new Promise(resolve => {
            const listener = () => {
              addressBookCache.off("address-book-deleted", listener);
              resolve();
            };
            addressBookCache.on("address-book-deleted", listener);
          });
          MailServices.ab.deleteAddressBook(node.item.URI);
          await deletePromise;
        },

        // The module name is addressBook as defined in ext-mail.json.
        onCreated: new EventManager({
          context,
          module: "addressBook",
          event: "onAddressBookCreated",
          extensionApi: this,
        }).api(),
        onUpdated: new EventManager({
          context,
          module: "addressBook",
          event: "onAddressBookUpdated",
          extensionApi: this,
        }).api(),
        onDeleted: new EventManager({
          context,
          module: "addressBook",
          event: "onAddressBookDeleted",
          extensionApi: this,
        }).api(),

        provider: {
          onSearchRequest: new EventManager({
            context,
            module: "addressBook",
            event: "onSearchRequest",
            extensionApi: this,
          }).api(),
        },
        contacts: getContactsApi(),
        mailingLists: getMailingListsApi(),
      },
      contacts: getContactsApi(),
      mailingLists: getMailingListsApi(),
    };
  }
};