Example #1
0
  public Element handle(Element request, Map<String, Object> context) throws ServiceException {
    ZimbraSoapContext zsc = getZimbraSoapContext(context);
    Account account = getRequestedAccount(zsc);

    if (!canModifyOptions(zsc, account))
      throw ServiceException.PERM_DENIED("can not modify options");

    HashMap<String, Object> prefs = new HashMap<String, Object>();
    Map<String, Set<String>> name2uniqueAttrValues = new HashMap<String, Set<String>>();
    for (KeyValuePair kvp :
        request.listKeyValuePairs(AccountConstants.E_PREF, AccountConstants.A_NAME)) {
      String name = kvp.getKey(), value = kvp.getValue();
      char ch = name.length() > 0 ? name.charAt(0) : 0;
      int offset = ch == '+' || ch == '-' ? 1 : 0;
      if (!name.startsWith(PREF_PREFIX, offset))
        throw ServiceException.INVALID_REQUEST("pref name must start with " + PREF_PREFIX, null);

      AttributeInfo attrInfo =
          AttributeManager.getInstance().getAttributeInfo(name.substring(offset));
      if (attrInfo == null) {
        throw ServiceException.INVALID_REQUEST("no such attribute: " + name, null);
      }
      if (attrInfo.isCaseInsensitive()) {
        String valueLowerCase = Strings.nullToEmpty(value).toLowerCase();
        if (name2uniqueAttrValues.get(name) == null) {
          Set<String> set = new HashSet<String>();
          set.add(valueLowerCase);
          name2uniqueAttrValues.put(name, set);
          StringUtil.addToMultiMap(prefs, name, value);
        } else {
          Set<String> set = name2uniqueAttrValues.get(name);
          if (set.add(valueLowerCase)) {
            StringUtil.addToMultiMap(prefs, name, value);
          }
        }
      } else {
        StringUtil.addToMultiMap(prefs, name, value);
      }
    }

    if (prefs.containsKey(Provisioning.A_zimbraPrefMailForwardingAddress)) {
      if (!account.getBooleanAttr(Provisioning.A_zimbraFeatureMailForwardingEnabled, false))
        throw ServiceException.PERM_DENIED("forwarding not enabled");
    }

    // call modifyAttrs and pass true to checkImmutable
    Provisioning.getInstance().modifyAttrs(account, prefs, true, zsc.getAuthToken());

    Element response = zsc.createElement(AccountConstants.MODIFY_PREFS_RESPONSE);
    return response;
  }
Example #2
0
  public Element handle(Element request, Map<String, Object> context) throws ServiceException {

    ZimbraSoapContext zsc = getZimbraSoapContext(context);
    Provisioning prov = Provisioning.getInstance();

    String id = request.getAttribute(AdminConstants.E_ID);

    Domain domain = prov.get(DomainBy.id, id);
    if (domain == null) throw AccountServiceException.NO_SUCH_DOMAIN(id);

    if (domain.isShutdown())
      throw ServiceException.PERM_DENIED(
          "can not access domain, domain is in " + domain.getDomainStatusAsString() + " status");

    checkRight(zsc, context, domain, Admin.R_deleteDomain);

    String name = domain.getName();

    prov.deleteDomain(id);

    ZimbraLog.security.info(
        ZimbraLog.encodeAttrs(new String[] {"cmd", "DeleteDomain", "name", name, "id", id}));

    Element response = zsc.createElement(AdminConstants.DELETE_DOMAIN_RESPONSE);
    return response;
  }
  private static Account validateAuthTokenInternal(
      Provisioning prov, AuthToken at, boolean addToLoggingContext) throws ServiceException {
    if (prov == null) {
      prov = Provisioning.getInstance();
    }

    if (at.isExpired()) {
      throw ServiceException.AUTH_EXPIRED();
    }

    // make sure that the authenticated account is still active and has not been deleted since the
    // last request
    String acctId = at.getAccountId();
    Account acct = prov.get(AccountBy.id, acctId, at);

    if (acct == null) {
      throw ServiceException.AUTH_EXPIRED("account " + acctId + " not found");
    }

    if (addToLoggingContext) {
      ZimbraLog.addAccountNameToContext(acct.getName());
    }

    if (!acct.checkAuthTokenValidityValue(at)) {
      throw ServiceException.AUTH_EXPIRED("invalid validity value");
    }

    boolean delegatedAuth = at.isDelegatedAuth();
    String acctStatus = acct.getAccountStatus(prov);

    if (!delegatedAuth && !Provisioning.ACCOUNT_STATUS_ACTIVE.equals(acctStatus)) {
      throw ServiceException.AUTH_EXPIRED("account not active");
    }

    // if using delegated auth, make sure the "admin" is really an active admin account
    if (delegatedAuth) {

      // note that delegated auth allows access unless the account's in maintenance mode
      if (Provisioning.ACCOUNT_STATUS_MAINTENANCE.equals(acctStatus)) {
        throw ServiceException.AUTH_EXPIRED("delegated account in MAINTENANCE mode");
      }

      Account admin = prov.get(AccountBy.id, at.getAdminAccountId());
      if (admin == null) {
        throw ServiceException.AUTH_EXPIRED(
            "delegating account " + at.getAdminAccountId() + " not found");
      }

      boolean isAdmin = AdminAccessControl.isAdequateAdminAccount(admin);
      if (!isAdmin) {
        throw ServiceException.PERM_DENIED("not an admin for delegated auth");
      }

      if (!Provisioning.ACCOUNT_STATUS_ACTIVE.equals(admin.getAccountStatus(prov))) {
        throw ServiceException.AUTH_EXPIRED("delegating account is not active");
      }
    }

    return acct;
  }
