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:
Thomas Güttler
2026-04-29 06:53:31 +02:00
co-authored by Claude Opus 4.7
parent 44d02afc46
commit fc270590c4
12 changed files with 745 additions and 158 deletions
+47
View File
@@ -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
+9
View File
@@ -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,
});
+12 -1
View File
@@ -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);
}
},
);
}
+330
View File
@@ -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
View File
@@ -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,
);
+4 -5
View File
@@ -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,
+39
View File
@@ -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,
+40
View File
@@ -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(
+8 -10
View File
@@ -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(
+3
View File
@@ -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.
+2 -1
View File
@@ -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));
});
});