Skip to content

Conversation

@Tsopic
Copy link

@Tsopic Tsopic commented Jan 20, 2026

Summary

This PR implements two key features for improved JMAP server compatibility:

1. WebSocket Authentication Strategy Pattern

Implements a flexible strategy pattern for WebSocket authentication to support multiple JMAP servers:

  • James Server: Uses ticket-based authentication via jmapWebSocketTicket capability
  • Stalwart: Supports OIDC tokens via access_token query parameter
  • Reverse Proxy: Supports Basic auth via query parameters (requires proxy configuration)

Why This Is Needed

Browser WebSocket connections cannot set custom HTTP headers (like Authorization), so authentication must be passed via query parameters. Different servers support different authentication methods.

Reference: stalwartlabs/stalwart#2680

New Strategy Files

File Description
web_socket_auth_strategy.dart Abstract interface for auth strategies
ticket_auth_strategy.dart James server ticket-based auth
token_auth_strategy.dart OIDC/OAuth2 token auth (Stalwart)
basic_auth_strategy.dart HTTP Basic auth for reverse proxies
no_auth_strategy.dart Fallback for unauthenticated connections
web_socket_auth_strategy_selector.dart Automatic strategy selection
web_socket_capability_provider.dart JMAP capability detection
web_socket_uri_builder.dart URI utilities with credential redaction

Documentation

  • Added docs/websocket-auth-proxy.md - Reverse proxy configuration guide for nginx, Caddy, Traefik, and HAProxy

2. Email/get Batch Size Fix

Implements batched Email/get requests to respect server maxObjectsInGet limits.

Changes

  • Added getEmailsByIdsBatched method in MailApiMixin for batched fetching
  • Added getMaxObjectsInGetMethod to query server capabilities
  • Refactored ThreadApi.getChanges to use batched requests with early termination
  • Added defaultMaxObjectsInGet = 50 constant

Why This Is Needed

Stalwart and other JMAP servers enforce maxObjectsInGet limits. When loading threads with many emails (e.g., after WebSocket push notification), the client must batch requests to avoid exceeding server limits.

Test Plan

  • 54 new unit tests covering all authentication strategies, selector logic, capability provider, and URI builder
  • Flutter analyzer passes with no issues
  • All existing tests continue to pass

Test Coverage

Test File Tests
auth_strategies_test.dart 20 tests
web_socket_auth_strategy_selector_test.dart 5 tests
web_socket_capability_provider_test.dart 13 tests
web_socket_uri_builder_test.dart 16 tests

Breaking Changes

None. The refactoring maintains backward compatibility:

  • James users continue to use ticket authentication automatically
  • OIDC users (Stalwart) now have proper token authentication
  • Basic auth users need reverse proxy configuration (documented)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • WebSocket push: multiple auth strategies (OIDC token, Basic, James ticket, no-auth), capability detection, strategy selection, safer URI construction with sensitive-param redaction.
    • Batched email fetching and threaded-change sync to improve performance and handle large result sets.
    • Exposed token/credential access for environments using query-parameter-based WebSocket auth.
  • Documentation

    • Reverse-proxy guide for enabling WebSocket push (Nginx, Caddy, Traefik, HAProxy) with security and troubleshooting advice.
  • Tests

    • Added tests for auth strategies, capability provider, URI builder, and selector logic.

✏️ Tip: You can customize this high-level summary in your review settings.

## WebSocket Authentication Strategy Pattern

Implements a flexible strategy pattern for WebSocket authentication to support
multiple JMAP servers (James, Stalwart, and reverse proxy setups).

### New Files:
- `web_socket_auth_strategy.dart` - Abstract interface for auth strategies
- `ticket_auth_strategy.dart` - James server ticket-based authentication
- `token_auth_strategy.dart` - OIDC/OAuth2 token authentication (Stalwart)
- `basic_auth_strategy.dart` - HTTP Basic authentication for reverse proxies
- `no_auth_strategy.dart` - Fallback for unauthenticated connections
- `web_socket_auth_strategy_selector.dart` - Automatic strategy selection
- `web_socket_capability_provider.dart` - JMAP capability detection
- `web_socket_uri_builder.dart` - URI utilities with credential redaction

### Documentation:
- `docs/websocket-auth-proxy.md` - Reverse proxy configuration guide