Example #4
0
  /**
   * Determines the set of {@link Message}s to be deleted from this <code>Conversation</code>.
   * Assembles a new {@link PendingDelete} object encapsulating the data on the items to be deleted.
   *
   * <p>A message will be deleted unless:
   *
   * <ul>
   *   <li>The caller lacks {@link ACL#RIGHT_DELETE} permission on the <code>Message</code>.
   *   <li>The caller has specified a {@link MailItem.TargetConstraint} that explicitly excludes the
   *       <code>Message</code>.
   *   <li>The caller has specified the maximum change number they know about, and the
   *       (modification/content) change number on the <code>Message</code> is greater.
   * </ul>
   *
   * As a result of all these constraints, no messages may actually be deleted.
   *
   * @throws ServiceException The following error codes are possible:
   *     <ul>
   *       <li><code>mail.MODIFY_CONFLICT</code> - if the caller specified a max change number and a
   *           modification check, and the modified change number of the <code>Message</code> is
   *           greater
   *       <li><code>service.FAILURE</code> - if there's a database failure fetching the message
   *           list
   *     </ul>
   */
  @Override
  PendingDelete getDeletionInfo() throws ServiceException {
    PendingDelete info = new PendingDelete();
    info.rootId = mId;
    info.itemIds.add(getType(), mId);

    if (mData.size == 0) return info;
    List<Message> msgs = getMessages();
    TargetConstraint tcon = mMailbox.getOperationTargetConstraint();

    boolean excludeModify = false, excludeAccess = false;
    for (Message child : msgs) {
      // silently skip explicitly excluded messages, PERMISSION_DENIED messages, and MODIFY_CONFLICT
      // messages
      if (!TargetConstraint.checkItem(tcon, child)) continue;
      else if (!child.canAccess(ACL.RIGHT_DELETE)) excludeAccess = true;
      else if (!child.checkChangeID()) excludeModify = true;
      else info.add(child.getDeletionInfo());
    }

    int totalDeleted = info.itemIds.size();
    if (totalDeleted == 1) {
      // if all messages have been excluded, some for "error" reasons, throw an exception
      if (excludeAccess)
        throw ServiceException.PERM_DENIED("you do not have sufficient permissions");
      if (excludeModify) throw MailServiceException.MODIFY_CONFLICT();
    }
    if (totalDeleted != msgs.size() + 1) info.incomplete = true;
    return info;
  }
Example #5
0
  public Element handle(Element request, Map<String, Object> context) throws ServiceException {
    ZimbraSoapContext zsc = getZimbraSoapContext(context);
    Account account = getRequestedAccount(zsc);

    if (!canAccessAccount(zsc, account))
      throw ServiceException.PERM_DENIED("can not access account");

    Element response = zsc.createElement(AccountConstants.GET_AVAILABLE_CSV_FORMATS_RESPONSE);
    for (String format : ContactCSV.getAllFormatNames())
      response.addElement(AccountConstants.E_CSV).addAttribute(AccountConstants.A_NAME, format);
    return response;
  }
