feat: remember show images per sender (#378)

## 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 <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/378
This commit was merged in pull request #378.
This commit is contained in:
Bot of Thomas Güttler
2026-06-04 01:41:50 +02:00
committed by guettli
co-authored by guettli Thomas SharedInbox
parent 5e029a1365
commit 692fa14d4d
10 changed files with 215 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 36;
const int dbSchemaVersion = 37;
@@ -5,4 +5,8 @@ abstract class UserPreferencesRepository {
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
Future<void> removeTrustedImageSender(String senderEmail);
}
+15
View File
@@ -307,6 +307,17 @@ class LocalSieveApplied extends Table {
Set<Column> 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<Column> 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);
}
},
);
}
@@ -50,6 +50,31 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
);
}
@override
Stream<List<String>> observeTrustedImageSenders() {
return (_db.select(_db.imageTrustedSenders)
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
.watch()
.map((rows) => rows.map((r) => r.senderEmail).toList());
}
@override
Future<void> addTrustedImageSender(String senderEmail) async {
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
ImageTrustedSendersCompanion(
senderEmail: Value(senderEmail.toLowerCase()),
addedAt: Value(DateTime.now()),
),
);
}
@override
Future<void> 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(
+7
View File
@@ -247,3 +247,10 @@ final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
final trustedImageSendersProvider =
StreamProvider.autoDispose<List<String>>((ref) {
return ref
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
+48 -5
View File
@@ -171,19 +171,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
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 <String>[];
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<String> 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<EmailDetailScreen> {
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(
+41 -6
View File
@@ -113,6 +113,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
@override
Widget build(BuildContext context) {
final trustedSenders =
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
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(
@@ -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),
);
},
),
),
],
),
],
),
),
+14 -2
View File
@@ -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();
});
});
+20 -1
View File
@@ -627,11 +627,13 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
List<String>? trustedImageSenders,
}) : _trustedImageSenders = trustedImageSenders ?? [];
MenuPosition menuPosition;
MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction;
final List<String> _trustedImageSenders;
@override
Stream<UserPreferences> observePreferences() => Stream.value(
@@ -656,6 +658,23 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action;
}
@override
Stream<List<String>> observeTrustedImageSenders() =>
Stream.value(List.of(_trustedImageSenders));
@override
Future<void> addTrustedImageSender(String senderEmail) async {
final normalized = senderEmail.toLowerCase();
if (!_trustedImageSenders.contains(normalized)) {
_trustedImageSenders.add(normalized);
}
}
@override
Future<void> removeTrustedImageSender(String senderEmail) async {
_trustedImageSenders.remove(senderEmail.toLowerCase());
}
}
class FakeSearchHistoryRepository implements SearchHistoryRepository {