Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 3519be1151 feat: add chaos monkey backend test running daily in CI (#448)
Introduces a headless chaos monkey test that drives the email repository
through 30 rounds of random IMAP/SMTP operations against a live Stalwart
instance to surface crashes and data-corruption bugs.

- test/backend/chaos_monkey_test.dart: random sync, send, flag, delete,
  and flush operations; seed logged for reproducibility (CHAOS_SEED env)
- ci/main.go: ChaosMonkeyBackend Dagger function (same pattern as TestBackend)
- Taskfile.yml: chaos-monkey-backend task
- .forgejo/workflows/chaos-monkey.yml: daily cron at 03:00 UTC + workflow_dispatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:40:07 +02:00
Bot of Thomas Güttler 8446b05601 feat: add per-email notes stored on IMAP/JMAP server (#443) 2026-06-05 19:31:35 +02:00
Bot of Thomas Güttler bcece9f0af refactor: unify mail display with shared ThreadTile widget (#445) 2026-06-05 19:06:29 +02:00
Bot of Thomas Güttler 3bd404f0cf feat: add 'Create new folder' option to Move To Folder dialog (#423) 2026-06-05 18:53:36 +02:00
Bot of Thomas Güttler 9ca7089c50 fix: enforce non-root execution in Taskfile and shell scripts (#433) 2026-06-05 18:41:36 +02:00
Bot of Thomas Güttler adef2e9f80 feat: unify thread list views via shared EmailThreadTile widget (#431) 2026-06-05 18:11:28 +02:00
Bot of Thomas Güttler 2788a43dda feat: dedicated page for allowed image-sender addresses (#420) 2026-06-05 17:53:48 +02:00
37 changed files with 1834 additions and 381 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Chaos Monkey
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
chaos-monkey-backend:
name: Chaos Monkey (backend)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run backend chaos monkey
run: task chaos-monkey-backend
+7
View File
@@ -37,6 +37,8 @@ tasks:
run: once
deps: [_nix-check]
preconditions:
- sh: '[ "$(id -u)" != "0" ]'
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
- sh: test -n "${IN_NIX_SHELL}"
msg: "Not in nix dev shell. Run: nix develop"
cmds:
@@ -720,6 +722,11 @@ tasks:
cmds:
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
chaos-monkey-backend:
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test]
+10
View File
@@ -565,6 +565,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
Stdout(ctx)
}
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
// Check runs the full check suite.
func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load .env into environment
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 38;
const int dbSchemaVersion = 39;
+16
View File
@@ -192,6 +192,22 @@ class EmailThread {
required this.accountId,
required this.mailboxPath,
});
/// Wraps a single [Email] as a one-message thread for uniform rendering.
factory EmailThread.fromEmail(Email e) => EmailThread(
threadId: e.threadId ?? e.id,
subject: e.subject,
participants: e.from,
latestDate: e.sentAt ?? e.receivedAt,
messageCount: 1,
hasUnread: !e.isSeen,
isFlagged: e.isFlagged,
latestEmailId: e.id,
preview: e.preview,
emailIds: [e.id],
accountId: e.accountId,
mailboxPath: e.mailboxPath,
);
}
class EmailAddress {
+17
View File
@@ -0,0 +1,17 @@
class EmailNote {
final String id; // UUID (X-SharedInbox-Note-Id)
final String accountId;
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
final String noteText;
final String serverId; // IMAP UID (as string) or JMAP email ID
final DateTime createdAt;
const EmailNote({
required this.id,
required this.accountId,
required this.messageId,
required this.noteText,
required this.serverId,
required this.createdAt,
});
}
@@ -20,4 +20,8 @@ abstract class MailboxRepository {
String name,
String role,
);
/// Creates a new mailbox named [name] for [accountId] without a special role.
/// Returns the newly created [Mailbox].
Future<Mailbox> createMailbox(String accountId, String name);
}
@@ -0,0 +1,15 @@
import 'package:sharedinbox/core/models/note.dart';
abstract class NoteRepository {
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
/// Fetches notes from the server into the local cache.
Future<void> syncNotes(String accountId, String messageId);
/// Creates a new note on the server and caches it locally.
Future<void> addNote(String accountId, String messageId, String text);
/// Deletes a note from the server and removes it from the local cache.
Future<void> deleteNote(String noteId);
}
+24
View File
@@ -318,6 +318,26 @@ class ImageTrustedSenders extends Table {
Set<Column> get primaryKey => {senderEmail};
}
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
// Added in schema v39.
@DataClassName('EmailNoteRow')
class EmailNotes extends Table {
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
TextColumn get id => text()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
TextColumn get messageId => text()();
TextColumn get noteText => text()();
// IMAP UID (as string) or JMAP email ID of the note message on the server.
TextColumn get serverId => text()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
@@ -363,6 +383,7 @@ class UserPreferences extends Table {
ShareKeys,
UserPreferences,
ImageTrustedSenders,
EmailNotes,
],
)
class AppDatabase extends _$AppDatabase {
@@ -639,6 +660,9 @@ class AppDatabase extends _$AppDatabase {
userPreferences.bodyCacheLimitMb,
);
}
if (from < 39) {
await m.createTable(emailNotes);
}
},
);
}
@@ -343,11 +343,23 @@ class MailboxRepositoryImpl implements MailboxRepository {
}
}
@override
Future<model.Mailbox> createMailbox(String accountId, String name) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, null);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, null);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String role,
String? role,
) async {
final client = await _imapConnect(
account,
@@ -380,7 +392,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
account_model.Account account,
String password,
String name,
String role,
String? role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
@@ -398,7 +410,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
'new-mailbox': {
'name': name,
if (role != null) 'role': role,
},
},
},
'0',
@@ -0,0 +1,570 @@
import 'dart:math' as math;
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/jmap_client.dart';
const _notesFolder = 'Notes';
const _headerNoteFor = 'X-SharedInbox-Note-For';
const _headerNoteId = 'X-SharedInbox-Note-Id';
class NoteRepositoryImpl implements NoteRepository {
NoteRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn imapConnect = connectImap,
http.Client? httpClient,
}) : _imapConnect = imapConnect,
_httpClient = httpClient ?? http.Client();
final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn _imapConnect;
final http.Client _httpClient;
String _effectiveUsername(account_model.Account account) =>
account.username.isNotEmpty ? account.username : account.email;
// ── Observe (local cache) ─────────────────────────────────────────────────
@override
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
return (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(accountId) & t.messageId.equals(messageId),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
// ── Sync (server → local cache) ──────────────────────────────────────────
@override
Future<void> syncNotes(String accountId, String messageId) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
await _syncNotesImap(account, password, messageId);
case account_model.AccountType.jmap:
await _syncNotesJmap(account, password, messageId);
}
}
Future<void> _syncNotesImap(
account_model.Account account,
String password,
String messageId,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
} catch (_) {
// Notes folder doesn't exist — nothing to sync.
return;
}
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
final searchResult = await client.uidSearchMessages(
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
);
final uids = searchResult.matchingSequence?.toList() ?? [];
if (uids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
final fetchedIds = <String>{};
for (final msg in fetch.messages) {
final uid = msg.uid;
if (uid == null) continue;
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
if (noteId == null || noteId.isEmpty) continue;
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: msg.decodeTextPlainPart() ?? '',
serverId: uid.toString(),
createdAt: msg.decodeDate() ?? DateTime.now(),
),
);
}
// Remove stale local notes (deleted on the server).
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
} finally {
await client.logout();
}
}
Future<void> _syncNotesJmap(
account_model.Account account,
String password,
String messageId,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findNotesMailboxJmap(jmap);
if (mailboxId == null) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final queryResp = await jmap.call([
[
'Email/query',
{
'accountId': jmap.accountId,
'filter': {'inMailbox': mailboxId},
},
'0',
],
]);
final ids = List<String>.from(
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
);
if (ids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final getResp = await jmap.call([
[
'Email/get',
{
'accountId': jmap.accountId,
'ids': ids,
'properties': [
'id',
'receivedAt',
'textBody',
'bodyValues',
'header:$_headerNoteFor:asText',
'header:$_headerNoteId:asText',
],
'fetchTextBodyValues': true,
},
'0',
],
]);
final list =
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
final fetchedIds = <String>{};
for (final e in list) {
final m = e as Map<String, dynamic>;
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
if (noteFor != messageId) continue;
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
if (noteId == null || noteId.isEmpty) continue;
final jmapEmailId = m['id'] as String;
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
var noteText = '';
if (textBodyParts.isNotEmpty) {
final partId =
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
if (partId != null) {
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
as String? ??
'';
}
}
final createdAt =
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: noteText,
serverId: jmapEmailId,
createdAt: createdAt,
),
);
}
// Remove stale local notes.
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) & t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
}
// ── Add ───────────────────────────────────────────────────────────────────
@override
Future<void> addNote(
String accountId,
String messageId,
String text,
) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
final noteId = _generateId();
switch (account.type) {
case account_model.AccountType.imap:
await _addNoteImap(account, password, messageId, noteId, text);
case account_model.AccountType.jmap:
await _addNoteJmap(account, password, messageId, noteId, text);
}
}
Future<void> _addNoteImap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.createMailbox(_notesFolder);
} catch (_) {
// Already exists.
}
final builder = imap.MessageBuilder()
..subject = 'Note'
..text = text;
builder.addHeader(_headerNoteFor, messageId);
builder.addHeader(_headerNoteId, noteId);
final mime = builder.buildMimeMessage();
final appendResult = await client.appendMessage(
mime,
targetMailboxPath: _notesFolder,
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final serverId = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: serverId,
createdAt: DateTime.now(),
),
);
} finally {
await client.logout();
}
}
Future<void> _addNoteJmap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
const bodyPartId = '1';
final setResp = await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'create': {
'new-note': {
'mailboxIds': {mailboxId: true},
'subject': 'Note',
'keywords': {r'$seen': true},
'headers': [
{'name': _headerNoteFor, 'value': ' $messageId'},
{'name': _headerNoteId, 'value': ' $noteId'},
],
'bodyValues': {
bodyPartId: {
'value': text,
'isEncodingProblem': false,
'isTruncated': false,
},
},
'textBody': [
{'partId': bodyPartId, 'type': 'text/plain'},
],
},
},
},
'0',
],
]);
final result = _responseArgs(setResp, 0, 'Email/set');
final created = result['created'] as Map<String, dynamic>?;
final newEmail = created?['new-note'] as Map<String, dynamic>?;
final jmapEmailId = newEmail?['id'] as String? ?? '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: jmapEmailId,
createdAt: DateTime.now(),
),
);
}
// ── Delete ────────────────────────────────────────────────────────────────
@override
Future<void> deleteNote(String noteId) async {
final noteRow = await (_db.select(_db.emailNotes)
..where((t) => t.id.equals(noteId)))
.getSingleOrNull();
if (noteRow == null) return;
final account = await _accounts.getAccount(noteRow.accountId);
if (account == null) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
.go();
return;
}
final password = await _accounts.getPassword(account.id);
switch (account.type) {
case account_model.AccountType.imap:
await _deleteNoteImap(account, password, noteRow);
case account_model.AccountType.jmap:
await _deleteNoteJmap(account, password, noteRow);
}
}
Future<void> _deleteNoteImap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
final uid = int.tryParse(noteRow.serverId);
if (uid != null) {
final seq = imap.MessageSequence.fromId(uid, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
}
} catch (_) {
// Notes folder gone or message already deleted — clean up locally.
}
} finally {
await client.logout();
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
Future<void> _deleteNoteJmap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
if (noteRow.serverId.isNotEmpty) {
await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'destroy': [noteRow.serverId],
},
'0',
],
]);
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
// ── JMAP helpers ──────────────────────────────────────────────────────────
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
final resp = await jmap.call([
[
'Mailbox/get',
{'accountId': jmap.accountId, 'ids': null},
'0',
],
]);
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
for (final m in list) {
final map = m as Map<String, dynamic>;
if (map['name'] == _notesFolder) return map['id'] as String?;
}
return null;
}
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
final existing = await _findNotesMailboxJmap(jmap);
if (existing != null) return existing;
final resp = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-notes': {'name': _notesFolder},
},
},
'0',
],
]);
final result = _responseArgs(resp, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
return newMailbox?['id'] as String? ?? _notesFolder;
}
Map<String, dynamic> _responseArgs(
List<dynamic> responses,
int index,
String expectedMethod,
) {
final triple = responses[index] as List<dynamic>;
final method = triple[0] as String;
if (method == 'error') {
final err = triple[1] as Map<String, dynamic>;
throw JmapException('$expectedMethod error: ${err['type']}');
}
return triple[1] as Map<String, dynamic>;
}
EmailNote _toModel(EmailNoteRow row) => EmailNote(
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
noteText: row.noteText,
serverId: row.serverId,
createdAt: row.createdAt,
);
// Generates a random UUID v4.
static String _generateId() {
final rng = math.Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
'-${hex.substring(20)}';
}
}
+18
View File
@@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
@@ -32,6 +34,7 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
@@ -282,3 +285,18 @@ final trustedImageSendersProvider =
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
return NoteRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
});
/// Stream of notes for a specific email, identified by (accountId, messageId).
final notesProvider =
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
(ref, params) =>
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
);
+7
View File
@@ -21,6 +21,7 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
@@ -67,6 +68,12 @@ final router = GoRouter(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: 'trusted-senders',
builder: (ctx, state) => TrustedImageSendersScreen(
highlightedSender: state.extra as String?,
),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
+171 -142
View File
@@ -3,20 +3,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@@ -30,6 +22,31 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
List<EmailThread> _currentThreads = [];
bool get _selecting => _selectedThreadIds.isNotEmpty;
void _toggleThreadSelection(EmailThread thread) {
setState(() {
if (_selectedThreadIds.contains(thread.threadId)) {
_selectedThreadIds.remove(thread.threadId);
} else {
_selectedThreadIds.add(thread.threadId);
}
});
}
void _clearSelection() => setState(() => _selectedThreadIds.clear());
void _selectAll() {
setState(
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
);
}
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
@@ -58,18 +75,38 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _buildDrawer(context, accounts),
drawer: _selecting ? null : _buildDrawer(context, accounts),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: _buildBody(accountNames, showAccount),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
floatingActionButton: _selecting
? null
: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
if (_selecting) {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text('${_selectedThreadIds.length} selected'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: 'Select all',
onPressed: _selectAll,
),
],
);
}
return AppBar(
title: const Text('Combined Inbox'),
actions: [
@@ -91,6 +128,26 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: _batchArchive,
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: _batchDelete,
),
],
),
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
@@ -176,6 +233,7 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
_currentThreads = threads;
if (threads.isEmpty) {
return ListView(
children: const [
@@ -207,119 +265,33 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
child: const Text('Load more'),
);
}
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
final t = threads[i];
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
showAccount: showAccount,
accountName: accountNames[t.accountId],
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
},
);
}
Widget _buildThreadTile(
BuildContext ctx,
EmailThread t,
Map<String, String> accountNames,
bool showAccount,
) {
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
if (showAccount)
Text(
accountNames[t.accountId] ?? t.accountId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
child: tile,
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
@@ -370,24 +342,81 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
Future<void> _batchArchive() async {
final repo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
// Group selected threads by accountId so we look up each account's archive once.
final byAccount = <String, List<EmailThread>>{};
for (final t in _currentThreads) {
if (!_selectedThreadIds.contains(t.threadId)) continue;
(byAccount[t.accountId] ??= []).add(t);
}
_clearSelection();
for (final entry in byAccount.entries) {
final accountId = entry.key;
final threads = entry.value;
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
if (!mounted || archive == null) continue;
for (final t in threads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
}
}
Future<void> _batchDelete() async {
final repo = ref.read(emailRepositoryProvider);
final selectedThreads = _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.toList();
_clearSelection();
for (final t in selectedThreads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
}
}
+180 -4
View File
@@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
@@ -37,6 +38,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
bool _notesSynced = false;
@override
Widget build(BuildContext context) {
@@ -50,6 +52,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
if (!_notesSynced && email?.messageId != null) {
_notesSynced = true;
unawaited(
ref.read(noteRepositoryProvider).syncNotes(
email!.accountId,
email.messageId!,
),
);
}
},
);
@@ -229,11 +240,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
@@ -254,6 +268,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body.textBody ?? '',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (header?.messageId != null) _buildNotesSection(ctx, header!),
if (body.attachments.isNotEmpty) ...[
const Divider(),
Padding(
@@ -337,6 +352,114 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}
}
Widget _buildNotesSection(BuildContext ctx, Email header) {
final messageId = header.messageId!;
final notes = ref.watch(notesProvider((header.accountId, messageId)));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'Notes',
style: Theme.of(ctx).textTheme.titleSmall,
),
),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('Add'),
onPressed: () => unawaited(_addNoteDialog(ctx, header)),
),
],
),
notes.when(
loading: () => const SizedBox.shrink(),
error: (e, _) => Text('Error loading notes: $e'),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text(
'No notes yet.',
style: TextStyle(color: Colors.grey),
),
);
}
return Column(
children: [
for (final note in list) _buildNoteRow(ctx, note),
],
);
},
),
],
);
}
Widget _buildNoteRow(BuildContext ctx, EmailNote note) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(note.noteText),
subtitle: Text(
DateFormat('MMM d, HH:mm').format(note.createdAt),
style: Theme.of(ctx).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Delete note',
onPressed: () {
unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id));
},
),
);
}
Future<void> _addNoteDialog(BuildContext context, Email header) async {
final messageId = header.messageId;
if (messageId == null) return;
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Add note'),
content: TextField(
controller: ctrl,
autofocus: true,
maxLines: 4,
decoration: const InputDecoration(hintText: 'Type a note…'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Save'),
),
],
),
);
final text = ctrl.text.trim();
ctrl.dispose();
if (confirmed != true || text.isEmpty) return;
if (!context.mounted) return;
await ref.read(noteRepositoryProvider).addNote(
header.accountId,
messageId,
text,
);
}
Widget _buildHeader(BuildContext ctx, Email email) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -560,6 +683,42 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String?> _promptNewFolderName(BuildContext context) async {
final controller = TextEditingController();
try {
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Create new folder'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'Folder name'),
textCapitalization: TextCapitalization.words,
onSubmitted: (value) {
if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim());
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.pop(ctx, name);
},
child: const Text('Create'),
),
],
),
);
} finally {
controller.dispose();
}
}
Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
@@ -573,6 +732,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return;
const createNewSentinel = '__create_new__';
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
@@ -590,13 +751,28 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('Create new folder…'),
onTap: () => Navigator.pop(ctx, createNewSentinel),
),
],
),
);
if (chosen == null || !context.mounted) return;
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
String destination = chosen;
if (chosen == createNewSentinel) {
final name = await _promptNewFolderName(context);
if (name == null || !context.mounted) return;
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
destination = mailbox.path;
}
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, destination);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -606,7 +782,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: chosen,
destinationMailboxPath: destination,
),
),
);
+69 -183
View File
@@ -12,19 +12,10 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day so DateFormat.format is called
// at most once per unique date rather than once per list item per rebuild.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
@@ -688,177 +679,93 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: _selecting
? Checkbox(
value: isSelected,
onChanged: (_) => _toggleThreadSelection(t),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
);
// For swipe actions on threads, operate on the latest email only
// (single-email threads) or the whole thread.
return Dismissible(
key: ValueKey(t.threadId),
direction:
_selecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
} else {
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
}
},
child: tile,
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
},
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
// Used for search results, which are individual emails.
Widget _buildEmailList(List<Email> emails) {
return ListView.builder(
itemCount: emails.length,
itemBuilder: (ctx, i) {
final e = emails[i];
final t = EmailThread.fromEmail(e);
final isSelected = _selectedSearchIds.contains(e.id);
return EmailTile(
email: e,
return ThreadTile(
thread: t,
selected: isSelected,
leading: SizedBox(
width: 40,
@@ -877,25 +784,4 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
},
);
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+4 -4
View File
@@ -8,7 +8,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref,
@@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'),
for (final e in r.emails)
EmailTile(
email: e,
showLocation: true,
ThreadTile(
thread: EmailThread.fromEmail(e),
locationLabel: '${e.accountId}${e.mailboxPath}',
onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}'
+5 -2
View File
@@ -217,11 +217,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'Settings',
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push('/accounts/preferences'),
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
@@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/di.dart';
class TrustedImageSendersScreen extends ConsumerWidget {
const TrustedImageSendersScreen({super.key, this.highlightedSender});
final String? highlightedSender;
@override
Widget build(BuildContext context, WidgetRef ref) {
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Allowed addresses for images')),
body: trustedSendersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading trusted senders')),
data: (senders) {
if (senders.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text(
'No addresses added yet. '
'Tap "Load remote images" in an email to add the sender.',
),
);
}
return ListView.builder(
itemCount: senders.length,
itemBuilder: (context, index) {
final sender = senders[index];
final isHighlighted = sender == highlightedSender;
return ListTile(
title: Text(
sender,
style: isHighlighted
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
);
},
);
},
),
);
}
}
+9 -32
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
@@ -14,6 +15,7 @@ class UserPreferencesScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
final trustedCount = trustedSendersAsync.value?.length ?? 0;
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
@@ -213,41 +215,16 @@ class UserPreferencesScreen extends ConsumerWidget {
const Divider(),
ListTile(
title: Text(
'Trusted image senders',
'Allowed addresses for images',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Remote images are loaded automatically for these senders.',
subtitle: Text(
trustedCount == 0
? 'No addresses added yet.'
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
),
),
...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),
);
},
),
),
],
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/accounts/trusted-senders'),
),
],
),
+171
View File
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
/// A swipeable list tile for an [EmailThread].
///
/// Handles the [Dismissible] wrapper (archive left, delete right) and
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
/// line with the account name — used in the combined-inbox view.
class EmailThreadTile extends StatelessWidget {
const EmailThreadTile({
super.key,
required this.thread,
required this.isSelected,
required this.isSelecting,
required this.onTap,
required this.onLongPress,
required this.onDismissed,
this.showAccount = false,
this.accountName,
});
final EmailThread thread;
final bool isSelected;
final bool isSelecting;
final VoidCallback onTap;
final VoidCallback onLongPress;
final Future<void> Function(DismissDirection) onDismissed;
/// When true, renders an extra subtitle line with [accountName].
final bool showAccount;
final String? accountName;
@override
Widget build(BuildContext context) {
final t = thread;
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: isSelecting
? Checkbox(
value: isSelected,
onChanged: (_) => onTap(),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (showAccount && accountName != null)
Text(
accountName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
onTap: onTap,
onLongPress: onLongPress,
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
direction:
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: onDismissed,
child: tile,
);
}
static Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
/// A list tile for an [EmailThread].
///
/// Used in inbox lists, combined inbox, and search result lists.
/// Pass a custom [leading] widget to support selection-mode checkboxes.
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
/// "accountId • mailboxPath") — useful in cross-mailbox views.
class ThreadTile extends StatelessWidget {
const ThreadTile({
super.key,
required this.thread,
required this.onTap,
this.leading,
this.selected = false,
this.onLongPress,
this.locationLabel,
});
final EmailThread thread;
final VoidCallback onTap;
final Widget? leading;
final bool selected;
final VoidCallback? onLongPress;
/// When non-null, appended as an extra subtitle line in primary colour.
final String? locationLabel;
@override
Widget build(BuildContext context) {
final senderNames = thread.participants.isEmpty
? '(unknown)'
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
return ListTile(
leading: leading ??
Icon(
thread.hasUnread ? Icons.mail : Icons.mail_outline,
color:
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (thread.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${thread.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
thread.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (thread.preview != null && thread.preview!.isNotEmpty)
Text(
thread.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (locationLabel != null)
Text(
locationLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (thread.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(thread.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
selected: selected,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
+8 -8
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: "direct main"
description:
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
version: "0.6.16"
timezone:
dependency: transitive
description:
+6
View File
@@ -23,6 +23,8 @@ const _noCode = {
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/models/note.dart',
'lib/core/repositories/note_repository.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -81,6 +83,10 @@ const _excluded = {
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart',
'lib/ui/widgets/email_thread_tile.dart',
'lib/ui/screens/trusted_image_senders_screen.dart',
'lib/data/repositories/note_repository_impl.dart',
'lib/ui/widgets/thread_tile.dart',
};
void main() {
+2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
[ "${CI:-}" = "true" ] || [ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set."
@@ -50,6 +51,7 @@ export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
rm -f ~/.ssh/dagger_key
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
+1
View File
@@ -7,6 +7,7 @@
# Run inside nix develop:
# stalwart-dev/integration_android_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
_SCRIPT_START=$(date +%s%3N)
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
+1
View File
@@ -5,6 +5,7 @@
#
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
# Timing helper: prints elapsed seconds since script start with a label.
_SCRIPT_START=$(date +%s%3N)
+1
View File
@@ -2,6 +2,7 @@
# Starts Stalwart in the background on fresh random ports, runs Flutter
# integration tests, then stops it.
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
@@ -169,6 +169,15 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
+210
View File
@@ -0,0 +1,210 @@
// Chaos monkey test — drives the email repository through random operations
// against a live Stalwart instance to surface crashes and data-corruption bugs.
//
// Run via: stalwart-dev/test.sh
//
// Environment variables:
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
import 'dart:io';
import 'dart:math';
import 'package:enough_mail/enough_mail.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' as email_model;
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:test/test.dart';
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
import '../unit/db_test_helper.dart';
String _env(String key, [String fallback = '']) =>
Platform.environment[key] ?? fallback;
Future<ImapClient> _imapConnectPlain(
Account account,
String username,
String password,
) async {
final client = ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
await client.connectToServer(account.imapHost, account.imapPort, isSecure: false);
await client.login(username, password);
return client;
}
Future<SmtpClient> _smtpConnectPlain(
Account account,
String username,
String password,
) async {
final atIndex = account.email.lastIndexOf('@');
final domain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
final client = SmtpClient(domain);
await client.connectToServer(account.smtpHost, account.smtpPort, isSecure: false);
await client.ehlo();
await client.authenticate(username, password);
return client;
}
Future<void> _clearMailbox(
Account account,
String userEmail,
String userPass,
String mailboxPath,
) async {
final client = await _imapConnectPlain(account, userEmail, userPass);
try {
final box = await client.selectMailboxByPath(mailboxPath);
if (box.messagesExists == 0) return;
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return;
final seq = MessageSequence.fromIds(uids, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
} finally {
await client.logout();
}
}
void main() {
late String imapHost;
late int imapPort;
late String smtpHost;
late int smtpPort;
late String userEmail;
late String userPass;
late Account account;
late AppDatabase db;
late EmailRepositoryImpl emails;
setUpAll(configureSqliteForTests);
setUp(() async {
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
userEmail = _env('STALWART_USER_B', 'alice@example.com');
userPass = _env('STALWART_PASS_B', 'secret');
account = Account(
id: 'chaos',
displayName: 'Chaos',
email: userEmail,
imapHost: imapHost,
imapPort: imapPort,
imapSsl: false,
smtpHost: smtpHost,
smtpPort: smtpPort,
);
db = openTestDatabase();
final secureStorage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, secureStorage);
await accounts.addAccount(account, userPass);
emails = EmailRepositoryImpl(
db,
accounts,
imapConnect: _imapConnectPlain,
smtpConnect: _smtpConnectPlain,
);
await _clearMailbox(account, userEmail, userPass, 'INBOX');
});
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository', () async {
final seedStr = _env('CHAOS_SEED', '');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
: int.parse(seedStr);
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
final rng = Random(seed);
print('chaos-monkey: seed=$seed rounds=$rounds');
// Seed INBOX with a few messages so early rounds have something to act on.
for (var i = 0; i < 3; i++) {
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: 'seed-$i',
body: 'Seed email $i.',
),
);
}
await emails.syncEmails(account.id, 'INBOX');
for (var round = 0; round < rounds; round++) {
final action = rng.nextInt(8);
print('chaos-monkey: round=$round action=$action');
switch (action) {
case 0: // sync INBOX
await emails.syncEmails(account.id, 'INBOX');
case 1: // sync Sent
await emails.syncEmails(account.id, 'Sent');
case 2: // send email to self
final subject = 'chaos-$round-${rng.nextInt(9999)}';
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: subject,
body: 'Round $round. Value: ${rng.nextInt(1000000)}.',
),
);
case 3: // mark random email seen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: true);
case 4: // mark random email unseen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: false);
case 5: // toggle flagged on random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, flagged: !e.isFlagged);
case 6: // flush pending changes to server
final flushed = await emails.flushPendingChanges(account.id, userPass);
print('chaos-monkey: flushed $flushed pending changes');
case 7: // delete random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.deleteEmail(e.id);
}
}
// Final flush and sync to confirm the server is in a consistent state.
final flushed = await emails.flushPendingChanges(account.id, userPass);
print('chaos-monkey: final flush flushed=$flushed');
final result = await emails.syncEmails(account.id, 'INBOX');
print('chaos-monkey: final sync fetched=${result.fetched}');
});
}
+9
View File
@@ -239,6 +239,15 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
@@ -235,6 +235,31 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
),
)),
) as _i5.Future<_i2.Mailbox>);
@override
_i5.Future<_i2.Mailbox> createMailbox(
String? accountId,
String? name,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
}
/// A class which mocks [EmailRepository].
+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, 38);
expect(db.schemaVersion, 39);
await db.close();
});
@@ -424,12 +424,15 @@ void main() {
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 38', () async {
test('fresh install creates all tables at schemaVersion 39', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -458,6 +461,7 @@ void main() {
'local_sieve_applied', // v32
'user_preferences', // v34
'image_trusted_senders', // v37
'email_notes', // v39
]),
);
@@ -493,6 +497,9 @@ void main() {
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
await db.close();
});
});
@@ -77,6 +77,15 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
+9
View File
@@ -67,6 +67,15 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
+14
View File
@@ -192,6 +192,20 @@ class FakeMailboxRepository implements MailboxRepository {
_mailboxes.add(mailbox);
return mailbox;
}
@override
Future<Mailbox> createMailbox(String accountId, String name) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
}
class FakeEmailRepository implements EmailRepository {