Example #6
0
  /**
   * check the checkRight right
   *
   * <p>check if the authed admin has the checkRight right on the user/group it is checking right
   * for.
   *
   * @param zsc
   * @param granteeType
   * @param granteeBy
   * @param grantee
   * @return whether the checkRight right is checked
   * @throws ServiceException
   */
  protected boolean checkCheckRightRight(
      ZimbraSoapContext zsc,
      GranteeType granteeType,
      GranteeBy granteeBy,
      String grantee,
      boolean granteeCanBeExternalEmailAddr)
      throws ServiceException {

    NamedEntry granteeEntry = null;

    try {
      granteeEntry =
          GranteeType.lookupGrantee(Provisioning.getInstance(), granteeType, granteeBy, grantee);
    } catch (ServiceException e) {
      // grantee to check could be an external email address
      ZimbraLog.acl.debug("unable to find grantee", e);
    }

    if (granteeEntry != null) {
      // call checkRight instead of checkAccountRight because there is no
      // backward compatibility issue for this SOAP.
      //
      // Note: granteeEntry is the target for the R_checkRight{Usr}/{Grp} right here
      if (granteeType == GranteeType.GT_USER) checkRight(zsc, granteeEntry, Admin.R_checkRightUsr);
      else if (granteeType == GranteeType.GT_GROUP)
        checkRight(zsc, granteeEntry, Admin.R_checkRightGrp);
      else
        throw ServiceException.PERM_DENIED(
            "invalid grantee type for check right:" + granteeType.getCode());

      return true;
    } else {
      if (granteeCanBeExternalEmailAddr) return false;
      else throw ServiceException.PERM_DENIED("unable to check checkRight right for " + grantee);
    }
  }
Example #7
0
  @Override
  public Element handle(Element request, Map<String, Object> context) throws ServiceException {
    ZimbraSoapContext zsc = getZimbraSoapContext(context);

    Element mreq = request.getElement(AdminConstants.E_MAILBOX);
    String accountId = mreq.getAttribute(AdminConstants.A_ACCOUNTID);

    Account account = Provisioning.getInstance().get(AccountBy.id, accountId, zsc.getAuthToken());
    if (account == null) {
      // Note: isDomainAdminOnly *always* returns false for pure ACL based AccessManager
      if (isDomainAdminOnly(zsc)) {
        throw ServiceException.PERM_DENIED(
            "account doesn't exist, unable to determine authorization");
      }

      // still need to check right, since we don't have an account, the
      // last resort is checking the global grant.  Do this for now until
      // there is complain.
      checkRight(zsc, context, null, Admin.R_deleteAccount);

      ZimbraLog.account.warn(
          "DeleteMailbox: account doesn't exist: " + accountId + " (still deleting mailbox)");

    } else {
      checkAccountRight(zsc, account, Admin.R_deleteAccount);
    }

    Mailbox mbox = MailboxManager.getInstance().getMailboxByAccountId(accountId, false);
    int mailboxId = -1;
    if (mbox != null) {
      mailboxId = mbox.getId();
      mbox.deleteMailbox();
    }

    String idString =
        (mbox == null) ? "<no mailbox for account " + accountId + ">" : Integer.toString(mailboxId);
    ZimbraLog.security.info(
        ZimbraLog.encodeAttrs(new String[] {"cmd", "DeleteMailbox", "id", idString}));

    Element response = zsc.createElement(AdminConstants.DELETE_MAILBOX_RESPONSE);
    if (mbox != null)
      response
          .addElement(AdminConstants.E_MAILBOX)
          .addAttribute(AdminConstants.A_MAILBOXID, mailboxId);
    return response;
  }
  public Element handle(Element request, Map<String, Object> context) throws ServiceException {
    ZimbraSoapContext zsc = getZimbraSoapContext(context);
    if (!AccessControlUtil.isGlobalAdmin(getAuthenticatedAccount(zsc))) {
      throw ServiceException.PERM_DENIED("only global admin is allowed");
    }

    Map<String, Long> domainAggrQuotaUsed = new HashMap<String, Long>();

    SearchAccountsOptions searchOpts = new SearchAccountsOptions();
    searchOpts.setIncludeType(IncludeType.ACCOUNTS_ONLY);
    searchOpts.setMakeObjectOpt(MakeObjectOpt.NO_DEFAULTS);

    Provisioning prov = Provisioning.getInstance();
    List<NamedEntry> accounts = prov.searchAccountsOnServer(prov.getLocalServer(), searchOpts);
    Map<String, Long> acctQuotaUsed = MailboxManager.getInstance().getMailboxSizes(accounts);
    for (NamedEntry ne : accounts) {
      if (!(ne instanceof Account)) {
        continue;
      }
      Account acct = (Account) ne;
      Long acctQuota = acctQuotaUsed.get(acct.getId());
      if (acctQuota == null) {
        acctQuota = 0L;
      }
      String domainId = acct.getDomainId();
      if (domainId == null) {
        continue;
      }
      Long aggrQuota = domainAggrQuotaUsed.get(domainId);
      domainAggrQuotaUsed.put(domainId, aggrQuota == null ? acctQuota : aggrQuota + acctQuota);
    }

    Element response = zsc.createElement(AdminConstants.GET_AGGR_QUOTA_USAGE_ON_SERVER_RESPONSE);
    for (String domainId : domainAggrQuotaUsed.keySet()) {
      Domain domain = prov.getDomainById(domainId);
      Element domainElt = response.addElement(AdminConstants.E_DOMAIN);
      domainElt.addAttribute(AdminConstants.A_NAME, domain.getName());
      domainElt.addAttribute(AdminConstants.A_ID, domainId);
      domainElt.addAttribute(AdminConstants.A_QUOTA_USED, domainAggrQuotaUsed.get(domainId));
    }
    return response;
  }
