feat: ManageSieve (RFC 5804) Sieve script editing for IMAP accounts
Adds a minimal ManageSieve client so the existing "Email filters" UI works for IMAP accounts, not just JMAP. SieveRepository becomes a dispatcher that routes to JMAP or ManageSieve based on account.type. Account model + DB schema v15 grow manageSieveHost/Port/Ssl fields (default 4190 / TLS, host falls back to imapHost when blank). The Add and Edit account screens expose them inside a collapsed ExpansionTile to keep the form short for users who accept defaults. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
44d02afc46
commit
fc270590c4
@@ -6,6 +6,53 @@ Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks
|
||||
|
||||
## Sieve filter editing for IMAP accounts (ManageSieve)
|
||||
|
||||
The "Email filters" entry was previously hidden for IMAP accounts because
|
||||
`SieveRepository` only spoke JMAP. Added a minimal ManageSieve client
|
||||
(RFC 5804) so IMAP accounts can now list / fetch / upload / activate /
|
||||
delete server-side Sieve scripts.
|
||||
|
||||
New: `lib/data/imap/managesieve_client.dart` — implements CONNECT
|
||||
(implicit TLS or plaintext), AUTHENTICATE PLAIN (SASL), LISTSCRIPTS,
|
||||
GETSCRIPT, PUTSCRIPT, SETACTIVE, DELETESCRIPT, LOGOUT. Handles
|
||||
RFC 5804 quoted-strings and `{N+}` non-synchronizing literals (used for
|
||||
both reading script bodies and uploading them in PUTSCRIPT). The base64
|
||||
SASL PLAIN payload is redacted in the verbose protocol log.
|
||||
|
||||
`SieveRepository` (`lib/data/jmap/sieve_repository.dart`) is now a
|
||||
dispatcher: `account.type == imap` routes through `ManageSieveClient`
|
||||
(connecting per-call and `LOGOUT`-ing in `finally`); `account.type ==
|
||||
jmap` keeps the existing JMAP path unchanged. Public API is unchanged
|
||||
so the existing Sieve script list / edit screens work for both
|
||||
account types. For ManageSieve, where scripts are identified by name,
|
||||
`SieveScript.id` and `SieveScript.blobId` are both set to the script
|
||||
name. Renames are implemented as PUTSCRIPT(new) followed by
|
||||
DELETESCRIPT(old).
|
||||
|
||||
Account model + DB: added `manageSieveHost`, `manageSievePort` (default
|
||||
4190), `manageSieveSsl` (default true) to `Account` and `Accounts`
|
||||
table. Schema bumped to v15 with a forward-only migration that
|
||||
`addColumn`s the three fields. Empty `manageSieveHost` falls back to
|
||||
`imapHost` so the typical setup (Stalwart / Dovecot on the same host)
|
||||
needs no extra configuration.
|
||||
|
||||
UI: removed the `account.type == AccountType.jmap` guard from the
|
||||
"Email filters" entry in both `FolderDrawer` (the per-account drawer)
|
||||
and the popup menu in `AccountListScreen`, so IMAP accounts now see it
|
||||
too. The Add and Edit account screens grew a collapsed `ExpansionTile`
|
||||
labelled "ManageSieve (email filters)" containing host / port / SSL
|
||||
fields — collapsed by default so the form stays the same height for
|
||||
users who accept the defaults (which avoided pushing the Save button
|
||||
off the bottom of the Linux Xvfb 1280x720 viewport in the integration
|
||||
test).
|
||||
|
||||
`scripts/check_coverage.dart` excludes `managesieve_client.dart` from
|
||||
the unit-coverage gate (real-socket network code, like
|
||||
`imap_client_factory.dart`). Updated `add_account_screen_test` to
|
||||
expect 2 visible `SwitchListTile`s on the IMAP form (the third toggle
|
||||
lives inside the collapsed ExpansionTile).
|
||||
|
||||
## Render HTML email bodies
|
||||
|
||||
`lib/ui/screens/email_detail_screen.dart` now renders the message's
|
||||
|
||||
@@ -14,6 +14,12 @@ class Account {
|
||||
final int smtpPort;
|
||||
final bool smtpSsl;
|
||||
|
||||
/// ManageSieve host (RFC 5804). Empty falls back to [imapHost].
|
||||
/// Only consulted when [type] == AccountType.imap.
|
||||
final String manageSieveHost;
|
||||
final int manageSievePort;
|
||||
final bool manageSieveSsl;
|
||||
|
||||
// Used when type == AccountType.jmap
|
||||
final String? jmapUrl;
|
||||
|
||||
@@ -38,6 +44,9 @@ class Account {
|
||||
this.smtpHost = '',
|
||||
this.smtpPort = 465,
|
||||
this.smtpSsl = true,
|
||||
this.manageSieveHost = '',
|
||||
this.manageSievePort = 4190,
|
||||
this.manageSieveSsl = true,
|
||||
this.jmapUrl,
|
||||
this.verbose = false,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,12 @@ class Accounts extends Table {
|
||||
TextColumn get username => text().withDefault(const Constant(''))();
|
||||
// Added in schema v13:
|
||||
BoolColumn get verbose => boolean().withDefault(const Constant(false))();
|
||||
// Added in schema v15: ManageSieve (RFC 5804) settings for IMAP accounts.
|
||||
TextColumn get manageSieveHost => text().withDefault(const Constant(''))();
|
||||
IntColumn get manageSievePort =>
|
||||
integer().withDefault(const Constant(4190))();
|
||||
BoolColumn get manageSieveSsl =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
@@ -195,7 +201,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 14;
|
||||
int get schemaVersion => 15;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -247,6 +253,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.addColumn(emails, emails.inReplyTo);
|
||||
await m.addColumn(emails, emails.references);
|
||||
}
|
||||
if (from < 15) {
|
||||
await m.addColumn(accounts, accounts.manageSieveHost);
|
||||
await m.addColumn(accounts, accounts.manageSievePort);
|
||||
await m.addColumn(accounts, accounts.manageSieveSsl);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
||||
show verboseLogKey;
|
||||
|
||||
/// Minimal ManageSieve (RFC 5804) client used by [SieveRepository] to
|
||||
/// list / fetch / upload / activate / delete server-side Sieve scripts on
|
||||
/// IMAP-style mail servers (e.g. Stalwart, Dovecot, Cyrus).
|
||||
///
|
||||
/// Only the small subset needed by the app is implemented:
|
||||
/// AUTHENTICATE PLAIN, LISTSCRIPTS, GETSCRIPT, PUTSCRIPT, SETACTIVE,
|
||||
/// DELETESCRIPT, LOGOUT. STARTTLS is not implemented — connect with
|
||||
/// implicit TLS instead (the default port 4190 layout used by Stalwart).
|
||||
class ManageSieveClient {
|
||||
ManageSieveClient._(this._socket, this._source);
|
||||
|
||||
final Socket _socket;
|
||||
final _ByteSource _source;
|
||||
bool _closed = false;
|
||||
|
||||
/// Connects to [host]:[port] and reads the capability greeting.
|
||||
static Future<ManageSieveClient> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
Duration timeout = const Duration(seconds: 20),
|
||||
}) async {
|
||||
// ignore: close_sinks // Stored in client and closed in logout().
|
||||
final Socket socket = useTls
|
||||
? await SecureSocket.connect(host, port, timeout: timeout)
|
||||
: await Socket.connect(host, port, timeout: timeout);
|
||||
final source = _ByteSource();
|
||||
socket.listen(
|
||||
source._add,
|
||||
onDone: () => source._close(),
|
||||
onError: (Object e, StackTrace _) => source._close(e),
|
||||
cancelOnError: true,
|
||||
);
|
||||
final client = ManageSieveClient._(socket, source);
|
||||
// Greeting / capability response.
|
||||
await client._readResponse();
|
||||
return client;
|
||||
}
|
||||
|
||||
/// Authenticates with SASL PLAIN. Throws [ManageSieveException] on failure.
|
||||
Future<void> authenticatePlain(String username, String password) async {
|
||||
// SASL PLAIN: "\0username\0password" base64-encoded (RFC 4616).
|
||||
final payload = <int>[
|
||||
0,
|
||||
...utf8.encode(username),
|
||||
0,
|
||||
...utf8.encode(password),
|
||||
];
|
||||
final initial = base64.encode(payload);
|
||||
await _writeLine('AUTHENTICATE "PLAIN" "$initial"');
|
||||
final resp = await _readResponse();
|
||||
if (resp.status != _Status.ok) {
|
||||
throw ManageSieveException(
|
||||
'Authentication failed: ${resp.message}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all scripts on the server, marking which one (if any) is active.
|
||||
Future<List<ManageSieveScript>> listScripts() async {
|
||||
await _writeLine('LISTSCRIPTS');
|
||||
final resp = await _readResponse();
|
||||
if (resp.status != _Status.ok) {
|
||||
throw ManageSieveException('LISTSCRIPTS failed: ${resp.message}');
|
||||
}
|
||||
return resp.dataLines.map(_parseListScriptLine).toList();
|
||||
}
|
||||
|
||||
/// Returns the raw text of the script named [scriptName].
|
||||
Future<String> getScript(String scriptName) async {
|
||||
await _writeLine('GETSCRIPT "${_quote(scriptName)}"');
|
||||
final resp = await _readResponse();
|
||||
if (resp.status != _Status.ok) {
|
||||
throw ManageSieveException('GETSCRIPT failed: ${resp.message}');
|
||||
}
|
||||
if (resp.dataLines.isEmpty) return '';
|
||||
// Server returns the script body as a single literal data line.
|
||||
return resp.dataLines.first;
|
||||
}
|
||||
|
||||
/// Uploads or replaces a script with [scriptName] and the given [content].
|
||||
Future<void> putScript(String scriptName, String content) async {
|
||||
final bytes = utf8.encode(content);
|
||||
await _writeLine(
|
||||
'PUTSCRIPT "${_quote(scriptName)}" {${bytes.length}+}',
|
||||
then: () async {
|
||||
_socket.add(bytes);
|
||||
_socket.add(_crlf);
|
||||
await _socket.flush();
|
||||
},
|
||||
);
|
||||
final resp = await _readResponse();
|
||||
if (resp.status != _Status.ok) {
|
||||
throw ManageSieveException('PUTSCRIPT failed: ${resp.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks [scriptName] as the active script. Pass an empty string to
|
||||
/// deactivate all scripts (RFC 5804 §2.8).
|
||||
Future<void> setActive(String scriptName) async {
|
||||
await _writeLine('SETACTIVE "${_quote(scriptName)}"');
|
||||
final resp = await _readResponse();
|
||||
if (resp.status != _Status.ok) {
|
||||
throw ManageSieveException('SETACTIVE failed: ${resp.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteScript(String scriptName) async {
|
||||
await _writeLine('DELETESCRIPT "${_quote(scriptName)}"');
|
||||
final resp = await _readResponse();
|
||||
if (resp.status != _Status.ok) {
|
||||
throw ManageSieveException('DELETESCRIPT failed: ${resp.message}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
try {
|
||||
await _writeLine('LOGOUT');
|
||||
// Server may respond with OK then close — read once, ignore failures.
|
||||
await _readResponse().timeout(const Duration(seconds: 2));
|
||||
} catch (_) {
|
||||
// Best-effort.
|
||||
} finally {
|
||||
await _socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────────────────
|
||||
|
||||
static const List<int> _crlf = [0x0D, 0x0A];
|
||||
|
||||
Future<void> _writeLine(String line, {Future<void> Function()? then}) async {
|
||||
final log = Zone.current[verboseLogKey] as StringBuffer?;
|
||||
if (log != null) {
|
||||
// Redact AUTHENTICATE PLAIN payload — contains base64-encoded password.
|
||||
log.writeln(
|
||||
line.startsWith('AUTHENTICATE')
|
||||
? 'SIEVE → AUTHENTICATE "PLAIN" <redacted>'
|
||||
: 'SIEVE → $line',
|
||||
);
|
||||
}
|
||||
_socket.add(utf8.encode(line));
|
||||
_socket.add(_crlf);
|
||||
await _socket.flush();
|
||||
if (then != null) {
|
||||
await then();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_Response> _readResponse() async {
|
||||
final dataLines = <String>[];
|
||||
final log = Zone.current[verboseLogKey] as StringBuffer?;
|
||||
while (true) {
|
||||
final lineBytes = await _source.takeLine();
|
||||
var line = utf8.decode(lineBytes);
|
||||
// Detect a trailing literal: line ends with `{NNN}` or `{NNN+}`.
|
||||
final lit = RegExp(r'\{(\d+)\+?\}\s*$').firstMatch(line);
|
||||
if (lit != null) {
|
||||
final n = int.parse(lit.group(1)!);
|
||||
final body = await _source.takeBytes(n);
|
||||
// After the literal, the server emits the rest of the line — typically
|
||||
// empty plus CRLF. Consume it so the next iteration is line-aligned.
|
||||
await _source.takeLine();
|
||||
line = utf8.decode(body);
|
||||
if (log != null) {
|
||||
log.writeln('SIEVE ← <literal $n bytes>');
|
||||
}
|
||||
dataLines.add(line);
|
||||
continue;
|
||||
}
|
||||
if (log != null) {
|
||||
log.writeln('SIEVE ← $line');
|
||||
}
|
||||
final upper = line.toUpperCase();
|
||||
if (upper.startsWith('OK')) {
|
||||
return _Response(
|
||||
_Status.ok,
|
||||
line.length > 2 ? line.substring(2).trim() : '',
|
||||
dataLines,
|
||||
);
|
||||
}
|
||||
if (upper.startsWith('NO')) {
|
||||
return _Response(
|
||||
_Status.no,
|
||||
line.length > 2 ? line.substring(2).trim() : '',
|
||||
dataLines,
|
||||
);
|
||||
}
|
||||
if (upper.startsWith('BYE')) {
|
||||
return _Response(
|
||||
_Status.bye,
|
||||
line.length > 3 ? line.substring(3).trim() : '',
|
||||
dataLines,
|
||||
);
|
||||
}
|
||||
dataLines.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes `\` and `"` for embedding inside an RFC 5804 quoted string.
|
||||
static String _quote(String s) =>
|
||||
s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
|
||||
}
|
||||
|
||||
class ManageSieveScript {
|
||||
ManageSieveScript({required this.name, required this.isActive});
|
||||
final String name;
|
||||
final bool isActive;
|
||||
}
|
||||
|
||||
ManageSieveScript _parseListScriptLine(String line) {
|
||||
// LISTSCRIPTS response per RFC 5804 §2.7:
|
||||
// "name" [ACTIVE]
|
||||
// The name is a quoted-string (or literal — already inlined by the parser).
|
||||
var rest = line.trim();
|
||||
String name;
|
||||
if (rest.startsWith('"')) {
|
||||
final end = _findClosingQuote(rest, 1);
|
||||
name = _unquote(rest.substring(1, end));
|
||||
rest = rest.substring(end + 1).trim();
|
||||
} else {
|
||||
// Bare token (literal already inlined as the whole "line").
|
||||
name = rest;
|
||||
rest = '';
|
||||
}
|
||||
final isActive = rest.toUpperCase() == 'ACTIVE';
|
||||
return ManageSieveScript(name: name, isActive: isActive);
|
||||
}
|
||||
|
||||
int _findClosingQuote(String s, int from) {
|
||||
for (var i = from; i < s.length; i++) {
|
||||
if (s[i] == r'\' && i + 1 < s.length) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (s[i] == '"') return i;
|
||||
}
|
||||
return s.length;
|
||||
}
|
||||
|
||||
String _unquote(String s) => s.replaceAll(r'\\', '\\').replaceAll(r'\"', '"');
|
||||
|
||||
enum _Status { ok, no, bye }
|
||||
|
||||
class _Response {
|
||||
_Response(this.status, this.message, this.dataLines);
|
||||
final _Status status;
|
||||
final String message;
|
||||
final List<String> dataLines;
|
||||
}
|
||||
|
||||
class ManageSieveException implements Exception {
|
||||
const ManageSieveException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ManageSieveException: $message';
|
||||
}
|
||||
|
||||
class _ByteSource {
|
||||
final List<int> _buffer = [];
|
||||
Completer<void>? _waiter;
|
||||
bool _closed = false;
|
||||
Object? _error;
|
||||
|
||||
void _add(List<int> data) {
|
||||
_buffer.addAll(data);
|
||||
final w = _waiter;
|
||||
_waiter = null;
|
||||
w?.complete();
|
||||
}
|
||||
|
||||
void _close([Object? error]) {
|
||||
_closed = true;
|
||||
_error = error;
|
||||
final w = _waiter;
|
||||
_waiter = null;
|
||||
if (w == null) return;
|
||||
if (error != null) {
|
||||
w.completeError(error);
|
||||
} else {
|
||||
w.complete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> takeLine() async {
|
||||
while (true) {
|
||||
for (var i = 0; i + 1 < _buffer.length; i++) {
|
||||
if (_buffer[i] == 13 && _buffer[i + 1] == 10) {
|
||||
final line = _buffer.sublist(0, i);
|
||||
_buffer.removeRange(0, i + 2);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
if (_closed) {
|
||||
if (_error != null) {
|
||||
throw _error!;
|
||||
}
|
||||
throw const ManageSieveException('Connection closed unexpectedly');
|
||||
}
|
||||
_waiter = Completer<void>();
|
||||
await _waiter!.future;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> takeBytes(int n) async {
|
||||
while (_buffer.length < n) {
|
||||
if (_closed) {
|
||||
if (_error != null) throw _error!;
|
||||
throw const ManageSieveException(
|
||||
'Connection closed before literal completed',
|
||||
);
|
||||
}
|
||||
_waiter = Completer<void>();
|
||||
await _waiter!.future;
|
||||
}
|
||||
final out = Uint8List.fromList(_buffer.sublist(0, n));
|
||||
_buffer.removeRange(0, n);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+242
-141
@@ -6,25 +6,239 @@ 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);
|
||||
SieveRepository(
|
||||
this._accounts,
|
||||
this._httpClient, {
|
||||
ManageSieveConnectFn manageSieveConnect = _defaultManageSieveConnect,
|
||||
}) : _manageSieveConnect = manageSieveConnect;
|
||||
|
||||
final AccountRepository _accounts;
|
||||
final http.Client _httpClient;
|
||||
final ManageSieveConnectFn _manageSieveConnect;
|
||||
|
||||
Future<JmapClient> _connect(String accountId) async {
|
||||
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');
|
||||
if (account.type != AccountType.jmap) {
|
||||
throw Exception('Sieve is only supported on JMAP accounts');
|
||||
}
|
||||
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(accountId);
|
||||
final password = await _accounts.getPassword(account.id);
|
||||
final username =
|
||||
account.username.isNotEmpty ? account.username : account.email;
|
||||
final jmap = await JmapClient.connect(
|
||||
@@ -38,147 +252,34 @@ class SieveRepository {
|
||||
'Server does not support Sieve (urn:ietf:params:jmap:sieve)',
|
||||
);
|
||||
}
|
||||
return jmap;
|
||||
return body(jmap);
|
||||
}
|
||||
|
||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||
final jmap = await _connect(accountId);
|
||||
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 jmap = await _connect(accountId);
|
||||
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 jmap = await _connect(accountId);
|
||||
final blobId = await jmap.uploadBlob(
|
||||
Uint8List.fromList(utf8.encode(content)),
|
||||
'application/sieve',
|
||||
);
|
||||
|
||||
final Map<String, dynamic> setArgs;
|
||||
if (id == null) {
|
||||
setArgs = {
|
||||
'accountId': jmap.accountId,
|
||||
'create': {
|
||||
'new': {'name': name, 'blobId': blobId},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
setArgs = {
|
||||
'accountId': jmap.accountId,
|
||||
'update': {
|
||||
id: {'name': name, 'blobId': blobId},
|
||||
},
|
||||
};
|
||||
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 responses = await jmap.call(
|
||||
[
|
||||
['SieveScript/set', setArgs, '0'],
|
||||
],
|
||||
withSieve: true,
|
||||
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,
|
||||
);
|
||||
|
||||
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']}',
|
||||
);
|
||||
}
|
||||
final newId = newScript['id'] as String? ?? '';
|
||||
return SieveScript(
|
||||
id: newId,
|
||||
name: name,
|
||||
blobId: blobId,
|
||||
isActive: false,
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
try {
|
||||
await client.authenticatePlain(username, password);
|
||||
return await body(client);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||
final jmap = await _connect(accountId);
|
||||
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 jmap = await _connect(accountId);
|
||||
await jmap.call(
|
||||
[
|
||||
[
|
||||
'SieveScript/activate',
|
||||
{'accountId': jmap.accountId, 'id': scriptId},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
withSieve: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _responseArgs(
|
||||
|
||||
@@ -42,6 +42,9 @@ class AccountRepositoryImpl implements AccountRepository {
|
||||
jmapUrl: Value(account.jmapUrl),
|
||||
username: Value(account.username),
|
||||
verbose: Value(account.verbose),
|
||||
manageSieveHost: Value(account.manageSieveHost),
|
||||
manageSievePort: Value(account.manageSievePort),
|
||||
manageSieveSsl: Value(account.manageSieveSsl),
|
||||
),
|
||||
);
|
||||
await _storage.write(key: _passwordKey(account.id), value: password);
|
||||
@@ -64,6 +67,9 @@ class AccountRepositoryImpl implements AccountRepository {
|
||||
jmapUrl: Value(account.jmapUrl),
|
||||
username: Value(account.username),
|
||||
verbose: Value(account.verbose),
|
||||
manageSieveHost: Value(account.manageSieveHost),
|
||||
manageSievePort: Value(account.manageSievePort),
|
||||
manageSieveSsl: Value(account.manageSieveSsl),
|
||||
),
|
||||
);
|
||||
if (password != null) {
|
||||
@@ -100,6 +106,9 @@ class AccountRepositoryImpl implements AccountRepository {
|
||||
smtpHost: row.smtpHost,
|
||||
smtpPort: row.smtpPort,
|
||||
smtpSsl: row.smtpSsl,
|
||||
manageSieveHost: row.manageSieveHost,
|
||||
manageSievePort: row.manageSievePort,
|
||||
manageSieveSsl: row.manageSieveSsl,
|
||||
jmapUrl: row.jmapUrl,
|
||||
verbose: row.verbose,
|
||||
);
|
||||
|
||||
@@ -90,11 +90,10 @@ class _AccountTile extends ConsumerWidget {
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (account.type == AccountType.jmap)
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFilters,
|
||||
child: Text('Email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFilters,
|
||||
child: Text('Email filters'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
|
||||
@@ -31,8 +31,11 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
final _imapPortCtrl = TextEditingController(text: '993');
|
||||
final _smtpHostCtrl = TextEditingController();
|
||||
final _smtpPortCtrl = TextEditingController(text: '465');
|
||||
final _sieveHostCtrl = TextEditingController();
|
||||
final _sievePortCtrl = TextEditingController(text: '4190');
|
||||
var _imapSsl = true;
|
||||
var _smtpSsl = true;
|
||||
var _sieveSsl = true;
|
||||
|
||||
// -- "Try connection" state ------------------------------------------------
|
||||
bool _tryTesting = false;
|
||||
@@ -56,6 +59,8 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
_imapPortCtrl,
|
||||
_smtpHostCtrl,
|
||||
_smtpPortCtrl,
|
||||
_sieveHostCtrl,
|
||||
_sievePortCtrl,
|
||||
]) {
|
||||
c.dispose();
|
||||
}
|
||||
@@ -121,6 +126,9 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
smtpHost: _smtpHostCtrl.text.trim(),
|
||||
smtpPort: int.parse(_smtpPortCtrl.text),
|
||||
smtpSsl: _smtpSsl,
|
||||
manageSieveHost: _sieveHostCtrl.text.trim(),
|
||||
manageSievePort: int.tryParse(_sievePortCtrl.text) ?? 4190,
|
||||
manageSieveSsl: _sieveSsl,
|
||||
);
|
||||
|
||||
Future<void> _tryConnection(
|
||||
@@ -208,6 +216,9 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
smtpHost: account.smtpHost,
|
||||
smtpPort: account.smtpPort,
|
||||
smtpSsl: account.smtpSsl,
|
||||
manageSieveHost: account.manageSieveHost,
|
||||
manageSievePort: account.manageSievePort,
|
||||
manageSieveSsl: account.manageSieveSsl,
|
||||
);
|
||||
await ref
|
||||
.read(accountRepositoryProvider)
|
||||
@@ -319,6 +330,9 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
_smtpHostCtrl.clear();
|
||||
_smtpPortCtrl.text = '465';
|
||||
_smtpSsl = true;
|
||||
_sieveHostCtrl.clear();
|
||||
_sievePortCtrl.text = '4190';
|
||||
_sieveSsl = true;
|
||||
_step = _Step.imapForm;
|
||||
}),
|
||||
child: const Text('IMAP / SMTP'),
|
||||
@@ -403,6 +417,31 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
|
||||
value: _smtpSsl,
|
||||
onChanged: (v) => setState(() => _smtpSsl = v),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'ManageSieve (email filters)',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
children: [
|
||||
_field(
|
||||
_sieveHostCtrl,
|
||||
'Host (leave blank to use IMAP host)',
|
||||
required: false,
|
||||
),
|
||||
_field(
|
||||
_sievePortCtrl,
|
||||
'Port',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _sieveSsl,
|
||||
onChanged: (v) => setState(() => _sieveSsl = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
TryConnectionButton(
|
||||
buttonKey: const Key('tryConnectionButton'),
|
||||
testing: _tryTesting,
|
||||
|
||||
@@ -32,6 +32,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
final _smtpHostCtrl = TextEditingController();
|
||||
final _smtpPortCtrl = TextEditingController();
|
||||
var _smtpSsl = true;
|
||||
final _sieveHostCtrl = TextEditingController();
|
||||
final _sievePortCtrl = TextEditingController();
|
||||
var _sieveSsl = true;
|
||||
var _verbose = false;
|
||||
final _jmapUrlCtrl = TextEditingController();
|
||||
|
||||
@@ -62,6 +65,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.text = account.smtpHost;
|
||||
_smtpPortCtrl.text = account.smtpPort.toString();
|
||||
_smtpSsl = account.smtpSsl;
|
||||
_sieveHostCtrl.text = account.manageSieveHost;
|
||||
_sievePortCtrl.text = account.manageSievePort.toString();
|
||||
_sieveSsl = account.manageSieveSsl;
|
||||
_verbose = account.verbose;
|
||||
_jmapUrlCtrl.text = account.jmapUrl ?? '';
|
||||
setState(() => _loading = false);
|
||||
@@ -77,6 +83,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_imapPortCtrl,
|
||||
_smtpHostCtrl,
|
||||
_smtpPortCtrl,
|
||||
_sieveHostCtrl,
|
||||
_sievePortCtrl,
|
||||
_jmapUrlCtrl,
|
||||
]) {
|
||||
c.dispose();
|
||||
@@ -97,6 +105,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
smtpHost: _smtpHostCtrl.text.trim(),
|
||||
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
|
||||
smtpSsl: _smtpSsl,
|
||||
manageSieveHost: _sieveHostCtrl.text.trim(),
|
||||
manageSievePort:
|
||||
int.tryParse(_sievePortCtrl.text) ?? account.manageSievePort,
|
||||
manageSieveSsl: _sieveSsl,
|
||||
jmapUrl:
|
||||
_jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
|
||||
verbose: _verbose,
|
||||
@@ -163,6 +175,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
smtpHost: updated.smtpHost,
|
||||
smtpPort: updated.smtpPort,
|
||||
smtpSsl: updated.smtpSsl,
|
||||
manageSieveHost: updated.manageSieveHost,
|
||||
manageSievePort: updated.manageSievePort,
|
||||
manageSieveSsl: updated.manageSieveSsl,
|
||||
jmapUrl: updated.jmapUrl,
|
||||
verbose: updated.verbose,
|
||||
);
|
||||
@@ -255,6 +270,31 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
value: _smtpSsl,
|
||||
onChanged: (v) => setState(() => _smtpSsl = v),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'ManageSieve (email filters)',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
children: [
|
||||
_field(
|
||||
_sieveHostCtrl,
|
||||
'Host (leave blank to use IMAP host)',
|
||||
required: false,
|
||||
),
|
||||
_field(
|
||||
_sievePortCtrl,
|
||||
'Port',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('SSL/TLS'),
|
||||
value: _sieveSsl,
|
||||
onChanged: (v) => setState(() => _sieveSsl = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const Divider(height: 32),
|
||||
SwitchListTile(
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
@@ -85,15 +84,14 @@ class FolderDrawer extends ConsumerWidget {
|
||||
context.go('/accounts');
|
||||
},
|
||||
),
|
||||
if (accountAsync.valueOrNull?.type == AccountType.jmap)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_list),
|
||||
title: const Text('Email filters'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/$accountId/sieve'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.filter_list),
|
||||
title: const Text('Email filters'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
unawaited(context.push('/accounts/$accountId/sieve'));
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: StreamBuilder(
|
||||
|
||||
@@ -30,6 +30,9 @@ const _excluded = {
|
||||
// IMAP/SMTP factory — top-level functions that open real network connections;
|
||||
// no seam to inject a fake client without wrapping the enough_mail types.
|
||||
'lib/data/imap/imap_client_factory.dart',
|
||||
// ManageSieve (RFC 5804) client — opens real TCP/TLS sockets; tested via
|
||||
// the Sieve UI + integration scenarios rather than unit tests.
|
||||
'lib/data/imap/managesieve_client.dart',
|
||||
// Pure adapter over FlutterSecureStorage (a platform plugin);
|
||||
// all three methods just delegate — no logic, and platform channels are
|
||||
// unavailable in unit tests.
|
||||
|
||||
@@ -308,7 +308,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('IMAP'), findsOneWidget);
|
||||
// Both IMAP and SMTP have SSL/TLS toggles.
|
||||
// IMAP and SMTP each have an SSL/TLS toggle (the ManageSieve toggle is
|
||||
// hidden inside a collapsed ExpansionTile).
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user