Files
sharedinbox/lib/data/imap/managesieve_client.dart
T

402 lines
12 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
show verboseLogKey;
import 'package:sharedinbox/data/imap/tls_error.dart';
/// 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:
/// STARTTLS, AUTHENTICATE PLAIN, LISTSCRIPTS, GETSCRIPT, PUTSCRIPT,
/// SETACTIVE, DELETESCRIPT, LOGOUT.
class ManageSieveClient {
ManageSieveClient._(this._socket, this._source);
final Socket _socket;
final _ByteSource _source;
bool _closed = false;
/// Connects to [host]:[port] in plaintext, reads the capability greeting,
/// and — when [useTls] is true — upgrades the connection with STARTTLS per
/// RFC 5804 §1.7 before returning. Implicit-TLS ManageSieve is non-standard
/// and not supported here; if [useTls] is true the server must advertise
/// the `STARTTLS` capability.
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().
Socket socket = await Socket.connect(host, port, timeout: timeout);
var source = _ByteSource();
var sub = _attach(socket, source);
try {
final greeting = await _readResponseFromSource(source);
if (greeting.status != _Status.ok) {
throw ManageSieveException(
'Capability greeting failed: ${greeting.message}',
);
}
if (!useTls) {
return ManageSieveClient._(socket, source);
}
if (!_advertisesStartTls(greeting.dataLines)) {
throw const ManageSieveException(
'Server does not advertise STARTTLS — disable SSL for ManageSieve '
'in account settings, or use a server that supports STARTTLS.',
);
}
await _writeLineTo(socket, 'STARTTLS');
final ok = await _readResponseFromSource(source);
if (ok.status != _Status.ok) {
throw ManageSieveException('STARTTLS rejected: ${ok.message}');
}
// Detach plaintext listener before handing the socket to TLS.
await sub.cancel();
try {
socket = await SecureSocket.secure(socket, host: host);
} catch (e, st) {
rethrowAsTlsHint(e, st, host, port);
}
source = _ByteSource();
sub = _attach(socket, source);
// RFC 5804 §1.7: server re-sends capability listing on the secured
// connection. Read and discard it so the response stream stays aligned.
final reCap = await _readResponseFromSource(source);
if (reCap.status != _Status.ok) {
throw ManageSieveException(
'Post-STARTTLS capability failed: ${reCap.message}',
);
}
return ManageSieveClient._(socket, source);
} catch (_) {
await sub.cancel();
try {
await socket.close();
} catch (_) {
/* best-effort */
}
rethrow;
}
}
static StreamSubscription<List<int>> _attach(
Socket socket,
_ByteSource source,
) {
return socket.listen(
source._add,
onDone: () => source._close(),
onError: (Object e, StackTrace _) => source._close(e),
cancelOnError: true,
);
}
static bool _advertisesStartTls(List<String> capLines) {
for (final line in capLines) {
// Capability tokens are quoted-strings, e.g. `"STARTTLS"`. Match the
// first token on each line, case-insensitively.
final m = RegExp(r'^\s*"?([A-Za-z0-9-]+)"?').firstMatch(line);
if (m != null && m.group(1)!.toUpperCase() == 'STARTTLS') return true;
}
return false;
}
static Future<void> _writeLineTo(Socket socket, String line) async {
final log = Zone.current[verboseLogKey] as StringBuffer?;
if (log != null) log.writeln('SIEVE → $line');
socket.add(utf8.encode(line));
socket.add(_crlf);
await socket.flush();
}
/// 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() => _readResponseFromSource(_source);
static Future<_Response> _readResponseFromSource(_ByteSource source) 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;
}
}