Example #9
0
  /**
   * Updates the unread state of all messages in the conversation. Persists the change to the
   * database and cache, and also updates the unread counts for the affected items' {@link Folder}s
   * and {@link Tag}s appropriately.
   *
   * <p>Messages in the conversation are omitted from this operation if one or more of the following
   * applies:
   *
   * <ul>
   *   <li>The caller lacks {@link ACL#RIGHT_WRITE} permission on the <code>Message</code>.
   *   <li>The caller has specified a {@link MailItem.TargetConstraint} that explicitly excludes the
   *       <code>Message</code>.
   *   <li>The caller has specified the maximum change number they know about, and the
   *       (modification/content) change number on the <code>Message</code> is greater.
   * </ul>
   *
   * As a result of all these constraints, no messages may actually be marked read/unread.
   *
   * @perms {@link ACL#RIGHT_WRITE} on all the messages
   */
  @Override
  void alterUnread(boolean unread) throws ServiceException {
    markItemModified(Change.MODIFIED_UNREAD);

    boolean excludeAccess = false;

    // Decrement the in-memory unread count of each message.  Each message will
    // then implicitly decrement the unread count for its conversation, folder
    // and tags.
    TargetConstraint tcon = mMailbox.getOperationTargetConstraint();
    List<Integer> targets = new ArrayList<Integer>();
    for (Message msg : getMessages()) {
      // skip messages that don't need to be changed, or that the client can't modify, doesn't know
      // about, or has explicitly excluded
      if (msg.isUnread() == unread) {
        continue;
      } else if (!msg.canAccess(ACL.RIGHT_WRITE)) {
        excludeAccess = true;
        continue;
      } else if (!msg.checkChangeID() || !TargetConstraint.checkItem(tcon, msg)) {
        continue;
      }

      int delta = unread ? 1 : -1;
      msg.updateUnread(delta, msg.isTagged(Flag.ID_FLAG_DELETED) ? delta : 0);
      msg.mData.metadataChanged(mMailbox);
      targets.add(msg.getId());
    }

    // mark the selected messages in this conversation as read in the database
    if (targets.isEmpty()) {
      if (excludeAccess)
        throw ServiceException.PERM_DENIED("you do not have sufficient permissions");
    } else {
      DbMailItem.alterUnread(mMailbox, targets, unread);
    }
  }
