security: enforce encrypted connections; pre-commit uses check-fast

IMAP/SMTP encryption:
- connectImap throws if account.imapSsl is false
- connectSmtp removes STARTTLS plaintext fallback; startTls failure is fatal
- Remove IMAP SSL/TLS toggle from add/edit account screens (always SSL)
- UI shows "IMAP (SSL/TLS)" section label to communicate the requirement

Pre-commit speed:
- Add check-fast task (analyze + unit + widget, no build-linux, no integration)
- pre-commit hook now runs task check-fast instead of task check
- task check remains the full suite for manual/CI use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-18 16:16:35 +02:00
co-authored by Claude Sonnet 4.6
parent b144dba5ec
commit 169e563e3d
7 changed files with 28 additions and 64 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ repos:
- repo: local
hooks:
- id: task-check
name: task check (analyze + unit + widget + integration)
name: task check-fast (analyze + unit + widget)
language: system
entry: task check
entry: task check-fast
pass_filenames: false
always_run: true
-5
View File
@@ -1,10 +1,5 @@
# Later
make `task check` faster.
It gets executed via pre-commit.... Must be fast.
---
Flutter best practices?
---
+5 -1
View File
@@ -101,6 +101,10 @@ tasks:
cmds:
- fvm flutter run -d linux --no-pub
check-fast:
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
deps: [analyze, test, test-widget]
check:
desc: All fast checks — analyze + unit tests + widget tests + build-linux + integration in parallel
desc: Full check suite — analyze + unit tests + widget tests + build-linux + integration in parallel
deps: [analyze, test, test-widget, build-linux, integration]
+13 -26
View File
@@ -1,25 +1,27 @@
import 'dart:io' show HandshakeException;
import 'package:enough_mail/enough_mail.dart';
import '../../core/models/account.dart';
import '../../core/utils/logger.dart';
/// Opens an authenticated IMAP client for [account] using [username].
///
/// Throws [Exception] if the account is not configured for SSL/TLS.
Future<ImapClient> connectImap(
Account account, String username, String password) async {
if (!account.imapSsl) {
throw Exception(
'Unencrypted IMAP connections are not allowed. Enable SSL/TLS.');
}
final client = ImapClient();
await client.connectToServer(
account.imapHost,
account.imapPort,
isSecure: account.imapSsl,
);
await client.connectToServer(account.imapHost, account.imapPort);
await client.login(username, password);
return client;
}
/// Opens an authenticated SMTP client for [account] using [username].
///
/// When [account.smtpSsl] is false, STARTTLS is required and the connection
/// fails if the server does not support it. Plaintext fallback is not allowed.
///
/// Caller is responsible for calling [SmtpClient.quit] when done.
Future<SmtpClient> connectSmtp(
Account account, String username, String password) async {
@@ -29,7 +31,7 @@ Future<SmtpClient> connectSmtp(
final clientDomain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
var client = SmtpClient(clientDomain);
final client = SmtpClient(clientDomain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
@@ -37,23 +39,8 @@ Future<SmtpClient> connectSmtp(
);
await client.ehlo();
if (!account.smtpSsl) {
// Opportunistic TLS on submission port (587).
try {
await client.startTls();
} on HandshakeException catch (e) {
// TLS handshake failure (e.g. self-signed cert) breaks the socket.
// Reconnect plaintext so authenticate() can still proceed.
log('STARTTLS handshake failed on ${account.smtpHost}: $e — reconnecting without TLS');
client = SmtpClient(clientDomain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
isSecure: false,
);
await client.ehlo();
} catch (e) {
log('STARTTLS not available on ${account.smtpHost}: $e — continuing without TLS');
}
// STARTTLS required on submission port (587). No plaintext fallback.
await client.startTls();
}
await client.authenticate(username, password);
return client;
+1 -12
View File
@@ -27,7 +27,6 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
final _jmapApiUrlCtrl = TextEditingController();
final _imapHostCtrl = TextEditingController();
final _imapPortCtrl = TextEditingController(text: '993');
var _imapSsl = true;
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController(text: '587');
var _smtpSsl = false;
@@ -80,14 +79,12 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
case ImapSmtpDiscovery(
:final imapHost,
:final imapPort,
:final imapSsl,
:final smtpHost,
:final smtpPort,
:final smtpSsl,
):
_imapHostCtrl.text = imapHost;
_imapPortCtrl.text = imapPort.toString();
_imapSsl = imapSsl;
_smtpHostCtrl.text = smtpHost;
_smtpPortCtrl.text = smtpPort.toString();
_smtpSsl = smtpSsl;
@@ -116,7 +113,6 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
username: _usernameCtrl.text.trim(),
imapHost: _imapHostCtrl.text.trim(),
imapPort: int.parse(_imapPortCtrl.text),
imapSsl: _imapSsl,
smtpHost: _smtpHostCtrl.text.trim(),
smtpPort: int.parse(_smtpPortCtrl.text),
smtpSsl: _smtpSsl,
@@ -202,7 +198,6 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
username: account.username.isNotEmpty ? account.username : effective,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSsl: account.imapSsl,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpSsl: account.smtpSsl,
@@ -314,7 +309,6 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
onPressed: () => setState(() {
_imapHostCtrl.clear();
_imapPortCtrl.text = '993';
_imapSsl = true;
_smtpHostCtrl.clear();
_smtpPortCtrl.text = '587';
_smtpSsl = false;
@@ -384,15 +378,10 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
required: false),
_field(_passwordCtrl, 'Password', obscure: true),
const Divider(height: 32),
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
Text('IMAP (SSL/TLS)', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'),
_field(_imapPortCtrl, 'Port',
keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _imapSsl,
onChanged: (v) => setState(() => _imapSsl = v),
),
const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'),
+1 -10
View File
@@ -26,7 +26,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
final _passwordCtrl = TextEditingController();
final _imapHostCtrl = TextEditingController();
final _imapPortCtrl = TextEditingController();
var _imapSsl = true;
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController();
var _smtpSsl = false;
@@ -56,7 +55,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_usernameCtrl.text = account.username;
_imapHostCtrl.text = account.imapHost;
_imapPortCtrl.text = account.imapPort.toString();
_imapSsl = account.imapSsl;
_smtpHostCtrl.text = account.smtpHost;
_smtpPortCtrl.text = account.smtpPort.toString();
_smtpSsl = account.smtpSsl;
@@ -91,7 +89,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
type: account.type,
imapHost: _imapHostCtrl.text.trim(),
imapPort: int.tryParse(_imapPortCtrl.text) ?? account.imapPort,
imapSsl: _imapSsl,
smtpHost: _smtpHostCtrl.text.trim(),
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
smtpSsl: _smtpSsl,
@@ -156,7 +153,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
type: updated.type,
imapHost: updated.imapHost,
imapPort: updated.imapPort,
imapSsl: updated.imapSsl,
smtpHost: updated.smtpHost,
smtpPort: updated.smtpPort,
smtpSsl: updated.smtpSsl,
@@ -229,15 +225,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
],
if (account.type == AccountType.imap) ...[
const Divider(height: 32),
Text('IMAP', style: Theme.of(context).textTheme.titleSmall),
Text('IMAP (SSL/TLS)', style: Theme.of(context).textTheme.titleSmall),
_field(_imapHostCtrl, 'Host'),
_field(_imapPortCtrl, 'Port',
keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _imapSsl,
onChanged: (v) => setState(() => _imapSsl = v),
),
const Divider(height: 32),
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'),
+6 -8
View File
@@ -133,8 +133,8 @@ void main() {
await tester.tap(find.text('IMAP / SMTP'));
await tester.pumpAndSettle();
expect(find.text('IMAP'), findsWidgets);
expect(find.text('SMTP'), findsWidgets);
expect(find.text('IMAP (SSL/TLS)'), findsOneWidget);
expect(find.text('SMTP'), findsOneWidget);
});
testWidgets('successful JMAP save pops back to accounts list', (tester) async {
@@ -216,7 +216,7 @@ void main() {
expect(find.text('No accounts yet.'), findsOneWidget);
});
testWidgets('IMAP SSL is on by default', (tester) async {
testWidgets('IMAP form shows SSL/TLS label and SMTP toggle', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/add',
overrides: baseOverrides(discovery: UnknownDiscovery()),
@@ -231,11 +231,9 @@ void main() {
await tester.tap(find.text('IMAP / SMTP'));
await tester.pumpAndSettle();
final tiles = tester
.widgetList<SwitchListTile>(find.byType(SwitchListTile))
.toList();
expect(tiles.first.value, isTrue, reason: 'IMAP SSL on by default');
expect(tiles.last.value, isFalse, reason: 'SMTP SSL off by default');
expect(find.text('IMAP (SSL/TLS)'), findsOneWidget);
// Only the SMTP SSL/TLS toggle remains; no IMAP toggle.
expect(find.byType(SwitchListTile), findsOneWidget);
});
});
}