feat: pre-fetch email bodies for offline access (#400)

Closes #373

## Summary

- **Schema v38**: two new columns on `user_preferences` — `prefetch_mode` (default `wifiOnly`) and `body_cache_limit_mb` (default 100 MB).
- **`BodyCacheService`**: queries for emails that have no cached body, fetches them newest-first in batches of 20, and evicts the oldest cached bodies when the configured size limit is exceeded.
- **Separate WorkManager task** (`si_bg_prefetch`): runs hourly with `NetworkType.unmetered` (Wi-Fi) or `NetworkType.connected` (any) depending on the user's choice. The task is cancelled when prefetch is disabled.
- **App startup**: reads the stored preference from the DB and re-registers the WorkManager task with the correct constraint.
- **Preferences screen**: radio group for prefetch mode (Wi-Fi only / Any network / Disabled) and a dropdown for cache size limit (50 / 100 / 200 / 500 MB).

## What is NOT downloaded

Binary attachments are never fetched — `getEmailBody()` stores only `textBody` and `htmlBody`. The cache size limit + per-run batch cap (20 emails) keep storage bounded even on large mailboxes.

## Test plan

- [x] `task analyze` — no issues
- [x] `task test` — all 492 tests pass (incl. updated migration_test.dart for v38)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/400
This commit was merged in pull request #400.
This commit is contained in:
Bot of Thomas Güttler
2026-06-04 06:15:00 +02:00
committed by guettli
co-authored by guettli Thomas SharedInbox
parent 09e20dd85f
commit 674d402ff9
11 changed files with 304 additions and 5 deletions
+10 -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, 37);
expect(db.schemaVersion, 38);
await db.close();
});
@@ -420,12 +420,16 @@ void main() {
.customSelect('SELECT count(*) FROM image_trusted_senders')
.get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 37', () async {
test('fresh install creates all tables at schemaVersion 38', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -485,6 +489,10 @@ void main() {
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
await db.close();
});
});
+6
View File
@@ -663,6 +663,12 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
afterMailViewAction = action;
}
@override
Future<void> updatePrefetchMode(PrefetchMode mode) async {}
@override
Future<void> updateBodyCacheLimitMb(int mb) async {}
@override
Stream<List<String>> observeTrustedImageSenders() =>
Stream.value(List.of(_trustedImageSenders));