### Why This Is Needed:
Browser WebSocket connections cannot set custom HTTP headers (like Authorization),
so authentication must be passed via query parameters. Different servers support
different authentication methods:
- James: Uses proprietary ticket-based auth (jmapWebSocketTicket capability)
- Stalwart: Supports OIDC tokens via access_token query parameter
- Reverse proxies: Can inject auth headers from query parameters

Reference: stalwartlabs/stalwart#2680

## Email/get Batch Size Fix

Implements batched Email/get requests to respect server maxObjectsInGet limits.

### Changes:
- Added `getEmailsByIdsBatched` method in `MailApiMixin` for batched fetching
- Added `getMaxObjectsInGetMethod` to query server capabilities
- Refactored `ThreadApi.getChanges` to use batched requests
- Added `defaultMaxObjectsInGet = 50` constant

### Why This Is Needed:
Stalwart and other JMAP servers enforce maxObjectsInGet limits. When loading
threads with many emails (e.g., after WebSocket push notification), the client
must batch requests to avoid exceeding server limits.

## Test Coverage:
- 20 tests for authentication strategies (TokenAuth, BasicAuth, NoAuth, TicketAuth)
- 5 tests for strategy selector logic
- 13 tests for capability provider
- 16 tests for URI builder utilities

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@coderabbitai
Copy link

coderabbitai bot commented Jan 20, 2026

Walkthrough

Adds a WebSocket push-notification strategy system and supporting utilities. Introduces an abstract WebSocketAuthStrategy and implementations: TicketAuthStrategy, TokenAuthStrategy, BasicAuthStrategy, and NoAuthStrategy. Adds WebSocketCapabilityProvider, WebSocketAuthStrategySelector, and WebSocketUriBuilder. Reworks the WebSocket datasource to use capability validation and strategy-driven URI construction, exposes token/credential getters in AuthorizationInterceptors, adds batched email fetching and Thread API changes, updates dependency bindings, includes documentation for reverse-proxy WebSocket auth, and adds tests.

Suggested labels

Label

