Files
sharedinbox/lib/data/jmap/sieve_repository.dart

290 lines
8.9 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart';
import 'package:sharedinbox/data/jmap/jmap_client.dart';
typedef ManageSieveConnectFn = Future<ManageSieveClient> Function({
required String host,
required int port,
required bool useTls,
});
Future<ManageSieveClient> _defaultManageSieveConnect({
required String host,
required int port,
required bool useTls,
}) =>
ManageSieveClient.connect(host: host, port: port, useTls: useTls);
class SieveRepository {
SieveRepository(
this._accounts,
this._httpClient, {
ManageSieveConnectFn manageSieveConnect = _defaultManageSieveConnect,
}) : _manageSieveConnect = manageSieveConnect;
final AccountRepository _accounts;
final http.Client _httpClient;
final ManageSieveConnectFn _manageSieveConnect;
Future<List<SieveScript>> listScripts(String accountId) async {
final account = await _requireAccount(accountId);
if (account.type == AccountType.imap) {
return _withManageSieve(account, (c) async {
final scripts = await c.listScripts();
return scripts
.map(
(s) => SieveScript(
id: s.name,
name: s.name,
blobId: s.name,
isActive: s.isActive,
),
)
.toList();
});
}
return _withJmap(account, (jmap) async {
final responses = await jmap.call(
[
[
'SieveScript/get',
{'accountId': jmap.accountId, 'ids': null},
'0',
],
],
withSieve: true,
);
final result = _responseArgs(responses, 0, 'SieveScript/get');
final list = result['list'] as List<dynamic>;
return list.map((e) {
final m = e as Map<String, dynamic>;
return SieveScript(
id: m['id'] as String,
name: m['name'] as String,
blobId: m['blobId'] as String,
isActive: m['isActive'] as bool? ?? false,
);
}).toList();
});
}
Future<String> getScriptContent(String accountId, String blobId) async {
final account = await _requireAccount(accountId);
if (account.type == AccountType.imap) {
// For ManageSieve, blobId is the script name (see listScripts above).
return _withManageSieve(account, (c) => c.getScript(blobId));
}
return _withJmap(account, (jmap) async {
final bytes = await jmap.downloadBlob(
blobId,
name: 'script.sieve',
type: 'application/sieve',
);
return utf8.decode(bytes);
});
}
Future<SieveScript> saveScript(
String accountId, {
String? id,
required String name,
required String content,
}) async {
final account = await _requireAccount(accountId);
if (account.type == AccountType.imap) {
return _withManageSieve(account, (c) async {
await c.putScript(name, content);
// Renamed: drop the old script.
if (id != null && id != name) {
await c.deleteScript(id);
}
return SieveScript(id: name, name: name, blobId: name, isActive: false);
});
}
return _withJmap(account, (jmap) async {
final blobId = await jmap.uploadBlob(
Uint8List.fromList(utf8.encode(content)),
'application/sieve',
);
final Map<String, dynamic> setArgs = id == null
? {
'accountId': jmap.accountId,
'create': {
'new': {'name': name, 'blobId': blobId},
},
}
: {
'accountId': jmap.accountId,
'update': {
id: {'name': name, 'blobId': blobId},
},
};
final responses = await jmap.call(
[
['SieveScript/set', setArgs, '0'],
],
withSieve: true,
);
final result = _responseArgs(responses, 0, 'SieveScript/set');
if (id == null) {
final created = result['created'] as Map<String, dynamic>?;
final newScript = created?['new'] as Map<String, dynamic>?;
if (newScript == null) {
final notCreated = result['notCreated'] as Map<String, dynamic>?;
final err = notCreated?['new'] as Map<String, dynamic>?;
throw JmapException(
'Failed to create script: ${err?['type']}${err?['description']}',
);
}
return SieveScript(
id: newScript['id'] as String? ?? '',
name: name,
blobId: blobId,
isActive: false,
);
}
final notUpdated = result['notUpdated'] as Map<String, dynamic>?;
if (notUpdated != null && notUpdated.containsKey(id)) {
final err = notUpdated[id] as Map<String, dynamic>?;
throw JmapException(
'Failed to update script: ${err?['type']}${err?['description']}',
);
}
return SieveScript(id: id, name: name, blobId: blobId, isActive: false);
});
}
Future<void> deleteScript(String accountId, String scriptId) async {
final account = await _requireAccount(accountId);
if (account.type == AccountType.imap) {
await _withManageSieve(account, (c) async {
await c.deleteScript(scriptId);
});
return;
}
await _withJmap(account, (jmap) async {
final responses = await jmap.call(
[
[
'SieveScript/set',
{
'accountId': jmap.accountId,
'destroy': [scriptId],
},
'0',
],
],
withSieve: true,
);
final result = _responseArgs(responses, 0, 'SieveScript/set');
final notDestroyed = result['notDestroyed'] as Map<String, dynamic>?;
if (notDestroyed != null && notDestroyed.containsKey(scriptId)) {
final err = notDestroyed[scriptId] as Map<String, dynamic>?;
throw JmapException('Failed to delete script: ${err?['type']}');
}
});
}
Future<void> activateScript(String accountId, String scriptId) async {
final account = await _requireAccount(accountId);
if (account.type == AccountType.imap) {
await _withManageSieve(account, (c) async {
await c.setActive(scriptId);
});
return;
}
await _withJmap(account, (jmap) async {
await jmap.call(
[
[
'SieveScript/activate',
{'accountId': jmap.accountId, 'id': scriptId},
'0',
],
],
withSieve: true,
);
});
}
// ── helpers ───────────────────────────────────────────────────────────────
Future<Account> _requireAccount(String accountId) async {
final account = await _accounts.getAccount(accountId);
if (account == null) throw Exception('Account not found');
return account;
}
Future<T> _withJmap<T>(
Account account,
Future<T> Function(JmapClient) body,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('Account has no JMAP URL');
}
final password = await _accounts.getPassword(account.id);
final username =
account.username.isNotEmpty ? account.username : account.email;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: username,
password: password,
);
if (!jmap.supportsSieve) {
throw Exception(
'Server does not support Sieve (urn:ietf:params:jmap:sieve)',
);
}
return body(jmap);
}
Future<T> _withManageSieve<T>(
Account account,
Future<T> Function(ManageSieveClient) body,
) async {
final host = account.manageSieveHost.isNotEmpty
? account.manageSieveHost
: account.imapHost;
if (host.isEmpty) {
throw Exception('Account has no ManageSieve host configured');
}
final password = await _accounts.getPassword(account.id);
final username =
account.username.isNotEmpty ? account.username : account.email;
final client = await _manageSieveConnect(
host: host,
port: account.manageSievePort,
useTls: account.manageSieveSsl,
);
try {
await client.authenticatePlain(username, password);
return await body(client);
} finally {
await client.logout();
}
}
}
Map<String, dynamic> _responseArgs(
List<dynamic> responses,
int index,
String expectedMethod,
) {
final triple = responses[index] as List<dynamic>;
final method = triple[0] as String;
if (method == 'error') {
final err = triple[1] as Map<String, dynamic>;
throw JmapException('$expectedMethod error: ${err['type']}');
}
return triple[1] as Map<String, dynamic>;
}