Example #10
0
  /**
   * Moves all the conversation's {@link Message}s to a different {@link Folder}. Persists the
   * change to the database and the in-memory cache. Updates all relevant unread counts, folder
   * sizes, etc.
   *
   * <p>Messages moved to the Trash folder are automatically marked read. Conversations moved to the
   * Junk folder will not receive newly-delivered messages.
   *
   * <p>Messages in the conversation are omitted from this operation if one or more of the following
   * applies:
   *
   * <ul>
   *   <li>The caller lacks {@link ACL#RIGHT_WRITE} permission on the <code>Message</code>.
   *   <li>The caller has specified a {@link MailItem.TargetConstraint} that explicitly excludes the
   *       <code>Message</code>.
   *   <li>The caller has specified the maximum change number they know about, and the
   *       (modification/content) change number on the <code>Message</code> is greater.
   * </ul>
   *
   * As a result of all these constraints, no messages may actually be moved.
   *
   * @perms {@link ACL#RIGHT_INSERT} on the target folder, {@link ACL#RIGHT_DELETE} on the messages'
   *     source folders
   */
  @Override
  boolean move(Folder target) throws ServiceException {
    if (!target.canContain(TYPE_MESSAGE)) throw MailServiceException.CANNOT_CONTAIN();
    markItemModified(Change.UNMODIFIED);

    List<Message> msgs = getMessages();
    TargetConstraint tcon = mMailbox.getOperationTargetConstraint();
    boolean toTrash = target.inTrash();
    int oldUnread = 0;
    for (Message msg : msgs) if (msg.isUnread()) oldUnread++;
    // if mData.unread is wrong, what to do?  right now, always use the calculated value
    mData.unreadCount = oldUnread;

    boolean excludeAccess = false;

    List<Integer> markedRead = new ArrayList<Integer>();
    List<Message> moved = new ArrayList<Message>();
    List<Message> indexUpdated = new ArrayList<Message>();

    for (Message msg : msgs) {
      Folder source = msg.getFolder();

      // skip messages that don't need to be moved, or that the client can't modify, doesn't know
      // about, or has explicitly excluded
      if (source.getId() == target.getId()) {
        continue;
      } else if (!source.canAccess(ACL.RIGHT_DELETE)) {
        excludeAccess = true;
        continue;
      } else if (target.getId() != Mailbox.ID_FOLDER_TRASH
          && target.getId() != Mailbox.ID_FOLDER_SPAM
          && !target.canAccess(ACL.RIGHT_INSERT)) {
        excludeAccess = true;
        continue;
      } else if (!msg.checkChangeID() || !TargetConstraint.checkItem(tcon, msg)) {
        continue;
      }

      boolean isDeleted = msg.isTagged(Flag.ID_FLAG_DELETED);
      if (msg.isUnread()) {
        if (!toTrash || msg.inTrash()) {
          source.updateUnread(-1, isDeleted ? -1 : 0);
          target.updateUnread(1, isDeleted ? 1 : 0);
        } else {
          // unread messages moved from Mailbox to Trash need to be marked read:
          //   update cached unread counts (message, conversation, folder, tags)
          msg.updateUnread(-1, isDeleted ? -1 : 0);
          //   note that we need to update this message in the DB
          markedRead.add(msg.getId());
        }
      }

      // moved an item out of the spam folder, need to index it
      if (msg.inSpam() && !target.inSpam()) {
        if (msg.isIndexed() && msg.getIndexId() != -1) {
          msg.indexIdChanged(msg.getId());
          indexUpdated.add(msg);
        }
      }

      // if a draft is being moved to Trash then remove any "send-later" info from it
      if (toTrash && msg.isDraft()) msg.setDraftAutoSendTime(0);

      // handle folder message counts
      source.updateSize(-1, isDeleted ? -1 : 0, -msg.getTotalSize());
      target.updateSize(1, isDeleted ? 1 : 0, msg.getTotalSize());

      moved.add(msg);
      msg.folderChanged(target, 0);
    }

    // mark unread messages moved from Mailbox to Trash/Spam as read in the DB
    if (!markedRead.isEmpty()) DbMailItem.alterUnread(target.getMailbox(), markedRead, false);

    if (moved.isEmpty()) {
      if (excludeAccess)
        throw ServiceException.PERM_DENIED("you do not have sufficient permissions");
    } else {
      // moving a conversation to spam closes it
      if (target.inSpam()) detach();
      if (ZimbraLog.mailop.isInfoEnabled()) {
        StringBuilder ids = new StringBuilder();
        for (int i = 0; i < moved.size(); i++) {
          if (i > 0) {
            ids.append(',');
          }
          ids.append(moved.get(i).getId());
        }
        ZimbraLog.mailop.info(
            "Moving %s to %s.  Affected message ids: %s.",
            getMailopContext(this), getMailopContext(target), ids);
      }
      DbMailItem.setFolder(moved, target);

      if (!indexUpdated.isEmpty()) {
        DbMailItem.setIndexIds(mMailbox, indexUpdated);
        for (Message msg : indexUpdated) {
          mMailbox.queueForIndexing(msg, false, null);
        }
      }
    }

    return !moved.isEmpty();
  }
