From 692fa14d4d365b8c1699263d9fa0203d8a14e416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Thu, 4 Jun 2026 01:41:50 +0200 Subject: [PATCH] feat: remember show images per sender (#378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #377 - Adds a new `ImageTrustedSenders` Drift table (schema v37) that stores email addresses for which remote images are loaded automatically (per device, not per account) - When the user taps "Load remote images", the sender's address is saved and a 3-second snackbar appears with a "Settings" hyperlink to undo the choice in preferences - Both `EmailDetailScreen` and `ThreadDetailScreen` check the trusted senders list on open and auto-load images for known senders - The Preferences screen gains a new "Trusted image senders" section listing all saved senders with individual remove buttons ## Test plan - [x] `dart run build_runner build` regenerates `database.g.dart` cleanly (schema v37) - [x] `flutter analyze` — no issues - [x] Migration test updated: checks `image_trusted_senders` table exists after upgrade and fresh install - [x] `FakeUserPreferencesRepository` updated with three new interface methods - [x] All 490 unit + widget tests pass (1 pre-existing golden test failure unrelated to this change) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Thomas SharedInbox Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/378 --- lib/core/db_schema_version.dart | 2 +- .../user_preferences_repository.dart | 4 ++ lib/data/db/database.dart | 15 ++++++ .../user_preferences_repository_impl.dart | 25 +++++++++ lib/di.dart | 7 +++ lib/ui/screens/email_detail_screen.dart | 53 +++++++++++++++++-- lib/ui/screens/thread_detail_screen.dart | 47 +++++++++++++--- lib/ui/screens/user_preferences_screen.dart | 40 ++++++++++++++ test/unit/migration_test.dart | 16 +++++- test/widget/helpers.dart | 21 +++++++- 10 files changed, 215 insertions(+), 15 deletions(-) diff --git a/lib/core/db_schema_version.dart b/lib/core/db_schema_version.dart index 2379cdd..ea4486a 100644 --- a/lib/core/db_schema_version.dart +++ b/lib/core/db_schema_version.dart @@ -1 +1 @@ -const int dbSchemaVersion = 36; +const int dbSchemaVersion = 37; diff --git a/lib/core/repositories/user_preferences_repository.dart b/lib/core/repositories/user_preferences_repository.dart index 4b26113..bc70e89 100644 --- a/lib/core/repositories/user_preferences_repository.dart +++ b/lib/core/repositories/user_preferences_repository.dart @@ -5,4 +5,8 @@ abstract class UserPreferencesRepository { Future updateMenuPosition(MenuPosition position); Future updateMailViewButtonPosition(MenuPosition position); Future updateAfterMailViewAction(AfterMailViewAction action); + + Stream> observeTrustedImageSenders(); + Future addTrustedImageSender(String senderEmail); + Future removeTrustedImageSender(String senderEmail); } diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 01164d5..5f5169e 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -307,6 +307,17 @@ class LocalSieveApplied extends Table { Set get primaryKey => {accountId, messageId}; } +/// Senders for whom remote images are loaded automatically. +/// Per-device/per-user — not tied to any email account. +@DataClassName('ImageTrustedSenderRow') +class ImageTrustedSenders extends Table { + TextColumn get senderEmail => text()(); + DateTimeColumn get addedAt => dateTime()(); + + @override + Set get primaryKey => {senderEmail}; +} + /// App-wide user preferences, stored as a singleton row (id always 1). @DataClassName('UserPreferencesRow') class UserPreferences extends Table { @@ -345,6 +356,7 @@ class UserPreferences extends Table { LocalSieveApplied, ShareKeys, UserPreferences, + ImageTrustedSenders, ], ) class AppDatabase extends _$AppDatabase { @@ -611,6 +623,9 @@ class AppDatabase extends _$AppDatabase { userPreferences.afterMailViewAction, ); } + if (from < 37) { + await m.createTable(imageTrustedSenders); + } }, ); } diff --git a/lib/data/repositories/user_preferences_repository_impl.dart b/lib/data/repositories/user_preferences_repository_impl.dart index 55d1b4a..7af191b 100644 --- a/lib/data/repositories/user_preferences_repository_impl.dart +++ b/lib/data/repositories/user_preferences_repository_impl.dart @@ -50,6 +50,31 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository { ); } + @override + Stream> observeTrustedImageSenders() { + return (_db.select(_db.imageTrustedSenders) + ..orderBy([(t) => OrderingTerm.desc(t.addedAt)])) + .watch() + .map((rows) => rows.map((r) => r.senderEmail).toList()); + } + + @override + Future addTrustedImageSender(String senderEmail) async { + await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate( + ImageTrustedSendersCompanion( + senderEmail: Value(senderEmail.toLowerCase()), + addedAt: Value(DateTime.now()), + ), + ); + } + + @override + Future removeTrustedImageSender(String senderEmail) async { + await (_db.delete(_db.imageTrustedSenders) + ..where((t) => t.senderEmail.equals(senderEmail.toLowerCase()))) + .go(); + } + static pref.UserPreferences _rowToModel(UserPreferencesRow? row) { if (row == null) return const pref.UserPreferences(); return pref.UserPreferences( diff --git a/lib/di.dart b/lib/di.dart index 7cb4674..152b311 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -247,3 +247,10 @@ final userPreferencesProvider = StreamProvider.autoDispose(( ) { return ref.watch(userPreferencesRepositoryProvider).observePreferences(); }); + +final trustedImageSendersProvider = + StreamProvider.autoDispose>((ref) { + return ref + .watch(userPreferencesRepositoryProvider) + .observeTrustedImageSenders(); +}); diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 61aff9c..d9bf884 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -171,19 +171,35 @@ class _EmailDetailScreenState extends ConsumerState { body: detail.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), - data: (d) => _buildBody(context, d.$1, d.$2), + data: (d) { + final trusted = + ref.watch(trustedImageSendersProvider).value ?? const []; + return _buildBody(context, d.$1, d.$2, trusted); + }, ), ); } - Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) { + Widget _buildBody( + BuildContext ctx, + Email? header, + EmailBody body, + List trustedSenders, + ) { final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; + final senderEmail = header?.from.isNotEmpty == true + ? header!.from.first.email.toLowerCase() + : null; + final isTrusted = + senderEmail != null && trustedSenders.contains(senderEmail); + final effectiveLoadImages = _loadRemoteImages || isTrusted; + return ListView( padding: const EdgeInsets.all(16), children: [ if (header != null) ...[_buildHeader(ctx, header), const Divider()], if (hasHtml) ...[ - if (!_loadRemoteImages) + if (!effectiveLoadImages) Align( alignment: Alignment.centerLeft, child: Padding( @@ -191,13 +207,40 @@ class _EmailDetailScreenState extends ConsumerState { child: OutlinedButton.icon( icon: const Icon(Icons.image_outlined, size: 18), label: const Text('Load remote images'), - onPressed: () => setState(() => _loadRemoteImages = true), + onPressed: () { + setState(() => _loadRemoteImages = true); + if (senderEmail != null) { + unawaited( + ref + .read(userPreferencesRepositoryProvider) + .addTrustedImageSender(senderEmail), + ); + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + duration: const Duration(seconds: 3), + content: const Text( + 'Images will be loaded automatically for this sender.', + ), + action: SnackBarAction( + label: 'Settings', + onPressed: () { + if (mounted) { + unawaited( + context.push('/accounts/preferences'), + ); + } + }, + ), + ), + ); + } + }, ), ), ), SecureEmailWebView( htmlBody: body.htmlBody!, - loadRemoteImages: _loadRemoteImages, + loadRemoteImages: effectiveLoadImages, ), ] else SelectableText( diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index 2bddb64..717a4b7 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -113,6 +113,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { @override Widget build(BuildContext context) { + final trustedSenders = + ref.watch(trustedImageSendersProvider).value ?? const []; + final senderEmail = widget.email.from.isNotEmpty + ? widget.email.from.first.email.toLowerCase() + : null; + final isTrusted = + senderEmail != null && trustedSenders.contains(senderEmail); + return Card( margin: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -147,13 +155,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { ], ), ), - if (_expanded) _buildExpandedBody(), + if (_expanded) _buildExpandedBody(isTrusted, senderEmail), ], ), ); } - Widget _buildExpandedBody() { + Widget _buildExpandedBody(bool isTrusted, String? senderEmail) { return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( @@ -184,21 +192,48 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { } final body = snapshot.data!; final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; + final effectiveLoadImages = _loadRemoteImages || isTrusted; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (hasHtml) ...[ - if (!_loadRemoteImages) + if (!effectiveLoadImages) TextButton.icon( icon: const Icon(Icons.image_outlined, size: 16), label: const Text('Load remote images'), - onPressed: () => - setState(() => _loadRemoteImages = true), + onPressed: () { + setState(() => _loadRemoteImages = true); + if (senderEmail != null) { + unawaited( + ref + .read(userPreferencesRepositoryProvider) + .addTrustedImageSender(senderEmail), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 3), + content: const Text( + 'Images will be loaded automatically for this sender.', + ), + action: SnackBarAction( + label: 'Settings', + onPressed: () { + if (mounted) { + unawaited( + context.push('/accounts/preferences'), + ); + } + }, + ), + ), + ); + } + }, ), SecureEmailWebView( htmlBody: body.htmlBody!, - loadRemoteImages: _loadRemoteImages, + loadRemoteImages: effectiveLoadImages, ), ] else SelectableText( diff --git a/lib/ui/screens/user_preferences_screen.dart b/lib/ui/screens/user_preferences_screen.dart index 08749ff..4d14a50 100644 --- a/lib/ui/screens/user_preferences_screen.dart +++ b/lib/ui/screens/user_preferences_screen.dart @@ -12,6 +12,7 @@ class UserPreferencesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final prefsAsync = ref.watch(userPreferencesProvider); + final trustedSendersAsync = ref.watch(trustedImageSendersProvider); return Scaffold( appBar: AppBar(title: const Text('Preferences')), @@ -131,6 +132,45 @@ class UserPreferencesScreen extends ConsumerWidget { ], ), ), + const Divider(), + ListTile( + title: Text( + 'Trusted image senders', + style: Theme.of(context).textTheme.titleSmall, + ), + subtitle: const Text( + 'Remote images are loaded automatically for these senders.', + ), + ), + ...trustedSendersAsync.when( + loading: () => const [], + error: (_, __) => const [], + data: (senders) => senders.isEmpty + ? [ + const Padding( + padding: + EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('No trusted senders yet.'), + ), + ] + : [ + for (final sender in senders) + ListTile( + title: Text(sender), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () { + unawaited( + ref + .read(userPreferencesRepositoryProvider) + .removeTrustedImageSender(sender), + ); + }, + ), + ), + ], + ), ], ), ), diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index e0aadad..143e1aa 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 36); + expect(db.schemaVersion, 37); await db.close(); }); @@ -209,6 +209,9 @@ void main() { // v36: after_mail_view_action column on user_preferences. expect(userPrefsColumns, contains('after_mail_view_action')); + // v37: image_trusted_senders table. + await db.customSelect('SELECT count(*) FROM image_trusted_senders').get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); @@ -412,12 +415,17 @@ void main() { // v36: after_mail_view_action column on user_preferences. expect(userPrefsColumns, contains('after_mail_view_action')); + // v37: image_trusted_senders table. + await db + .customSelect('SELECT count(*) FROM image_trusted_senders') + .get(); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }, ); - test('fresh install creates all tables at schemaVersion 36', () async { + test('fresh install creates all tables at schemaVersion 37', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -445,6 +453,7 @@ void main() { 'share_keys', // v31 'local_sieve_applied', // v32 'user_preferences', // v34 + 'image_trusted_senders', // v37 ]), ); @@ -473,6 +482,9 @@ void main() { // v36: after_mail_view_action column on user_preferences. expect(userPrefsColumns, contains('after_mail_view_action')); + // v37: image_trusted_senders table. + await db.customSelect('SELECT count(*) FROM image_trusted_senders').get(); + await db.close(); }); }); diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index bfb0360..bfd5515 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -627,11 +627,13 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository { this.menuPosition = MenuPosition.bottom, this.mailViewButtonPosition = MenuPosition.bottom, this.afterMailViewAction = AfterMailViewAction.nextMessage, - }); + List? trustedImageSenders, + }) : _trustedImageSenders = trustedImageSenders ?? []; MenuPosition menuPosition; MenuPosition mailViewButtonPosition; AfterMailViewAction afterMailViewAction; + final List _trustedImageSenders; @override Stream observePreferences() => Stream.value( @@ -656,6 +658,23 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository { Future updateAfterMailViewAction(AfterMailViewAction action) async { afterMailViewAction = action; } + + @override + Stream> observeTrustedImageSenders() => + Stream.value(List.of(_trustedImageSenders)); + + @override + Future addTrustedImageSender(String senderEmail) async { + final normalized = senderEmail.toLowerCase(); + if (!_trustedImageSenders.contains(normalized)) { + _trustedImageSenders.add(normalized); + } + } + + @override + Future removeTrustedImageSender(String senderEmail) async { + _trustedImageSenders.remove(senderEmail.toLowerCase()); + } } class FakeSearchHistoryRepository implements SearchHistoryRepository {