Suggested reviewers

  • hoangdat
  • tddang-linagora
  • zatteo
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: WebSocket authentication strategy pattern and Email/get batching, which are the core objectives of the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@lib/features/base/mixin/mail_api_mixin.dart`:
- Around line 159-167: The current code unconditionally assigns latestState =
emailResponse?.state which can overwrite a previously valid latestState with
null; change the logic in the batch handling (where emailResponse is processed
alongside allEmails and allNotFoundIds) to only update latestState when
emailResponse?.state is non-null (e.g., check emailResponse?.state != null
before assigning to latestState) so existing state is preserved when a batch
response has no state.

In
`@lib/features/push_notification/data/datasource/strategies/ticket_auth_strategy.dart`:
- Around line 42-50: The buildConnectionUri method in TicketAuthStrategy
currently constructs the URI via string interpolation which breaks when baseUri
already has query parameters; replace that logic by calling
baseUri.replace(queryParameters: {...}) to merge existing query parameters with
the ticket parameter returned from _webSocketApi.getWebSocketTicket(session,
accountId) (i.e., get the webSocketTicket, create a new map copying
baseUri.queryParameters and adding 'ticket': webSocketTicket, then call
baseUri.replace with that map) so the resulting Uri is built safely and
consistently with TokenAuthStrategy/BasicAuthStrategy.
🧹 Nitpick comments (4)
lib/features/push_notification/data/datasource/strategies/token_auth_strategy.dart (1)

44-49: Use WebSocketUriBuilder.addQueryParam for consistency.

This duplicates the URI query parameter logic that WebSocketUriBuilder.addQueryParam was created for. Using the utility improves consistency across strategies and centralizes URI manipulation logic.

♻️ Suggested refactor
+import 'package:tmail_ui_user/features/push_notification/data/utils/web_socket_uri_builder.dart';
 
 // In buildConnectionUri:
     if (token != null && token.isNotEmpty) {
-      return baseUri.replace(queryParameters: {
-        ...baseUri.queryParameters,
-        'access_token': token,
-      });
+      return WebSocketUriBuilder.addQueryParam(baseUri, 'access_token', token);
     }
lib/features/push_notification/data/datasource/strategies/basic_auth_strategy.dart (1)

44-49: Use WebSocketUriBuilder.addQueryParam for consistency.

Same refactor opportunity as in TokenAuthStrategy. Using the utility centralizes URI manipulation and ensures consistent behavior across all auth strategies.

♻️ Suggested refactor
+import 'package:tmail_ui_user/features/push_notification/data/utils/web_socket_uri_builder.dart';
 
 // In buildConnectionUri:
     if (basicAuth != null && basicAuth.isNotEmpty) {
-      return baseUri.replace(queryParameters: {
-        ...baseUri.queryParameters,
-        'authorization': 'Basic $basicAuth',
-      });
+      return WebSocketUriBuilder.addQueryParam(baseUri, 'authorization', 'Basic $basicAuth');
     }
test/features/push_notification/data/datasource/strategies/auth_strategies_test.dart (2)

96-120: Consider adding a test for empty credentials in BasicAuthStrategy.

The TokenAuthStrategy group includes tests for both null and empty tokens (lines 45-69), but BasicAuthStrategy only tests for null credentials. For consistency and completeness, consider adding a test case for empty credentials.

💡 Suggested additional test case
test('should return base URI when credentials are empty', () async {
  // Arrange
  when(mockAuthInterceptors.basicAuthCredentials).thenReturn('');
  final session = _createTestSession();

  // Act
  final result = await strategy.buildConnectionUri(baseUri, session, testAccountId);

  // Assert
  expect(result.queryParameters.containsKey('authorization'), isFalse);
  expect(result, equals(baseUri));
});

142-165: Consider adding error handling test for TicketAuthStrategy.

The current test only covers the happy path. Consider adding a test case for when getWebSocketTicket throws an exception to document the expected error behavior.

💡 Suggested additional test case
test('should propagate exception when WebSocket API fails', () async {
  // Arrange
  final session = _createTestSession();
  when(mockWebSocketApi.getWebSocketTicket(session, testAccountId))
      .thenThrow(Exception('Network error'));

  // Act & Assert
  expect(
    () => strategy.buildConnectionUri(baseUri, session, testAccountId),
    throwsException,
  );
});

Tsopic and others added 2 commits January 20, 2026 20:17
The previous implementation used string interpolation which would break
when baseUri already had query parameters (e.g., producing double '?').

Changed to use baseUri.replace(queryParameters: {...}) to safely merge
existing query parameters with the ticket parameter, consistent with
TokenAuthStrategy and BasicAuthStrategy implementations.

Added test case to verify existing query parameters are preserved.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Previously latestState was unconditionally assigned from emailResponse?.state,
which could overwrite a valid state from a previous batch with null if a
subsequent batch response had no state.

Now only updates latestState when emailResponse?.state is non-null,
preserving valid state across batches.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@lib/features/base/mixin/mail_api_mixin.dart`:
- Around line 119-127: The code doesn't guard against batchSize (or the result
of getMaxObjectsInGetMethod) being <= 0 and against negative maxEmailsToFetch,
which can cause division-by-zero, infinite loops, or RangeError in idsToFetch;
fix by clamping the effective batch size and requested max emails: compute
maxObjectsInGet = max(1, batchSize ?? getMaxObjectsInGetMethod(session,
accountId)) to ensure at least 1, and when building idsToFetch, if
maxEmailsToFetch != null clamp it to the range 0..emailIds.length before calling
emailIds.sublist(0, clampedValue); apply these changes where maxObjectsInGet and
idsToFetch are defined (referencing batchSize, getMaxObjectsInGetMethod,
maxEmailsToFetch, idsToFetch).
🧹 Nitpick comments (1)
test/features/push_notification/data/datasource/strategies/auth_strategies_test.dart (1)

87-121: Consider adding test for empty credentials and preserving existing query parameters.

For consistency with TokenAuthStrategy tests, consider adding:

  1. A test case for when basicAuthCredentials returns an empty string
  2. A test case verifying existing query parameters are preserved when credentials are added

This would improve coverage parity across strategies.

Suggested additional tests
test('should return base URI when credentials are empty', () async {
  // Arrange
  when(mockAuthInterceptors.basicAuthCredentials).thenReturn('');
  final session = _createTestSession();

  // Act
  final result = await strategy.buildConnectionUri(baseUri, session, testAccountId);

  // Assert
  expect(result.queryParameters.containsKey('authorization'), isFalse);
  expect(result, equals(baseUri));
});

