feat(sieve): local email filters alongside server filters (#90)

Reuse the same Sieve UI for both server-side (ManageSieve/JMAP) and local
email filters. Both filter sets are stored and managed independently.

Changes:
- Add LocalSieveScripts table (DB schema v29) to store local Sieve scripts
- Add LocalSieveRepository with full CRUD and activate-script support
- Add isLocal param to SieveScriptsScreen and SieveScriptEditScreen; each
  screen shows a banner explaining whether scripts run on the server or device
- Add routes /accounts/:id/sieve/local and /accounts/:id/sieve/local/edit
- Split "Email filters" account menu entry into "Server email filters" and
  "Local email filters" (local is always available, server requires ManageSieve)
- Wire up localSieveRepositoryProvider in DI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-15 18:32:47 +02:00
co-authored by Claude Sonnet 4.6
parent cc052db6c7
commit 0620663630
9 changed files with 441 additions and 53 deletions
+15 -1
View File
@@ -243,6 +243,16 @@ class SearchHistoryEntries extends Table {
DateTimeColumn get searchedAt => dateTime()();
}
@DataClassName('LocalSieveScriptRow')
class LocalSieveScripts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
TextColumn get name => text()();
TextColumn get content => text().withDefault(const Constant(''))();
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
}
@DataClassName('UndoActionRow')
class UndoActions extends Table {
TextColumn get id => text()();
@@ -273,13 +283,14 @@ class UndoActions extends Table {
SyncHealth,
UndoActions,
SearchHistoryEntries,
LocalSieveScripts,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 28;
int get schemaVersion => 29;
Future<void> _createEmailFts() async {
await customStatement('''
@@ -508,6 +519,9 @@ class AppDatabase extends _$AppDatabase {
if (from < 28) {
await m.addColumn(emailBodies, emailBodies.mimeTreeJson);
}
if (from < 29) {
await m.createTable(localSieveScripts);
}
},
);
}
+101
View File
@@ -0,0 +1,101 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/data/db/database.dart';
class LocalSieveRepository {
LocalSieveRepository(this._db);
final AppDatabase _db;
Future<List<SieveScript>> listScripts(String accountId) async {
final rows = await (_db.select(_db.localSieveScripts)
..where((t) => t.accountId.equals(accountId)))
.get();
return rows
.map(
(r) => SieveScript(
id: r.id.toString(),
name: r.name,
blobId: r.id.toString(),
isActive: r.isActive,
),
)
.toList();
}
Future<String> getScriptContent(String accountId, String blobId) async {
final rowId = int.parse(blobId);
final row = await (_db.select(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.getSingleOrNull();
if (row == null) throw Exception('Local script not found: $blobId');
return row.content;
}
Future<SieveScript> saveScript(
String accountId, {
String? id,
required String name,
required String content,
}) async {
if (id != null) {
final rowId = int.parse(id);
await (_db.update(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(
LocalSieveScriptsCompanion(
name: Value(name),
content: Value(content),
),
);
final updated = await (_db.select(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.getSingleOrNull();
return SieveScript(
id: id,
name: name,
blobId: id,
isActive: updated?.isActive ?? false,
);
}
final rowId = await _db.into(_db.localSieveScripts).insert(
LocalSieveScriptsCompanion.insert(
accountId: accountId,
name: name,
content: Value(content),
),
);
final idStr = rowId.toString();
return SieveScript(id: idStr, name: name, blobId: idStr, isActive: false);
}
Future<void> deleteScript(String accountId, String scriptId) async {
final rowId = int.parse(scriptId);
await (_db.delete(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.go();
}
Future<void> activateScript(String accountId, String scriptId) async {
await _db.transaction(() async {
await (_db.update(_db.localSieveScripts)
..where((t) => t.accountId.equals(accountId)))
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
final rowId = int.parse(scriptId);
await (_db.update(_db.localSieveScripts)
..where(
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
))
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
});
}
}
+5
View File
@@ -20,6 +20,7 @@ import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
@@ -155,6 +156,10 @@ final sieveRepositoryProvider = Provider<SieveRepository>((ref) {
);
});
final localSieveRepositoryProvider = Provider<LocalSieveRepository>((ref) {
return LocalSieveRepository(ref.watch(dbProvider));
});
final connectionTestServiceProvider = Provider<ConnectionTestService>((ref) {
return ConnectionTestServiceImpl(
ref.watch(httpClientProvider),
+15
View File
@@ -77,6 +77,21 @@ final router = GoRouter(
script: state.extra as SieveScript?,
),
),
GoRoute(
path: ':accountId/sieve/local',
builder: (ctx, state) => SieveScriptsScreen(
accountId: state.pathParameters['accountId']!,
isLocal: true,
),
),
GoRoute(
path: ':accountId/sieve/local/edit',
builder: (ctx, state) => SieveScriptEditScreen(
accountId: state.pathParameters['accountId']!,
script: state.extra as SieveScript?,
isLocal: true,
),
),
GoRoute(
path: ':accountId/search',
builder: (ctx, state) =>
+19 -4
View File
@@ -160,9 +160,13 @@ class _AccountTile extends ConsumerWidget {
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFilters,
child: Text('Email filters'),
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.export,
child: Text('Export account'),
@@ -203,9 +207,12 @@ class _AccountTile extends ConsumerWidget {
case _AccountAction.edit:
await context.push('/accounts/${account.id}/edit');
break;
case _AccountAction.emailFilters:
case _AccountAction.emailFiltersRemote:
await context.push('/accounts/${account.id}/sieve');
break;
case _AccountAction.emailFiltersLocal:
await context.push('/accounts/${account.id}/sieve/local');
break;
case _AccountAction.export:
await context.push('/accounts/${account.id}/export');
break;
@@ -344,7 +351,15 @@ class _Step extends StatelessWidget {
}
}
enum _AccountAction { syncLog, verifySync, edit, emailFilters, export, delete }
enum _AccountAction {
syncLog,
verifySync,
edit,
emailFiltersRemote,
emailFiltersLocal,
export,
delete,
}
/// Whether to surface the "Email filters" (Sieve) entry for [account].
///
+26 -9
View File
@@ -11,6 +11,7 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
super.key,
required this.accountId,
this.script,
this.isLocal = false,
});
final String accountId;
@@ -18,6 +19,9 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
/// Null when creating a new script.
final SieveScript? script;
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
final bool isLocal;
@override
ConsumerState<SieveScriptEditScreen> createState() =>
_SieveScriptEditScreenState();
@@ -50,9 +54,13 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
Future<void> _loadContent() async {
setState(() => _loadingContent = true);
try {
final content = await ref
.read(sieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId);
final content = widget.isLocal
? await ref
.read(localSieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId)
: await ref
.read(sieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) {
_contentController.text = content;
setState(() => _loadingContent = false);
@@ -78,12 +86,21 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
_error = null;
});
try {
await ref.read(sieveRepositoryProvider).saveScript(
widget.accountId,
id: widget.script?.id,
name: name,
content: _contentController.text,
);
if (widget.isLocal) {
await ref.read(localSieveRepositoryProvider).saveScript(
widget.accountId,
id: widget.script?.id,
name: name,
content: _contentController.text,
);
} else {
await ref.read(sieveRepositoryProvider).saveScript(
widget.accountId,
id: widget.script?.id,
name: name,
content: _contentController.text,
);
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
+110 -37
View File
@@ -8,10 +8,17 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/di.dart';
class SieveScriptsScreen extends ConsumerStatefulWidget {
const SieveScriptsScreen({super.key, required this.accountId});
const SieveScriptsScreen({
super.key,
required this.accountId,
this.isLocal = false,
});
final String accountId;
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
final bool isLocal;
@override
ConsumerState<SieveScriptsScreen> createState() => _SieveScriptsScreenState();
}
@@ -21,6 +28,10 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
String? _error;
bool _loading = true;
String get _editRoute => widget.isLocal
? '/accounts/${widget.accountId}/sieve/local/edit'
: '/accounts/${widget.accountId}/sieve/edit';
@override
void initState() {
super.initState();
@@ -33,8 +44,13 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
_error = null;
});
try {
final scripts =
await ref.read(sieveRepositoryProvider).listScripts(widget.accountId);
final scripts = widget.isLocal
? await ref
.read(localSieveRepositoryProvider)
.listScripts(widget.accountId)
: await ref
.read(sieveRepositoryProvider)
.listScripts(widget.accountId);
if (mounted) {
setState(() {
_scripts = scripts;
@@ -53,15 +69,19 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
Future<void> _activate(SieveScript script) async {
try {
await ref
.read(sieveRepositoryProvider)
.activateScript(widget.accountId, script.id);
if (widget.isLocal) {
await ref
.read(localSieveRepositoryProvider)
.activateScript(widget.accountId, script.id);
} else {
await ref
.read(sieveRepositoryProvider)
.activateScript(widget.accountId, script.id);
}
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Failed to activate: $e'),
@@ -91,15 +111,19 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
);
if (!(confirmed ?? false) || !mounted) return;
try {
await ref
.read(sieveRepositoryProvider)
.deleteScript(widget.accountId, script.id);
if (widget.isLocal) {
await ref
.read(localSieveRepositoryProvider)
.deleteScript(widget.accountId, script.id);
} else {
await ref
.read(sieveRepositoryProvider)
.deleteScript(widget.accountId, script.id);
}
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Failed to delete: $e'),
@@ -112,11 +136,15 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Email filters')),
appBar: AppBar(
title: Text(
widget.isLocal ? 'Local email filters' : 'Server email filters',
),
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await context.push('/accounts/${widget.accountId}/sieve/edit');
await context.push(_editRoute);
await _load();
},
child: const Icon(Icons.add),
@@ -144,22 +172,68 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
);
}
final scripts = _scripts ?? [];
if (scripts.isEmpty) {
return const Center(
child: Text('No Sieve scripts. Tap + to create one.'),
);
}
return RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: scripts.length,
itemBuilder: (ctx, i) => _ScriptTile(
script: scripts[i],
accountId: widget.accountId,
onActivate: () => _activate(scripts[i]),
onDelete: () => _delete(scripts[i]),
onEdited: _load,
return Column(
children: [
_SieveSourceBanner(isLocal: widget.isLocal),
Expanded(
child: scripts.isEmpty
? const Center(
child: Text('No Sieve scripts. Tap + to create one.'),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: scripts.length,
itemBuilder: (ctx, i) => _ScriptTile(
script: scripts[i],
accountId: widget.accountId,
editRoute: _editRoute,
onActivate: () => _activate(scripts[i]),
onDelete: () => _delete(scripts[i]),
onEdited: _load,
),
),
),
),
],
);
}
}
class _SieveSourceBanner extends StatelessWidget {
const _SieveSourceBanner({required this.isLocal});
final bool isLocal;
@override
Widget build(BuildContext context) {
final text = isLocal
? 'These scripts run locally on this device. '
'Server email filters are separate and independent.'
: 'These scripts run on the mail server (ManageSieve / JMAP). '
'Local email filters are separate and independent.';
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isLocal ? Icons.phone_android : Icons.dns,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
@@ -169,6 +243,7 @@ class _ScriptTile extends StatelessWidget {
const _ScriptTile({
required this.script,
required this.accountId,
required this.editRoute,
required this.onActivate,
required this.onDelete,
required this.onEdited,
@@ -176,6 +251,7 @@ class _ScriptTile extends StatelessWidget {
final SieveScript script;
final String accountId;
final String editRoute;
final VoidCallback onActivate;
final VoidCallback onDelete;
final VoidCallback onEdited;
@@ -193,10 +269,7 @@ class _ScriptTile extends StatelessWidget {
onSelected: (action) async {
switch (action) {
case _ScriptAction.edit:
await context.push(
'/accounts/$accountId/sieve/edit',
extra: script,
);
await context.push(editRoute, extra: script);
onEdited();
case _ScriptAction.activate:
onActivate();
@@ -219,7 +292,7 @@ class _ScriptTile extends StatelessWidget {
],
),
onTap: () async {
await context.push('/accounts/$accountId/sieve/edit', extra: script);
await context.push(editRoute, extra: script);
onEdited();
},
);
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'db_test_helper.dart';
void main() {
configureSqliteForTests();
group('LocalSieveRepository', () {
test('listScripts returns empty list for new account', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
final scripts = await repo.listScripts('acc-1');
expect(scripts, isEmpty);
await db.close();
});
test('saveScript creates and retrieves a script', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
final saved = await repo.saveScript(
'acc-1',
name: 'My filter',
content: 'require ["fileinto"]; fileinto "Archive";',
);
expect(saved.name, 'My filter');
expect(saved.isActive, false);
final scripts = await repo.listScripts('acc-1');
expect(scripts, hasLength(1));
expect(scripts.first.name, 'My filter');
expect(scripts.first.isActive, false);
await db.close();
});
test('getScriptContent returns saved content', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
const content = 'require ["fileinto"]; fileinto "Archive";';
final saved = await repo.saveScript(
'acc-1',
name: 'Test',
content: content,
);
final loaded = await repo.getScriptContent('acc-1', saved.blobId);
expect(loaded, content);
await db.close();
});
test('saveScript with id updates existing script', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
final created = await repo.saveScript(
'acc-1',
name: 'Old name',
content: 'stop;',
);
await repo.saveScript(
'acc-1',
id: created.id,
name: 'New name',
content: 'keep;',
);
final scripts = await repo.listScripts('acc-1');
expect(scripts, hasLength(1));
expect(scripts.first.name, 'New name');
final content = await repo.getScriptContent('acc-1', created.blobId);
expect(content, 'keep;');
await db.close();
});
test('deleteScript removes the script', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
final saved = await repo.saveScript(
'acc-1',
name: 'To delete',
content: 'stop;',
);
await repo.deleteScript('acc-1', saved.id);
final scripts = await repo.listScripts('acc-1');
expect(scripts, isEmpty);
await db.close();
});
test('activateScript sets one script active, deactivates others', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
final s1 = await repo.saveScript(
'acc-1',
name: 'Script 1',
content: 'stop;',
);
final s2 = await repo.saveScript(
'acc-1',
name: 'Script 2',
content: 'keep;',
);
await repo.activateScript('acc-1', s1.id);
var scripts = await repo.listScripts('acc-1');
expect(scripts.firstWhere((s) => s.id == s1.id).isActive, true);
expect(scripts.firstWhere((s) => s.id == s2.id).isActive, false);
await repo.activateScript('acc-1', s2.id);
scripts = await repo.listScripts('acc-1');
expect(scripts.firstWhere((s) => s.id == s1.id).isActive, false);
expect(scripts.firstWhere((s) => s.id == s2.id).isActive, true);
await db.close();
});
test('scripts are isolated per account', () async {
final db = openTestDatabase();
final repo = LocalSieveRepository(db);
await repo.saveScript('acc-1', name: 'Filter A', content: 'stop;');
await repo.saveScript('acc-2', name: 'Filter B', content: 'keep;');
final acc1Scripts = await repo.listScripts('acc-1');
expect(acc1Scripts, hasLength(1));
expect(acc1Scripts.first.name, 'Filter A');
final acc2Scripts = await repo.listScripts('acc-2');
expect(acc2Scripts, hasLength(1));
expect(acc2Scripts.first.name, 'Filter B');
await db.close();
});
});
}
+9 -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, 28);
expect(db.schemaVersion, 29);
await db.close();
});
@@ -183,6 +183,9 @@ void main() {
)
.get();
// v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
@@ -335,11 +338,14 @@ void main() {
)
.get();
// v29: local_sieve_scripts table.
await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 28', () async {
test('fresh install creates all tables at schemaVersion 29', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -363,6 +369,7 @@ void main() {
'sync_health',
'undo_actions',
'search_history_entries',
'local_sieve_scripts', // v29
]),
);