Principal/query returns broad results when name/email filter cannot be resolved

Issue Description

Principal/query appears to fail open when a name or email filter cannot be resolved to an account id.

Relevant code:

crates/jmap/src/principal/query.rs

PrincipalFilter::Name(name) | PrincipalFilter::Email(name) => {
    if let Some(account_id) = self.account_id_from_email(&name, false).await? {
        filters.push(SearchFilter::is_in_set(
            RoaringBitmap::from_sorted_iter([account_id]).unwrap(),
        ));
    }
}

If account_id_from_email(&name, false) returns None, no filter is pushed.

That unresolved path is reachable because account_id_from_email() returns Ok(None) when the address cannot be mapped to an account.

Relevant code:

crates/common/src/cache/principals.rs

Ok(result.and_then(|result| {
    if let EmailCache::Account(account_id) = result {
        Some(account_id)
    } else {
        None
    }
}))

and:

Ok(None)

Later, SearchQuery::filter() returns the full mask when there are no filters:

crates/store/src/search/local.rs

if self.filters.is_empty() {
    return QueryResults {
        results: self.mask,
        comparators: self.comparators,
    };
}

So an unresolved exact name / email predicate can result in all visible principals being returned instead of zero results.

Expected Behavior

If PrincipalFilter::Name or PrincipalFilter::Email cannot be resolved to an account id, the query should return no principals.

The unresolved predicate should not broaden the query.

Actual Behavior

The unresolved predicate is omitted from the search filter list.

If this leaves the query with no filters, SearchQuery::filter() returns the whole visible-principal mask.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

Internal

Directory Backend

SQL

Additional Context

Assume a tenant has multiple visible principals:

A client sends Principal/query with an exact email or name filter that does not resolve to an account.

Instead of returning an empty result, the unresolved predicate may be omitted. If no other filters remain, the search can return all visible principals for that tenant.

The client then receives multiple candidates for what was intended to be a single-principal lookup.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This is a well-identified bug report. Let me verify the code paths mentioned to give an accurate response.

The bug report is accurate and well-documented. The code analysis is correct:

  1. When PrincipalFilter::Name or PrincipalFilter::Email is evaluated, it calls account_id_from_email().
  2. If that call returns Ok(None) (address not found), no filter is pushed to the filters vec – the unresolved predicate is silently dropped.
  3. Downstream, SearchQuery::filter() in crates/store/src/search/local.rs short-circuits to return the full principal mask when filters is empty.
  4. Net result: an exact-match lookup for a non-existent email/name can return all visible principals for the tenant instead of an empty result set.

The fix is straightforward: when account_id_from_email returns None, push an empty-set filter (SearchFilter::is_in_set(RoaringBitmap::new())) rather than nothing. This mirrors how PrincipalFilter::Type already handles unresolvable types at line ~99 of query.rs:

filters.push(SearchFilter::is_in_set(Default::default()));

Applying the same pattern to the Name/Email branch would close the fail-open path:

PrincipalFilter::Name(name) | PrincipalFilter::Email(name) => {
    if let Some(account_id) = self.account_id_from_email(&name, false).await? {
        filters.push(SearchFilter::is_in_set(
            RoaringBitmap::from_sorted_iter([account_id]).unwrap(),
        ));
    } else {
        // Unresolvable predicate -- return no results, not all results
        filters.push(SearchFilter::is_in_set(RoaringBitmap::new()));
    }
}

This is a valid bug report against the current server source. A human maintainer will need to review and apply the fix.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

This has been fixed. The fix will be included in v0.16.11.

Thanks for the quick fix.