diff --git a/README.md b/README.md index ffa27e4..b6ef86b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,13 @@ For more information on JMAP, see also [the JMAP Crash Course](https://jmap.io/c * [fileNodes mkdir](#filenodes-mkdir) * [fileNodes rm](#filenodes-rm) * [fileNodes cp](#filenodes-cp) + * [Sieve](#sieve) + * [sieve get](#sieve-get) + * [sieve create](#sieve-create) + * [sieve delete](#sieve-delete) + * [sieve changes](#sieve-changes) + * [sieve activate](#sieve-activate) + * [sieve deactivate](#sieve-deactivate) * [License](#license) * [Acknowledgement](#acknowledgement) @@ -78,9 +85,10 @@ Dart supports Linux/ Windows/ MacOS. The above command does not depend on your o ## Features -* We support the jmap functions `/get` `/set`, and `/changes` for Contacts, AddressBooks, CalendarEvents, Calendars, Emails, Mailboxes, and FileNodes. +* We support the jmap functions `/get` `/set`, and `/changes` for Contacts, AddressBooks, CalendarEvents, Calendars, Emails, Mailboxes, FileNodes, and Sieve scripts. * For Emails we additionally support `/query` for filtering and paging, `/import` for storing raw MIME messages, and `EmailSubmission/set` for sending. * In the FileNode case we support (mostly) rsync-inspired file management operations. +* For Sieve scripts we additionally support activating and deactivating a script. ### Supported Sub-Commands @@ -96,6 +104,7 @@ The general command structure is * `identity` * `mailbox` * `session` +* `sieve` The available operations depend on the object type (and will be explained in their corresponding section), but common ones are * `get` gets all items ot the object type @@ -510,6 +519,79 @@ Usage example: jmap_cli mailbox changes --url -u -p --state ``` +## Sieve + +Manage Sieve scripts (mail filtering rules) on the JMAP server. The script content is treated as an opaque string; this CLI does not parse or validate Sieve syntax. + +### sieve get +Fetch all sieve scripts or a single script by id. + +Options: +- `--id`, `-i` : Id of the sieve script to fetch + +Usage examples: +```bash +jmap_cli sieve get --url -u -p +jmap_cli sieve get --url -u -p -i +``` + +### sieve create +Create a sieve script from a file or inline content. + +Options: +- `--name`, `-n` : Name of the sieve script (required) +- `--file`, `-f` : Path to a file containing the sieve script source +- `--content` : Sieve script source, given inline instead of a file + +Usage examples: +```bash +jmap_cli sieve create --url -u -p --name myFilter --file myFilter.sieve +jmap_cli sieve create --url -u -p --name myFilter --content 'require ["fileinto"]; keep;' +``` + +### sieve delete +Delete a sieve script by id. + +Options: +- `--id`, `-i` : Id of the sieve script to delete + +Usage example: +```bash +jmap_cli sieve delete --url -u -p -i +``` + +### sieve changes +Fetch sieve script changes since a given state. + +Options: +- `--state` : If omitted, the CLI fetches the latest state automatically + +Usage example: +```bash +jmap_cli sieve changes --url -u -p --state +``` + +Note: this is part of the JMAP Sieve specification, but we are not aware of a working server implementation of it yet (e.g. Stalwart v1.0.0 returns an `unknownMethod` error), so the CLI will fail gracefully with a message stating this. + +### sieve activate +Activate a sieve script by id. Only one script can be active per account at a time; activating one replaces whichever script was previously active. + +Options: +- `--id`, `-i` : Id of the sieve script to activate + +Usage example: +```bash +jmap_cli sieve activate --url -u -p -i +``` + +### sieve deactivate +Deactivate whichever sieve script is currently active, if any. + +Usage example: +```bash +jmap_cli sieve deactivate --url -u -p +``` + ## FileNodes Manage files and folders on the JMAP server. diff --git a/lib/jmap_cli.dart b/lib/jmap_cli.dart index fae4980..f35527f 100644 --- a/lib/jmap_cli.dart +++ b/lib/jmap_cli.dart @@ -39,4 +39,11 @@ export 'src/commands/mailbox/mailbox_command.dart'; export 'src/commands/mailbox/get_mailbox_command.dart'; export 'src/commands/mailbox/create_mailbox_command.dart'; export 'src/commands/mailbox/delete_mailbox_command.dart'; -export 'src/commands/mailbox/changes_mailbox_command.dart'; \ No newline at end of file +export 'src/commands/mailbox/changes_mailbox_command.dart'; +export 'src/commands/sieve/sieve_command.dart'; +export 'src/commands/sieve/get_sieve_command.dart'; +export 'src/commands/sieve/create_sieve_command.dart'; +export 'src/commands/sieve/delete_sieve_command.dart'; +export 'src/commands/sieve/changes_sieve_command.dart'; +export 'src/commands/sieve/activate_sieve_command.dart'; +export 'src/commands/sieve/deactivate_sieve_command.dart'; \ No newline at end of file diff --git a/lib/src/commands/sieve/activate_sieve_command.dart b/lib/src/commands/sieve/activate_sieve_command.dart new file mode 100644 index 0000000..81e4b43 --- /dev/null +++ b/lib/src/commands/sieve/activate_sieve_command.dart @@ -0,0 +1,44 @@ +import 'package:jmap_cli/src/commands/base_command.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/util/sieve_util.dart'; + +class ActivateSieveCommand extends BaseCommand { + @override + final name = 'activate'; + + @override + List get aliases => ['sieve.activate']; + + @override + final description = 'Activate a sieve script by id'; + + @override + Future run() async { + try { + final args = argResults; + if (args == null) return 1; + + final idArg = args['id']; + if (idArg == null || idArg.toString().trim().isEmpty) { + print('Error: --id is required'); + return 1; + } + + final live = await buildClientAndAccount(args, 'POST'); + final accountId = AccountId(Id(args['accountId'] ?? live.accountId.id.value)); + + await SieveUtil.activateSieveScript( + client: live.httpClient, + accountId: accountId, + id: idArg, + ); + + print('Sieve script activated!'); + return 0; + } catch (e) { + print('Error: $e'); + return 1; + } + } +} diff --git a/lib/src/commands/sieve/changes_sieve_command.dart b/lib/src/commands/sieve/changes_sieve_command.dart new file mode 100644 index 0000000..c3e5a14 --- /dev/null +++ b/lib/src/commands/sieve/changes_sieve_command.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'package:jmap_cli/src/commands/base_command.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/error/method/error_method_response.dart'; +import 'package:jmap_dart_client/jmap/core/error/method/exception/error_method_response_exception.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/util/sieve_util.dart'; + +class ChangesSieveCommand extends BaseCommand { + @override + final name = 'changes'; + + @override + List get aliases => ['sieve.changes']; + + @override + final description = 'Get change information for sieve scripts as JSON'; + + ChangesSieveCommand() { + argParser.addOption( + 'state', + help: 'The previous state to check changes from. If omitted, fetches current state first.', + ); + } + + @override + Future run() async { + try { + final args = argResults; + if (args == null) return 1; + + final live = await buildClientAndAccount(args, 'GET'); + final accountId = AccountId(Id(args['accountId'] ?? live.accountId.id.value)); + + State fromState; + final stateArg = args['state']; + + if (stateArg != null && stateArg.trim().isNotEmpty) { + fromState = State(stateArg.trim()); + } else { + final getResp = await SieveUtil.getSieveScripts( + client: live.httpClient, + accountId: accountId, + ); + fromState = getResp.state; + } + + final changes = await SieveUtil.changesSieveScripts( + client: live.httpClient, + accountId: accountId, + sinceState: fromState, + ); + + print(jsonEncode({ + 'accountId': changes.accountId.id.value, + 'oldState': changes.oldState.value, + 'newState': changes.newState.value, + 'hasMoreChanges': changes.hasMoreChanges, + 'created': changes.created.map((id) => id.value).toList(), + 'updated': changes.updated.map((id) => id.value).toList(), + 'destroyed': changes.destroyed.map((id) => id.value).toList(), + })); + return 0; + } on ErrorMethodResponseException catch (e) { + final errorResponse = e.errorResponse; + if (errorResponse is ErrorMethodResponse && + errorResponse.type == ErrorMethodResponse.unknownMethod) { + print('Error: this server does not support SieveScript/changes.'); + return 1; + } + print('Error: $e'); + return 1; + } catch (e) { + print('Error: $e'); + return 1; + } + } +} diff --git a/lib/src/commands/sieve/create_sieve_command.dart b/lib/src/commands/sieve/create_sieve_command.dart new file mode 100644 index 0000000..89030e8 --- /dev/null +++ b/lib/src/commands/sieve/create_sieve_command.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'package:jmap_cli/src/commands/base_command.dart'; +import 'package:jmap_cli/src/commands/session/get_session_command.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/util/sieve_util.dart'; + +class CreateSieveCommand extends BaseCommand { + @override + final name = 'create'; + + @override + List get aliases => ['sieve.create']; + + @override + final description = 'Create a sieve script from a file or inline content'; + + CreateSieveCommand() { + argParser + ..addOption('name', abbr: 'n', help: 'Name of the sieve script.', mandatory: true) + ..addOption('file', abbr: 'f', help: 'Path to a file containing the sieve script source.') + ..addOption('content', help: 'Sieve script source, given inline instead of a file.'); + } + + @override + Future run() async { + try { + final args = argResults; + if (args == null) return 1; + + final name = args['name'] as String; + final filePath = args['file'] as String?; + final inlineContent = args['content'] as String?; + + String content; + if (filePath != null) { + final file = File(filePath); + if (!file.existsSync()) { + print('File not found: $filePath'); + return 1; + } + content = await file.readAsString(); + } else if (inlineContent != null) { + content = inlineContent; + } else { + print('Error: either --file or --content is required'); + return 1; + } + + final live = await GetSessionCommand().getLiveClient(args, 'POST'); + final accountId = AccountId(Id(args['accountId'] ?? live.accountId.id.value)); + + final uploadTemplate = Uri.decodeFull(live.session.uploadUrl.toString()) + .replaceFirst('{accountId}', accountId.id.value); + // Cyrus returns a server-relative path; make it absolute to avoid Dio + // appending it to the JMAP API path and producing a duplicate segment. + final uploadUrl = uploadTemplate.startsWith('http://') || uploadTemplate.startsWith('https://') + ? uploadTemplate + : Uri.parse(live.dio.options.baseUrl) + .replace(path: uploadTemplate) + .toString(); + + final id = await SieveUtil.createSieveScript( + client: live.httpClient, + accountId: accountId, + name: name, + content: content, + httpUploadUrl: uploadUrl, + ); + + if (id != null) { + print(id); + return 0; + } + + print('Sieve script could not be created!'); + return 1; + } catch (e) { + print('Error: $e'); + return 1; + } + } +} diff --git a/lib/src/commands/sieve/deactivate_sieve_command.dart b/lib/src/commands/sieve/deactivate_sieve_command.dart new file mode 100644 index 0000000..a75c911 --- /dev/null +++ b/lib/src/commands/sieve/deactivate_sieve_command.dart @@ -0,0 +1,37 @@ +import 'package:jmap_cli/src/commands/base_command.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/util/sieve_util.dart'; + +class DeactivateSieveCommand extends BaseCommand { + @override + final name = 'deactivate'; + + @override + List get aliases => ['sieve.deactivate']; + + @override + final description = 'Deactivate the currently active sieve script'; + + @override + Future run() async { + try { + final args = argResults; + if (args == null) return 1; + + final live = await buildClientAndAccount(args, 'POST'); + final accountId = AccountId(Id(args['accountId'] ?? live.accountId.id.value)); + + await SieveUtil.deactivateActiveSieveScript( + client: live.httpClient, + accountId: accountId, + ); + + print('Sieve script deactivated!'); + return 0; + } catch (e) { + print('Error: $e'); + return 1; + } + } +} diff --git a/lib/src/commands/sieve/delete_sieve_command.dart b/lib/src/commands/sieve/delete_sieve_command.dart new file mode 100644 index 0000000..7ed72f4 --- /dev/null +++ b/lib/src/commands/sieve/delete_sieve_command.dart @@ -0,0 +1,49 @@ +import 'package:jmap_cli/src/commands/base_command.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/util/sieve_util.dart'; + +class DeleteSieveCommand extends BaseCommand { + @override + final name = 'delete'; + + @override + List get aliases => ['sieve.delete']; + + @override + final description = 'Delete a sieve script by id'; + + @override + Future run() async { + try { + final args = argResults; + if (args == null) return 1; + + final idArg = args['id']; + if (idArg == null || idArg.toString().trim().isEmpty) { + print('Error: --id is required'); + return 1; + } + + final live = await buildClientAndAccount(args, 'POST'); + final accountId = AccountId(Id(args['accountId'] ?? live.accountId.id.value)); + + final resp = await SieveUtil.deleteSieveScript( + client: live.httpClient, + accountId: accountId, + id: idArg, + ); + + if (resp.destroyed != null && resp.destroyed!.isNotEmpty) { + print('Sieve script successfully deleted!'); + return 0; + } + + print('Sieve script could not be deleted!'); + return 1; + } catch (e) { + print('Error: $e'); + return 1; + } + } +} diff --git a/lib/src/commands/sieve/get_sieve_command.dart b/lib/src/commands/sieve/get_sieve_command.dart new file mode 100644 index 0000000..629ee15 --- /dev/null +++ b/lib/src/commands/sieve/get_sieve_command.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; +import 'package:jmap_cli/src/commands/base_command.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/util/sieve_util.dart'; + +class GetSieveCommand extends BaseCommand { + @override + final name = 'get'; + + @override + List get aliases => ['sieve.get']; + + @override + final description = 'Get sieve scripts or a single script by id'; + + @override + Future run() async { + try { + final args = argResults; + if (args == null) return 1; + + final live = await buildClientAndAccount(args, 'GET'); + final accountId = AccountId(Id(args['accountId'] ?? live.accountId.id.value)); + final id = args['id']; + + if (id != null && id is String && id.isNotEmpty) { + final script = await SieveUtil.getSieveScriptById( + client: live.httpClient, + accountId: accountId, + id: id, + ); + + if (script == null) { + print('Sieve script not found'); + return 1; + } + + print(jsonEncode(script)); + return 0; + } + + final resp = await SieveUtil.getSieveScripts( + client: live.httpClient, + accountId: accountId, + ); + + print(jsonEncode(resp)); + return 0; + } catch (e) { + print('Error: $e'); + return 1; + } + } +} diff --git a/lib/src/commands/sieve/sieve_command.dart b/lib/src/commands/sieve/sieve_command.dart new file mode 100644 index 0000000..2aad051 --- /dev/null +++ b/lib/src/commands/sieve/sieve_command.dart @@ -0,0 +1,33 @@ +import 'package:jmap_cli/jmap_cli.dart'; +import 'package:jmap_cli/src/commands/base_command.dart'; + +class SieveCommand extends BaseCommand { + @override + final name = 'sieve'; + + @override + final description = 'Manage JMAP Sieve scripts'; + + SieveCommand() { + addSubcommand(GetSieveCommand()); + addSubcommand(CreateSieveCommand()); + addSubcommand(DeleteSieveCommand()); + addSubcommand(ChangesSieveCommand()); + addSubcommand(ActivateSieveCommand()); + addSubcommand(DeactivateSieveCommand()); + } + + @override + Future run() async { + print('Usage: jmap-cli sieve [options]'); + print(''); + print('Available commands:'); + print(' get Get sieve scripts or a single script by id'); + print(' create Create a sieve script'); + print(' delete Delete a sieve script'); + print(' changes Show sieve script changes since a given state'); + print(' activate Activate a sieve script'); + print(' deactivate Deactivate the currently active sieve script'); + return 1; + } +} diff --git a/lib/src/jmap_command_runner.dart b/lib/src/jmap_command_runner.dart index a1c1363..d65a6cb 100644 --- a/lib/src/jmap_command_runner.dart +++ b/lib/src/jmap_command_runner.dart @@ -28,6 +28,7 @@ class JmapCommandRunner extends CommandRunner { addCommand(CalendarCommand()); addCommand(EmailCommand()); addCommand(MailboxCommand()); + addCommand(SieveCommand()); } /// Parses and executes commands from the provided [args]. diff --git a/test/data/credentials/auth_ietf.json b/test/data/credentials/auth_ietf.json index 3c6cd12..9f5585a 100644 --- a/test/data/credentials/auth_ietf.json +++ b/test/data/credentials/auth_ietf.json @@ -2,4 +2,4 @@ "url": "", "username": "", "password": "" -} \ No newline at end of file +} diff --git a/test/sieve/sieve_cli_test.dart b/test/sieve/sieve_cli_test.dart new file mode 100644 index 0000000..bee3999 --- /dev/null +++ b/test/sieve/sieve_cli_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CLI tests for sieve commands', () { + late Map auth; + late List baseArgs; + + setUpAll(() async { + final authFile = File('test/data/credentials/auth_ietf.json'); + auth = jsonDecode(await authFile.readAsString()); + baseArgs = [ + '--url', auth['url'].toString(), + '-u', auth['username'].toString(), + '-p', auth['password'].toString(), + ]; + }); + + test('sieve get returns valid JSON with list', () async { + final process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'get', ...baseArgs, + ]); + + final output = await process.stdout.transform(utf8.decoder).join(); + final exitCode = await process.exitCode; + + print(output); + expect(exitCode, equals(0)); + expect(isJSON(output), true); + + final json = jsonDecode(output); + expect(json['accountId'], isNotNull); + expect(json['list'], isList); + }, timeout: Timeout(Duration(seconds: 300))); + + test('sieve create then get by id then delete', () async { + var process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'create', ...baseArgs, + '--name', 'CLITestScript', + '--content', 'require ["fileinto"];\nkeep;\n', + ]); + final createOutput = await process.stdout.transform(utf8.decoder).join(); + final createExit = await process.exitCode; + + print('Created sieve script id: $createOutput'); + expect(createExit, equals(0)); + expect(createOutput.trim(), isNotEmpty); + + final scriptId = createOutput.trim(); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'get', ...baseArgs, + '--id', scriptId, + ]); + final getOutput = await process.stdout.transform(utf8.decoder).join(); + final getExit = await process.exitCode; + + print(getOutput); + expect(getExit, equals(0)); + expect(isJSON(getOutput), true); + final getJson = jsonDecode(getOutput); + expect(getJson['id'], equals(scriptId)); + expect(getJson['name'], equals('CLITestScript')); + expect(getJson['isActive'], equals(false)); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'delete', ...baseArgs, + '--id', scriptId, + ]); + final deleteOutput = await process.stdout.transform(utf8.decoder).join(); + final deleteExit = await process.exitCode; + + print(deleteOutput); + expect(deleteExit, equals(0)); + expect(deleteOutput.trim(), contains('deleted')); + }, timeout: Timeout(Duration(seconds: 300))); + + test('sieve activate then deactivate a script', () async { + var process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'create', ...baseArgs, + '--name', 'CLIActivateTestScript', + '--content', 'require ["fileinto"];\nkeep;\n', + ]); + final createOutput = await process.stdout.transform(utf8.decoder).join(); + expect(await process.exitCode, equals(0)); + final scriptId = createOutput.trim(); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'activate', ...baseArgs, + '--id', scriptId, + ]); + final activateOutput = await process.stdout.transform(utf8.decoder).join(); + expect(await process.exitCode, equals(0)); + expect(activateOutput.trim(), contains('activated')); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'get', ...baseArgs, + '--id', scriptId, + ]); + final afterActivateOutput = await process.stdout.transform(utf8.decoder).join(); + expect(await process.exitCode, equals(0)); + expect(jsonDecode(afterActivateOutput)['isActive'], equals(true)); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'deactivate', ...baseArgs, + ]); + final deactivateOutput = await process.stdout.transform(utf8.decoder).join(); + expect(await process.exitCode, equals(0)); + expect(deactivateOutput.trim(), contains('deactivated')); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'get', ...baseArgs, + '--id', scriptId, + ]); + final afterDeactivateOutput = await process.stdout.transform(utf8.decoder).join(); + expect(await process.exitCode, equals(0)); + expect(jsonDecode(afterDeactivateOutput)['isActive'], equals(false)); + + process = await Process.start('dart', [ + 'bin/jmap_cli.dart', 'sieve', 'delete', ...baseArgs, + '--id', scriptId, + ]); + expect(await process.exitCode, equals(0)); + }, timeout: Timeout(Duration(seconds: 300))); + }); +} + +bool isJSON(String str) { + try { + jsonDecode(str); + return true; + } catch (_) { + return false; + } +}