IMAP LOGIN with an invalid app password returns an untagged NO instead of a tagged completion

Issue Description

When an existing account authenticates over IMAP using a syntactically valid but invalid app-password token, Stalwart returns:

  • NO [AUTHENTICATIONFAILED] Authentication failed

The response is untagged, and the LOGIN command receives no tagged completion. A client waiting for its command tag therefore keeps waiting instead of reporting the authentication failure.

An ordinary incorrect account password correctly receives a tagged NO response.

Environment:

  • Stalwart v0.16.24, official Linux binary
  • Internal directory
  • IMAP over implicit TLS on port 993
  • Affected webmail client: Roundcube 1.7.4

Root cause identified in the v0.16.24 source:

  1. validate_credential() in crates/common/src/auth/authentication.rs adds the numeric credential ID as Key::Id to its authentication errors: “Invalid credential secret”, “Credential not found for account” and “Credential has expired”.

  2. authenticate() in crates/imap/src/op/authenticate.rs calls err.id(tag.clone()), adding the IMAP command tag under the same Key::Id.

  3. In crates/trc/src/event/mod.rs, id() delegates to ctx(), which appends rather than replaces. value() returns the first matching key, and value_as_str() attempts to read that value as a string.

  4. SerializeResponse for trc::Error in crates/imap-proto/src/protocol/mod.rs uses value_as_str(Key::Id) for the response tag. It finds the numeric credential ID first, which is not a string, so it emits “*” instead of the command tag.

The ordinary wrong-password path does not add that numeric Key::Id, so its response is tagged correctly.

The IMAP command tag needs to remain distinguishable from the credential ID used as diagnostic metadata.

Expected Behavior

A rejected LOGIN command should promptly receive a tagged completion, for example:

a1 NO [AUTHENTICATIONFAILED] Authentication failed

The client can then report invalid credentials immediately.

RFC 9051, section 2.2.2, specifies that a command completion response is tagged with the same tag as the client command:

Actual Behavior

Stalwart promptly sends an untagged authentication failure but never sends the tagged completion. The client stays blocked until the server’s unauthenticated-session timeout closes the connection: Imap.timeoutAnonymous, 60000 ms by default. In our reproduction on v0.16.24 the untagged NO arrived after 0.06 s and the connection was closed with “* BYE Connection timed out.” after 60.06 s, with no tagged completion in between.

In Roundcube this appears as a wait of about 60 seconds followed by a generic IMAP connection error instead of an immediate login failure. Each affected request occupies a PHP worker while it waits.

Reproduction Steps

  1. Connect to Stalwart over IMAP with implicit TLS on port 993.

  2. Use the username of an existing account and a deliberately invalid synthetic app-password token: “app_” followed by 36 lowercase “a” characters.

  3. Send:
    a1 LOGIN “[email protected]” “app_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”

  4. Observe:

    • NO [AUTHENTICATIONFAILED] Authentication failed

    No “a1 NO” completion follows; the server closes the connection only after Imap.timeoutAnonymous.

  5. Open a fresh connection and compare with an ordinary incorrect password:
    a1 LOGIN “[email protected]” “wrong-password-xyz”

  6. This time, observe the correctly tagged response:
    a1 NO [AUTHENTICATIONFAILED] Authentication failed

Source inspection shows the same tag collision for a revoked app password, an app password whose secret is incorrect and an expired one.

Relevant Log Output

Direct protocol reproduction on Stalwart v0.16.24, default Imap.timeoutAnonymous.

Connection 1:
C: a1 LOGIN “[email protected]” “app_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”
S (+0.06 s): * NO [AUTHENTICATIONFAILED] Authentication failed
S (+60.06 s): * BYE Connection timed out.
[connection closed by the server; no “a1” completion was ever sent]

Connection 2:
C: a1 LOGIN “[email protected]” “wrong-password-xyz”
S (+0.32 s): a1 NO [AUTHENTICATIONFAILED] Authentication failed

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

Client: Roundcube 1.7.4 webmail on the same host, connecting to 127.0.0.1:993 with implicit TLS; no proxy between the client and Stalwart’s IMAP listener. The transcript above was captured with a plain TLS socket straight to 127.0.0.1:993 on the server, so neither the client nor the network is involved. OS: AlmaLinux 10. Imap.timeoutAnonymous is at its default.

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 confirms the user’s analysis of the root cause. This is a legitimate confirmed source-level bug report, not a question I need to research further to answer—I should acknowledge it accurately based on what’s found, but I have no further docs to add. Let me check the imap-proto serialize function and authenticate.rs briefly to be precise in my reply.

This confirms the reported root cause exactly: value_as_str(trc::Key::Id) finds the first Key::Id entry (which may be the numeric credential ID pushed by validate_credential), and if it’s not a string, value_as_str returns None, causing serialize() to fall back to * instead of the actual tag pushed later by authenticate().

Let me check the authenticate.rs call site to confirm ordering, and see if there’s a public tracker/forum precedent worth checking.

This confirms the report’s root cause analysis is accurate against the current source: validate_credential() attaches Key::Id with the numeric credential id via .ctx(trc::Key::Id, credential_id), and later err.id(tag.clone()) in authenticate() appends the IMAP tag under the same Key::Id key via ctx() (which only pushes, not replaces). Since SerializeResponse::serialize() for trc::Error does self.value_as_str(trc::Key::Id), and value() returns the first matching key, it picks up the non-string numeric credential id first, fails as_str(), and falls back to * for the tag.

This confirms the reported root cause exactly. I have enough to answer confidently, noting this is a genuine bug confirmed in source, matching the analysis exactly.

This is a confirmed bug in how app-password (secondary credential) authentication failures build their error context, not something covered or fixable via configuration. I verified it directly in the source and your analysis is accurate.

In crates/common/src/auth/authentication.rs, the app-password validation path attaches the numeric credential ID under trc::Key::Id:

return Err(trc::AuthEvent::Failed
    .into_err()
    .ctx(trc::Key::AccountName, account.name)
    .ctx(trc::Key::AccountId, account_id)
    .ctx(trc::Key::Id, credential_id)
    .ctx(trc::Key::SpanId, span_id)
    .reason("Invalid credential secret"));

The same pattern appears for “Credential not found for account” and “Credential has expired”. Later, authenticate() in crates/imap/src/op/authenticate.rs calls .id(tag.clone()), which (via ctx) appends the IMAP tag as a second Key::Id entry rather than replacing the existing numeric one. Since Event::value() returns the first match for a key, and SerializeResponse for trc::Error in crates/imap-proto/src/protocol/mod.rs does self.value_as_str(trc::Key::Id) to get the tag, it finds the non-string numeric credential ID first, as_str() fails, and the code falls back to * (untagged). The regular wrong-password path never sets Key::Id for the credential, so it doesn’t collide and the tag serializes correctly.

This is a real defect in the tag-serialization/credential-context collision, not a documented or configurable behavior, so there’s nothing to point you to in the docs to work around it. A maintainer will need to look at deconflicting the Key::Id usage (e.g. giving the credential ID its own key) so the IMAP tag is never shadowed.

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

Fixed in v0.16.25 (yet to be released).