feat: restrict plain-text connections to localhost only

Hides the SSL/TLS toggle in add/edit account screens when the host is
not localhost; enforces SSL in connectImap/connectSmtp for non-localhost
hosts so plaintext can never be configured accidentally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-05-05 07:35:56 +02:00
co-authored by Claude Sonnet 4.6
parent d4d61b2b39
commit 569273d7ff
7 changed files with 111 additions and 40 deletions
+2
View File
@@ -6,6 +6,8 @@ Tasks get moved from next.md to done.md
## Tasks
## Plain-text connections only via localhost; SSL toggle hidden for non-localhost hosts
## ManageSieve uses STARTTLS; clearer TLS-mismatch errors; broader connection check
The "Email filters" screen failed for IMAP accounts with
+4
View File
@@ -0,0 +1,4 @@
bool isLocalhost(String host) {
final h = host.trim().toLowerCase();
return h == 'localhost' || h == '127.0.0.1' || h == '::1';
}
+11
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:enough_mail/enough_mail.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/utils/host_utils.dart';
import 'package:sharedinbox/data/imap/tls_error.dart';
typedef ImapConnectFn = Future<ImapClient> Function(
@@ -31,6 +32,11 @@ Future<ImapClient> connectImap(
defaultResponseTimeout: const Duration(seconds: 20),
isLogEnabled: verboseBuffer != null,
);
if (!account.imapSsl && !isLocalhost(account.imapHost)) {
throw Exception(
'Plain-text IMAP is only allowed for localhost connections',
);
}
try {
await client.connectToServer(
account.imapHost,
@@ -61,6 +67,11 @@ Future<SmtpClient> connectSmtp(
final clientDomain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
if (!account.smtpSsl && !isLocalhost(account.smtpHost)) {
throw Exception(
'Plain-text SMTP is only allowed for localhost connections',
);
}
final client = SmtpClient(clientDomain);
try {
await client.connectToServer(
+40 -22
View File
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/utils/host_utils.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/try_connection_button.dart';
@@ -46,8 +47,19 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
final _jmapFormKey = GlobalKey<FormState>();
final _imapFormKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_imapHostCtrl.addListener(_rebuild);
_smtpHostCtrl.addListener(_rebuild);
}
void _rebuild() => setState(() {});
@override
void dispose() {
_imapHostCtrl.removeListener(_rebuild);
_smtpHostCtrl.removeListener(_rebuild);
for (final c in [
_emailCtrl,
_displayNameCtrl,
@@ -112,18 +124,22 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
jmapUrl: _jmapApiUrlCtrl.text.trim(),
);
Account _buildImapAccount() => Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: _displayNameCtrl.text.trim(),
email: _emailCtrl.text.trim(),
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,
);
Account _buildImapAccount() {
final imapHost = _imapHostCtrl.text.trim();
final smtpHost = _smtpHostCtrl.text.trim();
return Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: _displayNameCtrl.text.trim(),
email: _emailCtrl.text.trim(),
username: _usernameCtrl.text.trim(),
imapHost: imapHost,
imapPort: int.parse(_imapPortCtrl.text),
imapSsl: isLocalhost(imapHost) ? _imapSsl : true,
smtpHost: smtpHost,
smtpPort: int.parse(_smtpPortCtrl.text),
smtpSsl: isLocalhost(smtpHost) ? _smtpSsl : true,
);
}
Future<void> _tryConnection(
GlobalKey<FormState> formKey,
@@ -399,20 +415,22 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
Text('IMAP', 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),
),
if (isLocalhost(_imapHostCtrl.text.trim()))
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'),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _smtpSsl,
onChanged: (v) => setState(() => _smtpSsl = v),
),
if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile(
title: const Text('SSL/TLS'),
value: _smtpSsl,
onChanged: (v) => setState(() => _smtpSsl = v),
),
TryConnectionButton(
buttonKey: const Key('tryConnectionButton'),
testing: _tryTesting,
+31 -13
View File
@@ -5,6 +5,7 @@ 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/utils/host_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/try_connection_button.dart';
@@ -46,9 +47,14 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
@override
void initState() {
super.initState();
_smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild);
unawaited(_load());
}
void _rebuild() => setState(() {});
Future<void> _load() async {
final repo = ref.read(accountRepositoryProvider);
final account = await repo.getAccount(widget.accountId);
@@ -75,6 +81,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
@override
void dispose() {
_smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild);
for (final c in [
_displayNameCtrl,
_usernameCtrl,
@@ -104,6 +113,8 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
sieveHost != account.manageSieveHost ||
sievePort != account.manageSievePort ||
_sieveSsl != account.manageSieveSsl;
final smtpHost = _smtpHostCtrl.text.trim();
final effectiveSieveHost = sieveHost.isNotEmpty ? sieveHost : imapHost;
return Account(
id: account.id,
displayName: _displayNameCtrl.text.trim(),
@@ -112,12 +123,13 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
type: account.type,
imapHost: imapHost,
imapPort: int.tryParse(_imapPortCtrl.text) ?? account.imapPort,
smtpHost: _smtpHostCtrl.text.trim(),
imapSsl: isLocalhost(imapHost) ? account.imapSsl : true,
smtpHost: smtpHost,
smtpPort: int.tryParse(_smtpPortCtrl.text) ?? account.smtpPort,
smtpSsl: _smtpSsl,
smtpSsl: isLocalhost(smtpHost) ? _smtpSsl : true,
manageSieveHost: sieveHost,
manageSievePort: sievePort,
manageSieveSsl: _sieveSsl,
manageSieveSsl: isLocalhost(effectiveSieveHost) ? _sieveSsl : true,
manageSieveAvailable:
sieveSettingsChanged ? null : account.manageSieveAvailable,
jmapUrl:
@@ -284,11 +296,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
Text('SMTP', style: Theme.of(context).textTheme.titleSmall),
_field(_smtpHostCtrl, 'Host'),
_field(_smtpPortCtrl, 'Port', keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _smtpSsl,
onChanged: (v) => setState(() => _smtpSsl = v),
),
if (isLocalhost(_smtpHostCtrl.text.trim()))
SwitchListTile(
title: const Text('SSL/TLS'),
value: _smtpSsl,
onChanged: (v) => setState(() => _smtpSsl = v),
),
const Divider(height: 32),
ExpansionTile(
tilePadding: EdgeInsets.zero,
@@ -307,11 +320,16 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
'Port',
keyboardType: TextInputType.number,
),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _sieveSsl,
onChanged: (v) => setState(() => _sieveSsl = v),
),
if (isLocalhost(
_sieveHostCtrl.text.trim().isNotEmpty
? _sieveHostCtrl.text.trim()
: _imapHostCtrl.text.trim(),
))
SwitchListTile(
title: const Text('SSL/TLS'),
value: _sieveSsl,
onChanged: (v) => setState(() => _sieveSsl = v),
),
],
),
],
+4 -2
View File
@@ -20,5 +20,7 @@ Then push
## Tasks
Plain-text connections only via localhost.
Dont show in ui, except host is localhost.
Download of attachments does not work yet. Attachments have size 0. (IMAP account)
---
I deleted some mails, then I use Thunderbird, but the deleted mails are still there. After restart of sharedinbox the delete seems to get synced. Why not immediately?
+19 -3
View File
@@ -287,7 +287,8 @@ void main() {
expect(find.text('No accounts yet.'), findsOneWidget);
});
testWidgets('IMAP form shows SSL/TLS label and SMTP toggle',
testWidgets(
'IMAP form hides SSL toggle for non-localhost, shows for localhost',
(tester) async {
await tester.pumpWidget(
buildApp(
@@ -308,8 +309,23 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('IMAP'), findsOneWidget);
// IMAP and SMTP each have an SSL/TLS toggle (the ManageSieve toggle is
// hidden inside a collapsed ExpansionTile).
// No SSL toggles shown when hosts are empty (non-localhost).
expect(find.byType(SwitchListTile), findsNothing);
// Entering localhost as IMAP host reveals the IMAP SSL toggle.
await tester.enterText(
find.widgetWithText(TextFormField, 'Host').first,
'localhost',
);
await tester.pumpAndSettle();
expect(find.byType(SwitchListTile), findsOneWidget);
// Entering localhost as SMTP host reveals both SSL toggles.
await tester.enterText(
find.widgetWithText(TextFormField, 'Host').last,
'localhost',
);
await tester.pumpAndSettle();
expect(find.byType(SwitchListTile), findsNWidgets(2));
});
});