test('should preserve existing query parameters', () async {
  // Arrange
  const testCredentials = 'dXNlcm5hbWU6cGFzc3dvcmQ=';
  when(mockAuthInterceptors.basicAuthCredentials).thenReturn(testCredentials);
  final uriWithParams = Uri.parse('wss://example.com/ws?existing=param');
  final session = _createTestSession();

  // Act
  final result = await strategy.buildConnectionUri(uriWithParams, session, testAccountId);

  // Assert
  expect(result.queryParameters['existing'], equals('param'));
  expect(result.queryParameters['authorization'], equals('Basic $testCredentials'));
});

- Ensure maxObjectsInGet is at least 1 using max(1, ...) to prevent
  infinite loops or division by zero when batchSize <= 0
- Clamp maxEmailsToFetch to valid range [0, emailIds.length] to prevent
  RangeError when negative values are passed to sublist()

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Comment on lines 75 to 90
int getMaxObjectsInGetMethod(Session session, AccountId accountId) {
final coreCapability = session.getCapabilityProperties<CoreCapability>(
accountId,
CapabilityIdentifier.jmapCore,
);
final maxObjectsInGetMethod =
coreCapability?.maxObjectsInGet?.value.toInt() ??
CapabilityIdentifierExtension.defaultMaxObjectsInGet;

final minOfMaxObjectsInGetMethod = min(
maxObjectsInGetMethod,
CapabilityIdentifierExtension.defaultMaxObjectsInGet,
);
log('$runtimeType::getMaxObjectsInGetMethod:minOfMaxObjectsInGetMethod = $minOfMaxObjectsInGetMethod');
return minOfMaxObjectsInGetMethod;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use getMaxObjectsInGet method in session_extension.dart file

- Replace custom getMaxObjectsInGetMethod with session.getMaxObjectsInGet
  from session_extension.dart as suggested in PR review
- Change emailResponse?.list != null to emailResponse?.list.isNotEmpty == true
  to avoid adding empty lists

Addresses review comments on PR linagora#4261

Co-Authored-By: Claude Opus 4.5 <[email protected]>
///
/// The [maxCreatedEmailsToFetch] parameter enables early termination when only
/// a limited number of new emails are needed (e.g., for UI preview).
Future<EmailChangeResponse> getChanges(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deserves a unit test.

@dab246
Copy link
Member

dab246 commented Jan 21, 2026

  • What's about mobile?

@chibenwa
Copy link
Member

Stalwart and other JMAP servers enforce maxObjectsInGet limits. When loading threads with many emails (e.g., after WebSocket push notification), the client must batch requests to avoid exceeding server limits.

So does Twake Mail backend, though we accept high limits on metadata.

Please @dab246 @hoangdat this bug would definitly affect us so please consider splitting that fix and integrating it in Twake mail as well.

@chibenwa chibenwa mentioned this pull request Jan 21, 2026
@hoangdat
Copy link
Member

hoangdat commented Jan 21, 2026

we didnot use maxObjectInGet in getChanges because:

  • we combined getChanges + getEmail when ever we have new state in websocket
  • with web socket, we also synchornize local caches
  • what happen if we only synchornize maxObjectInGet when changes has more than maxObjectInGet?
  • we depend on the response of Back-end in case Cannot calculate change to make the cache synchronize well (flush cache then synchronize on top of 20 new changes)

@chibenwa
Copy link
Member

we didnot use maxObjectInGet in getChanges because

While this is true for users relying on tmail server this is apparently not enough for users of Stallwart servers.

IMO we should be open to such a contributions.

State? latestState;

// If maxEmailsToFetch is specified, clamp it to valid range and only fetch up to that many IDs
final idsToFetch = maxEmailsToFetch != null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is NO continuation logic for ignored IDs. The maxEmailsToFetch parameter creates an irreversible data loss if used .Emails beyond the limit are discarded without tracking, and callers have no mechanism to fetch them later.

Comment on lines +45 to +47
return baseUri.replace(queryParameters: {
...baseUri.queryParameters,
'access_token': token,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if baseUri already have valid, unexpired access_token, but the _authInterceptors.currentToken is expired?

Properties? propertiesCreated,
Properties? propertiesUpdated
Properties? propertiesUpdated,
int? maxCreatedEmailsToFetch,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this maxCreatedEmailsToFetch parameter used?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants