Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
290 lines
8.9 KiB
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>;
|
|
}
|