diff --git a/DB-SYNC.md b/DB-SYNC.md index 31712d3..a984a42 100644 --- a/DB-SYNC.md +++ b/DB-SYNC.md @@ -67,7 +67,7 @@ pending_changes ( ) ``` -### Step 3 — JMAP session client `[ ]` +### Step 3 — JMAP session client `[x]` Implement `JmapSession`: parse the JMAP Session object from `GET {jmapUrl}`, extract `apiUrl`, primary `accountId`, and capabilities. Store nothing extra in the diff --git a/lib/data/jmap/jmap_client.dart b/lib/data/jmap/jmap_client.dart new file mode 100644 index 0000000..3508548 --- /dev/null +++ b/lib/data/jmap/jmap_client.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +const _using = [ + 'urn:ietf:params:jmap:core', + 'urn:ietf:params:jmap:mail', +]; + +/// A connected JMAP session. Fetch via [JmapClient.connect]. +/// +/// Parses the JMAP Session object (RFC 8620 §2), stores the resolved +/// [apiUrl] and server-side [accountId], and wraps API calls in [call]. +class JmapClient { + JmapClient._({ + required http.Client httpClient, + required String credentials, + required Uri apiUrl, + required String accountId, + }) : _httpClient = httpClient, + _credentials = credentials, + _apiUrl = apiUrl, + _accountId = accountId; + + final http.Client _httpClient; + final String _credentials; + final Uri _apiUrl; + final String _accountId; + + String get accountId => _accountId; + + /// Fetches the JMAP Session object from [jmapUrl] and returns a connected + /// client. Throws [JmapException] on HTTP errors or missing capabilities. + static Future connect({ + required http.Client httpClient, + required Uri jmapUrl, + required String username, + required String password, + }) async { + final credentials = base64.encode(utf8.encode('$username:$password')); + final resp = await httpClient.get( + jmapUrl, + headers: {'Authorization': 'Basic $credentials'}, + ).timeout(const Duration(seconds: 10)); + + if (resp.statusCode == 401 || resp.statusCode == 403) { + throw JmapException('Authentication failed (HTTP ${resp.statusCode})'); + } + if (resp.statusCode != 200) { + throw JmapException('Session fetch failed (HTTP ${resp.statusCode})'); + } + + final session = jsonDecode(resp.body) as Map; + final apiUrl = _extractApiUrl(session, jmapUrl); + final accountId = _extractAccountId(session); + + return JmapClient._( + httpClient: httpClient, + credentials: credentials, + apiUrl: apiUrl, + accountId: accountId, + ); + } + + /// Issues a JMAP API request with [methodCalls]. + /// + /// Each call is a triple `[methodName, arguments, callId]`. + /// Returns the raw `methodResponses` list from the server. + /// + /// Throws [JmapException] on HTTP errors or a top-level JMAP error response. + Future> call(List> methodCalls) async { + final body = jsonEncode({ + 'using': _using, + 'methodCalls': methodCalls, + }); + + final resp = await _httpClient + .post( + _apiUrl, + headers: { + 'Authorization': 'Basic $_credentials', + 'Content-Type': 'application/json', + }, + body: body, + ) + .timeout(const Duration(seconds: 30)); + + if (resp.statusCode != 200) { + throw JmapException('API call failed (HTTP ${resp.statusCode})'); + } + + final decoded = jsonDecode(resp.body) as Map; + + // Top-level error (e.g. unknownCapability) + if (decoded.containsKey('type')) { + throw JmapException( + 'JMAP error: ${decoded['type']} — ${decoded['description'] ?? ''}'); + } + + return decoded['methodResponses'] as List; + } + + static Uri _extractApiUrl(Map session, Uri sessionUri) { + final raw = session['apiUrl'] as String?; + if (raw == null || raw.isEmpty) { + throw JmapException('Session missing apiUrl'); + } + // apiUrl may be relative (RFC 8620 §2 allows it) + return sessionUri.resolve(raw); + } + + static String _extractAccountId(Map session) { + final primaryAccounts = + session['primaryAccounts'] as Map?; + final id = primaryAccounts?['urn:ietf:params:jmap:mail'] as String? ?? + primaryAccounts?['urn:ietf:params:jmap:core'] as String?; + if (id != null) return id; + + // Fall back to first account in the accounts map + final accounts = session['accounts'] as Map?; + if (accounts != null && accounts.isNotEmpty) { + return accounts.keys.first; + } + throw JmapException('Session has no usable accountId'); + } +} + +class JmapException implements Exception { + JmapException(this.message); + final String message; + + @override + String toString() => 'JmapException: $message'; +} diff --git a/test/unit/jmap_client_test.dart b/test/unit/jmap_client_test.dart new file mode 100644 index 0000000..ddab250 --- /dev/null +++ b/test/unit/jmap_client_test.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:sharedinbox/data/jmap/jmap_client.dart'; + +const _sessionUrl = 'https://jmap.example.com/.well-known/jmap'; +const _apiUrl = 'https://jmap.example.com/api/'; +const _accountId = 'u1'; + +Map _sessionBody({String? apiUrl, String? accountId}) => { + 'apiUrl': apiUrl ?? _apiUrl, + 'accounts': { + accountId ?? _accountId: { + 'name': 'alice@example.com', + 'isPersonal': true, + 'isReadOnly': false, + 'accountCapabilities': {}, + }, + }, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': accountId ?? _accountId, + 'urn:ietf:params:jmap:mail': accountId ?? _accountId, + }, + 'capabilities': {}, + 'username': 'alice@example.com', + 'state': 'st1', + }; + +http.Client _sessionClient({ + int sessionStatus = 200, + Map? sessionBody, + int apiStatus = 200, + dynamic apiBody, +}) { + return MockClient((req) async { + if (req.url.path.contains('well-known')) { + return http.Response( + jsonEncode(sessionBody ?? _sessionBody()), sessionStatus); + } + return http.Response( + jsonEncode(apiBody ?? + { + 'sessionState': 'st1', + 'methodResponses': [], + }), + apiStatus); + }); +} + +void main() { + group('JmapClient.connect', () { + test('parses apiUrl and accountId from session', () async { + final client = await JmapClient.connect( + httpClient: _sessionClient(), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'secret', + ); + expect(client.accountId, _accountId); + }); + + test('falls back to first account when primaryAccounts missing', () async { + final body = _sessionBody()..remove('primaryAccounts'); + final client = await JmapClient.connect( + httpClient: _sessionClient(sessionBody: body), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'secret', + ); + expect(client.accountId, _accountId); + }); + + test('throws JmapException on 401', () async { + expect( + () => JmapClient.connect( + httpClient: _sessionClient(sessionStatus: 401), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'wrong', + ), + throwsA(isA()), + ); + }); + + test('throws JmapException on non-200 non-auth error', () async { + expect( + () => JmapClient.connect( + httpClient: _sessionClient(sessionStatus: 503), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'secret', + ), + throwsA(isA()), + ); + }); + + test('throws JmapException when apiUrl is missing', () async { + final body = _sessionBody()..remove('apiUrl'); + expect( + () => JmapClient.connect( + httpClient: _sessionClient(sessionBody: body), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'secret', + ), + throwsA(isA()), + ); + }); + + test('throws JmapException when no accounts exist', () async { + final body = { + 'apiUrl': _apiUrl, + 'accounts': {}, + 'primaryAccounts': {}, + 'capabilities': {}, + 'username': 'alice@example.com', + 'state': 'st1', + }; + expect( + () => JmapClient.connect( + httpClient: _sessionClient(sessionBody: body), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'secret', + ), + throwsA(isA()), + ); + }); + }); + + group('JmapClient.call', () { + Future connected({ + int apiStatus = 200, + dynamic apiBody, + }) => + JmapClient.connect( + httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody), + jmapUrl: Uri.parse(_sessionUrl), + username: 'alice', + password: 'secret', + ); + + test('returns methodResponses on success', () async { + final responses = [ + ['Mailbox/get', {'state': 'st2', 'list': []}, '0'] + ]; + final client = await connected( + apiBody: {'sessionState': 'st1', 'methodResponses': responses}); + final result = await client.call([ + ['Mailbox/get', {'accountId': _accountId, 'ids': null}, '0'] + ]); + expect(result, hasLength(1)); + expect((result[0] as List)[0], 'Mailbox/get'); + }); + + test('throws JmapException on non-200 API response', () async { + final client = await connected(apiStatus: 500); + expect( + () => client.call([ + ['Mailbox/get', {'accountId': _accountId}, '0'] + ]), + throwsA(isA()), + ); + }); + + test('throws JmapException on top-level JMAP error', () async { + final client = await connected( + apiBody: {'type': 'unknownCapability', 'description': 'oops'}); + expect( + () => client.call([ + ['Mailbox/get', {'accountId': _accountId}, '0'] + ]), + throwsA(isA()), + ); + }); + }); +}