Example #11
0
  /**
   * Tags or untags all messages in the conversation. Persists the change to the database and cache.
   * If the conversation includes at least one unread {@link Message} whose tagged state is
   * changing, updates the {@link Tag}'s unread count appropriately.
   *
   * <p>Messages in the conversation are omitted from this operation if one or more of the following
   * applies:
   *
   * <ul>
   *   <li>The caller lacks {@link ACL#RIGHT_WRITE} permission on the <code>Message</code>.
   *   <li>The caller has specified a {@link MailItem.TargetConstraint} that explicitly excludes the
   *       <code>Message</code>.
   *   <li>The caller has specified the maximum change number they know about, and the
   *       (modification/content) change number on the <code>Message</code> is greater.
   * </ul>
   *
   * As a result of all these constraints, no messages may actually be tagged/untagged.
   *
   * @perms {@link ACL#RIGHT_WRITE} on all the messages
   */
  @Override
  void alterTag(Tag tag, boolean add) throws ServiceException {
    if (tag == null) throw ServiceException.FAILURE("missing tag argument", null);
    if (!add && !isTagged(tag)) return;
    if (tag.getId() == Flag.ID_FLAG_UNREAD)
      throw ServiceException.FAILURE("unread state must be set with alterUnread", null);
    // don't let the user tag things as "has attachments" or "draft"
    if (tag instanceof Flag && (tag.getBitmask() & Flag.FLAG_SYSTEM) != 0)
      throw MailServiceException.CANNOT_TAG(tag, this);

    markItemModified(tag instanceof Flag ? Change.MODIFIED_FLAGS : Change.MODIFIED_TAGS);

    TargetConstraint tcon = mMailbox.getOperationTargetConstraint();
    boolean excludeAccess = false;

    List<Message> msgs = getMessages();
    List<Integer> targets = new ArrayList<Integer>(msgs.size());
    for (Message msg : msgs) {
      // skip messages that don't need to be changed, or that the client can't modify, doesn't know
      // about, or has explicitly excluded
      if (msg.isTagged(tag) == add) {
        continue;
      } else if (!msg.canAccess(ACL.RIGHT_WRITE)) {
        excludeAccess = true;
        continue;
      } else if (!msg.checkChangeID() || !TargetConstraint.checkItem(tcon, msg)) {
        continue;
      } else if (add && !tag.canTag(msg)) {
        throw MailServiceException.CANNOT_TAG(tag, this);
      }

      targets.add(msg.getId());
      msg.tagChanged(tag, add);

      // since we're adding/removing a tag, the tag's unread count may change
      int delta = add ? 1 : -1;
      if (tag.trackUnread() && msg.isUnread())
        tag.updateUnread(delta, isTagged(Flag.ID_FLAG_DELETED) ? delta : 0);

      // if we're adding/removing the \Deleted flag, update the folder and tag "deleted" and
      // "deleted unread" counts
      if (tag.getId() == Flag.ID_FLAG_DELETED) {
        getFolder().updateSize(0, delta, 0);
        // note that Message.updateUnread() calls updateTagUnread()
        if (msg.isUnread()) msg.updateUnread(0, delta);
      }
    }

    if (targets.isEmpty()) {
      if (excludeAccess)
        throw ServiceException.PERM_DENIED("you do not have sufficient permissions");
    } else {
      if (ZimbraLog.mailop.isDebugEnabled()) {
        String operation = add ? "Setting" : "Unsetting";
        ZimbraLog.mailop.debug(
            "%s %s for %s.  Affected ids: %s",
            operation,
            getMailopContext(tag),
            getMailopContext(this),
            StringUtil.join(",", targets));
      }
      recalculateCounts(msgs);
      DbMailItem.alterTag(tag, targets, add);
    }
  }