Incomplete results for emails containing attachments in JMAP query

Issue Description

The JMAP query (using jmap-client-ts):

const query = await client.email_query({
			accountId: "f",
			filter: {
				hasAttachment: true
			},
			limit: 2000,
			calculateTotal: true
});

Returns incomplete results (only 32 emails instead of > 100) in my scenario. I am not quite sure whether this is really a bug or a broken database on my site.

Expected Behavior

Stalwart should return all emails containing attachments.

Actual Behavior

Stalwart returns exactly 32 emails. The weird thing is that most of them are relatively recent, then there is a huge gap, and the last few are from August 2025.

Reproduction Steps

  1. Setup Stalwart
  2. Have some emails in an inbox
  3. Use the provided TypeScript file

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

Internal

Additional Context

The same happens when using GitHub - root-fr/jmap-webmail: Modern JMAP webmail client built for Stalwart Mail Server. Privacy-focused, responsive, with real-time updates. · GitHub as a webmail client. Filtering for emails containing attachments only returns 32 emails (the same).

Script:

import { Client } from 'jmap-client-ts';

interface Transport {
    get<ResponseType>(url: string, headers: { [headerName: string]: string }): Promise<ResponseType>;
    post<ResponseType>(url: string, content: unknown, headers: { [headerName: string]: string }): Promise<ResponseType>;
}

const transport: Transport = {
    get: async <ResponseType>(url: string, headers: { [headerName: string]: string }) => {
        const response = await fetch(url, { method: 'GET', headers });

        if (!response.ok) {
            throw new Error(`GET ${url} failed with status ${response.status}`);
        }

        return (await response.json()) as ResponseType;
    },
    post: async <ResponseType>(url: string, content: unknown, headers: { [headerName: string]: string }) => {
        const response = await fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                ...headers
            },
            body: JSON.stringify(content)
        });

        if (!response.ok) {
            throw new Error(`POST ${url} failed with status ${response.status}`);
        }

        return (await response.json()) as ResponseType;
    }
};

async function markEmailsWithAttachmentsAsUnread() {
    const API_TOKEN = process.env.JMAP_TOKEN || 'YOUR_ACCESS_TOKEN_HERE';
    const SESSION_URL = 'https://HOST/.well-known/jmap';

    const client = new Client({
        accessToken: API_TOKEN,
        sessionUrl: SESSION_URL,
        transport
    });

    await client.fetchSession();

    const emailIds: string[] = [];
    let position = 0;

    while (true) {
        const query = await client.email_query({
            accountId: "f",
            filter: {
                hasAttachment: true
            },
            position: position,
            limit: 2000,
            calculateTotal: true
        });

        emailIds.push(...query.ids);
        position += query.ids.length;

        if (query.ids.length === 0 || (query.total !== undefined && emailIds.length >= query.total)) {
            break;
        }
    }

    if (emailIds.length === 0) {
        console.log('No emails with attachments found.');
        return;
    }

    const update = Object.fromEntries(
        emailIds.map((id) => [id, { 'keywords/$seen': null }])
    );

    await client.email_set({
        accountId: "f",
        update
    });

    console.log(`Successfully marked ${emailIds.length} email(s) as unread.`);
}

markEmailsWithAttachmentsAsUnread().catch((error) => {
    console.error('Error while marking emails as unread:', error);
    process.exitCode = 1;
});

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

Let me research how Stalwart handles the hasAttachment filter and any related indexing behavior.

The CHANGELOG is very helpful. I can see two highly relevant items:

  1. In the v0.16.0 section: JMAP: Fix hasAttachment search field (#2778) - meaning hasAttachment search indexing was broken before 0.16.0 and was fixed in 0.16.0.
  2. In v0.15.3: PostgreSQL search: Truncate emails to 650kb for full-text search indexing.

Now let me check if there’s a re-indexing capability that could help fix stale/missing FTS index data:

This is a known bug that was fixed in the codebase. The CHANGELOG entry under v0.16.0 explicitly lists: Fix hasAttachment search field (#2778). If you are on an earlier build of the v0.16.x line (before this fix landed), upgrading to the latest image should resolve the incorrect hasAttachment filter results.

Additionally, because the hasAttachment field is stored in the FTS/search index, emails that were indexed before the fix may still have stale or missing index entries. After upgrading, you can force a full re-index by creating a StoreMaintenance task with maintenanceType: reindexAccounts. This can be triggered from:

  • WebUI: Management › Tasks, create a StoreMaintenance task
  • CLI: stalwart-cli create task/StoreMaintenance
  • JMAP API: x:Task/set with a create argument for the StoreMaintenance variant

The reindexAccounts maintenance type is documented as “Reindex all accounts’ data for search”, which will rebuild the hasAttachment boolean field for all messages.

References:

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

1 Like

The hasAttachment flag is computed from MessageMetadata at ingest and stored in the index; older messages that pre-date that index field never had it set, which is why your query is finding a sliver of recent messages plus a few stragglers. The fix is to reindex the account: from the admin UI, Tasks → Perform account maintenance → pick “Reindex”. You can also do it via stalwart-cli or by hitting the maintenance endpoint directly. After it finishes, hasAttachment should return the full set.

There is no actual cap of 32 anywhere in the query path, so we can rule out a pagination or limit bug; it is purely the index being incomplete on legacy messages.