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:
co-authored by
Claude Sonnet 4.6
parent
b144dba5ec
commit
169e563e3d
@@ -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
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
# Later
|
||||
|
||||
make `task check` faster.
|
||||
It gets executed via pre-commit.... Must be fast.
|
||||
|
||||
---
|
||||
|
||||
Flutter best practices?
|
||||
|
||||
---
|
||||
|
||||
+5
-1
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user