Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc3aeb21d | ||
|
|
7985caa9b4 | ||
|
|
e28996cf86 | ||
|
|
d994723a2d | ||
|
|
145346c18a | ||
|
|
f3e1ca13de | ||
|
|
d86ce7766c | ||
|
|
f88d14f362 | ||
|
|
3e2da2bdf8 | ||
|
|
6a60c8d73b | ||
|
|
985bac7022 | ||
|
|
aed0d63703 | ||
|
|
8446b05601 | ||
|
|
bcece9f0af |
@@ -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
|
||||
@@ -58,6 +58,14 @@ tasks:
|
||||
cmds:
|
||||
- echo "Setup complete."
|
||||
|
||||
generate-icons:
|
||||
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||
deps: [_pub-get]
|
||||
cmds:
|
||||
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||
- fvm flutter pub run flutter_launcher_icons
|
||||
|
||||
generate-changelog:
|
||||
desc: Generate assets/changelog.txt from git history
|
||||
cmds:
|
||||
@@ -521,13 +529,6 @@ tasks:
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
@@ -542,6 +543,13 @@ tasks:
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
deploy-android:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
deps: [check, build-android]
|
||||
@@ -722,6 +730,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]
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,11 +48,28 @@
|
||||
chmod +x $out/bin/fgj
|
||||
'';
|
||||
};
|
||||
|
||||
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
||||
dagger021 = pkgs.stdenv.mkDerivation {
|
||||
pname = "dagger";
|
||||
version = "0.21.4";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
||||
};
|
||||
sourceRoot = ".";
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp dagger $out/bin/dagger
|
||||
chmod +x $out/bin/dagger
|
||||
'';
|
||||
};
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger.packages.${system}.dagger
|
||||
dagger021
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
@@ -100,12 +117,16 @@
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||
export IN_NIX_SHELL=1
|
||||
|
||||
# Point Dagger client at the running engine socket
|
||||
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
||||
|
||||
# Disable Flutter telemetry inside dev shell
|
||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 38;
|
||||
const int dbSchemaVersion = 40;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -318,6 +318,37 @@ 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};
|
||||
}
|
||||
|
||||
/// Records the first time the user ran each app version (identified by GIT_HASH).
|
||||
/// Added in schema v40.
|
||||
@DataClassName('InstalledVersionRow')
|
||||
class InstalledVersions extends Table {
|
||||
TextColumn get gitHash => text()();
|
||||
DateTimeColumn get installedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {gitHash};
|
||||
}
|
||||
|
||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||
@DataClassName('UserPreferencesRow')
|
||||
class UserPreferences extends Table {
|
||||
@@ -363,6 +394,8 @@ class UserPreferences extends Table {
|
||||
ShareKeys,
|
||||
UserPreferences,
|
||||
ImageTrustedSenders,
|
||||
EmailNotes,
|
||||
InstalledVersions,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -639,8 +672,33 @@ class AppDatabase extends _$AppDatabase {
|
||||
userPreferences.bodyCacheLimitMb,
|
||||
);
|
||||
}
|
||||
if (from < 39) {
|
||||
await m.createTable(emailNotes);
|
||||
}
|
||||
if (from < 40) {
|
||||
await m.createTable(installedVersions);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// Inserts a row for [gitHash] the first time that version is seen.
|
||||
/// Subsequent calls for the same hash are silently ignored so the original
|
||||
/// install timestamp is preserved.
|
||||
Future<void> recordInstalledVersionIfNew(String gitHash) async {
|
||||
if (gitHash.isEmpty) return;
|
||||
await into(installedVersions).insert(
|
||||
InstalledVersionsCompanion.insert(
|
||||
gitHash: gitHash,
|
||||
installedAt: DateTime.now(),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, DateTime>> loadInstalledVersions() async {
|
||||
final rows = await select(installedVersions).get();
|
||||
return {for (final r in rows) r.gitHash: r.installedAt};
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved once in main() via initDatabasePath() before runApp().
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
}
|
||||
@@ -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,22 @@ final trustedImageSendersProvider =
|
||||
.watch(userPreferencesRepositoryProvider)
|
||||
.observeTrustedImageSenders();
|
||||
});
|
||||
|
||||
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
||||
return NoteRepositoryImpl(
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(accountRepositoryProvider),
|
||||
imapConnect: ref.watch(imapConnectProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
|
||||
return ref.watch(dbProvider).loadInstalledVersions();
|
||||
});
|
||||
|
||||
/// 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),
|
||||
);
|
||||
|
||||
@@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||
}
|
||||
|
||||
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
@override
|
||||
void initState() {
|
||||
@@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||
ref.read(syncManagerProvider).start();
|
||||
ref.read(reliabilityRunnerProvider).start();
|
||||
if (_kGitHash.isNotEmpty) {
|
||||
unawaited(
|
||||
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
@@ -22,6 +23,7 @@ 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_detail_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';
|
||||
@@ -55,6 +57,14 @@ final router = GoRouter(
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':actionId',
|
||||
builder: (ctx, state) => UndoLogDetailScreen(
|
||||
action: state.extra as UndoAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'changelog',
|
||||
|
||||
@@ -2,21 +2,79 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChangeLogScreen extends StatelessWidget {
|
||||
class ChangeLogScreen extends ConsumerWidget {
|
||||
const ChangeLogScreen({super.key});
|
||||
|
||||
static const _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
static String _formatInstallDate(DateTime dt) {
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
final month = _months[dt.month - 1];
|
||||
return '$h:$m, ${dt.day} $month ${dt.year}';
|
||||
}
|
||||
|
||||
// Changelog lines have the form:
|
||||
// * 2026-06-05 [abc1234](https://...): subject
|
||||
// This pattern captures the short hash inside the markdown link.
|
||||
static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\(');
|
||||
|
||||
static String _injectInstallMarkers(
|
||||
String changelog,
|
||||
Map<String, DateTime> versions,
|
||||
) {
|
||||
if (versions.isEmpty) return changelog;
|
||||
final lines = changelog.split('\n');
|
||||
final buf = StringBuffer();
|
||||
for (final line in lines) {
|
||||
final match = _hashPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final lineHash = match.group(1)!;
|
||||
for (final entry in versions.entries) {
|
||||
final stored = entry.key;
|
||||
final matches = stored == lineHash ||
|
||||
stored.startsWith(lineHash) ||
|
||||
lineHash.startsWith(stored);
|
||||
if (!matches) continue;
|
||||
buf.write(
|
||||
'\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
buf.writeln(line);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final installedVersions = ref.watch(installedVersionsProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString('assets/changelog.txt'),
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||
installedVersions.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
@@ -25,8 +83,10 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
final content = snapshot.data ?? 'No changelog entries found.';
|
||||
final versions = installedVersions.value ?? {};
|
||||
final annotated = _injectInstallMarkers(content, versions);
|
||||
return Markdown(
|
||||
data: content,
|
||||
data: annotated,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -257,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(
|
||||
@@ -340,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,
|
||||
|
||||
@@ -13,9 +13,9 @@ 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_thread_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||
|
||||
class EmailListScreen extends ConsumerStatefulWidget {
|
||||
const EmailListScreen({
|
||||
@@ -50,6 +50,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Pagination: number of threads currently requested from the DB.
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
// Incremented on every search start; stale completions are ignored when the
|
||||
// generation has advanced (prevents out-of-order IMAP responses from
|
||||
// overwriting fresh results with results for an older query).
|
||||
int _searchGeneration = 0;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@@ -121,14 +126,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
setState(() => _searchResults = null);
|
||||
return;
|
||||
}
|
||||
final generation = ++_searchGeneration;
|
||||
setState(() => _searchLoading = true);
|
||||
try {
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||
if (mounted) setState(() => _searchResults = results);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchResults = results);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _searchLoading = false);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,9 +772,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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,
|
||||
|
||||
@@ -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)}'
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
class UndoLogDetailScreen extends ConsumerWidget {
|
||||
const UndoLogDetailScreen({super.key, required this.action});
|
||||
|
||||
final UndoAction action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Undo Log Detail'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Undo'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
_SectionHeader(text: 'Transaction', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: const Text('Account'),
|
||||
subtitle: Text(action.accountId),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
: (action.type == UndoType.snooze
|
||||
? Icons.access_time
|
||||
: Icons.move_to_inbox),
|
||||
color: action.type == UndoType.delete
|
||||
? Colors.redAccent
|
||||
: (action.type == UndoType.snooze
|
||||
? Colors.orangeAccent
|
||||
: Colors.blueAccent),
|
||||
),
|
||||
title: const Text('Action'),
|
||||
subtitle: Text(action.type.name.toUpperCase()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: const Text('Timestamp'),
|
||||
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
|
||||
),
|
||||
_SectionHeader(text: 'Folders', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_open),
|
||||
title: const Text('Source'),
|
||||
subtitle: Text(action.sourceMailboxPath),
|
||||
),
|
||||
if (action.type == UndoType.move &&
|
||||
action.destinationMailboxPath != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.drive_file_move),
|
||||
title: const Text('Destination'),
|
||||
subtitle: Text(action.destinationMailboxPath!),
|
||||
),
|
||||
_SectionHeader(
|
||||
text: 'Emails (${action.emailIds.length})',
|
||||
theme: theme,
|
||||
),
|
||||
if (action.originalEmails.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'${action.emailIds.length} email(s) — details not available',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, required this.theme});
|
||||
|
||||
final String text;
|
||||
final ThemeData theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailTile extends StatelessWidget {
|
||||
const _EmailTile({required this.email});
|
||||
|
||||
final Email email;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sender = email.from.isNotEmpty
|
||||
? (email.from.first.name ?? email.from.first.email)
|
||||
: '(Unknown Sender)';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.email_outlined),
|
||||
title: Text(email.subject ?? '(No Subject)'),
|
||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.go(
|
||||
'/accounts/undo-log/${action.id}',
|
||||
extra: action,
|
||||
),
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
|
||||
DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
|
||||
|
||||
// Show AFTER adding FlView so GTK's first layout pass allocates the full
|
||||
// window content area (1280×800) to FlView, not the default 1×1.
|
||||
gtk_widget_show_all(GTK_WIDGET(window));
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -371,6 +371,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -562,6 +570,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
||||
@@ -81,6 +81,15 @@ dev_dependencies:
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
|
||||
flutter_icons:
|
||||
android: "ic_launcher"
|
||||
ios: false
|
||||
image_path: "icon.png"
|
||||
linux:
|
||||
generate: true
|
||||
image_path: "icon.png"
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"fileMatch": ["^\\.fvmrc$"],
|
||||
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
|
||||
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -55,6 +57,7 @@ const _excluded = {
|
||||
'lib/ui/screens/sieve_scripts_screen.dart',
|
||||
'lib/ui/screens/sync_log_screen.dart',
|
||||
'lib/ui/screens/thread_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_screen.dart',
|
||||
'lib/ui/widgets/folder_drawer.dart',
|
||||
'lib/ui/widgets/secure_email_webview.dart',
|
||||
@@ -83,6 +86,8 @@ const _excluded = {
|
||||
'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() {
|
||||
|
||||
@@ -17,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||
|
||||
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
|
||||
# must be masked line-by-line because ::add-mask:: covers one line at a time.
|
||||
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||
done <<< "$DAGGER_SSH_KEY"
|
||||
|
||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||
# can use them without referencing Forgejo secrets directly.
|
||||
export_secret() {
|
||||
local name="$1"
|
||||
local value
|
||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
||||
# Register each non-empty line for log redaction in the Actions runner.
|
||||
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||
done <<< "$value"
|
||||
fi
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
# Use heredoc syntax for multiline-safe export.
|
||||
# Avoid adding a second trailing newline for values that already end with one
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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);
|
||||
|
||||
stdout.writeln('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);
|
||||
stdout.writeln('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);
|
||||
stdout.writeln('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);
|
||||
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
|
||||
final result = await emails.syncEmails(account.id, 'INBOX');
|
||||
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
|
||||
});
|
||||
}
|
||||
@@ -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, 40);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -424,12 +424,18 @@ 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();
|
||||
|
||||
// v40: installed_versions table.
|
||||
await db.customSelect('SELECT count(*) FROM installed_versions').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 40', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -458,6 +464,8 @@ void main() {
|
||||
'local_sieve_applied', // v32
|
||||
'user_preferences', // v34
|
||||
'image_trusted_senders', // v37
|
||||
'email_notes', // v39
|
||||
'installed_versions', // v40
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -493,6 +501,12 @@ 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();
|
||||
|
||||
// v40: installed_versions table.
|
||||
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
|
||||
class _FakeAssetBundle extends CachingAssetBundle {
|
||||
@@ -19,16 +23,33 @@ class _FakeAssetBundle extends CachingAssetBundle {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScreen({
|
||||
required Map<String, String> assets,
|
||||
Map<String, DateTime> installedVersions = const {},
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWith((ref) {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
}),
|
||||
installedVersionsProvider.overrideWith((ref) async => installedVersions),
|
||||
],
|
||||
child: DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle(assets),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _fakeChangelog =
|
||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -41,14 +62,44 @@ void main() {
|
||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(_buildScreen(assets: {}));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen injects install marker for a known hash', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog =
|
||||
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||
final installedAt = DateTime(2024, 6, 15, 14, 32);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(
|
||||
assets: {'assets/changelog.txt': changelog},
|
||||
installedVersions: {'abc1234': installedAt},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Installed: 14:32'), findsOneWidget);
|
||||
expect(find.textContaining('15 Jun 2024'), findsOneWidget);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen shows no markers when no version recorded', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog =
|
||||
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Installed:'), findsNothing);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -102,30 +104,6 @@ void main() {
|
||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping search icon shows search bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submitting a search query shows "No results" when empty', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -430,6 +408,128 @@ void main() {
|
||||
expect(find.text('Result email'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping first of multiple search results opens the first email',
|
||||
(tester) async {
|
||||
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
searchResults: [email1, email2],
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Match');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alpha Match'), findsOneWidget);
|
||||
expect(find.text('Beta Match'), findsOneWidget);
|
||||
|
||||
// Tap the first result.
|
||||
await tester.tap(find.text('Alpha Match'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
||||
// The detail AppBar title shows the first email's subject.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.text('Alpha Match'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The second email's subject must not appear in the detail view.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(EmailDetailScreen),
|
||||
matching: find.text('Beta Match'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'stale search results from a slower concurrent search are discarded',
|
||||
(tester) async {
|
||||
// Reproduces: user types quickly, triggering multiple concurrent IMAP
|
||||
// searches. An older, slower search must not overwrite the results for
|
||||
// the user's current query (issue #467).
|
||||
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
|
||||
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
|
||||
|
||||
// The first search call is held open by a Completer; all subsequent
|
||||
// calls resolve immediately with freshEmail.
|
||||
final staleCompleter = Completer<List<Email>>();
|
||||
var firstCall = true;
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return staleCompleter.future;
|
||||
}
|
||||
return Future.value([freshEmail]);
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Trigger the first (slow) search.
|
||||
await tester.enterText(find.byType(TextField), 'slow');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
// Do not pumpAndSettle yet — the slow search is still in flight.
|
||||
|
||||
// Trigger the second (fast) search by changing the query.
|
||||
await tester.enterText(find.byType(TextField), 'fast');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle(); // fast searches settle immediately
|
||||
|
||||
// The fresh results must be shown.
|
||||
expect(find.text('Fresh Result'), findsOneWidget);
|
||||
expect(find.text('Stale Result'), findsNothing);
|
||||
|
||||
// Now let the stale search complete.
|
||||
staleCompleter.complete([staleEmail]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The stale results must NOT replace the fresh ones.
|
||||
expect(find.text('Fresh Result'), findsOneWidget);
|
||||
expect(find.text('Stale Result'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('deleting all search results pops back to previous screen', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -216,12 +216,17 @@ class FakeEmailRepository implements EmailRepository {
|
||||
|
||||
final List<Email> _searchResults;
|
||||
|
||||
/// Optional override: when set, [searchEmails] calls this instead of
|
||||
/// returning [_searchResults]. Useful for testing race-condition fixes.
|
||||
final Future<List<Email>> Function(String query)? onSearch;
|
||||
|
||||
FakeEmailRepository({
|
||||
List<Email>? emails,
|
||||
Email? emailDetail,
|
||||
EmailBody? emailBody,
|
||||
List<Email>? searchResults,
|
||||
String rawRfc822 = '',
|
||||
this.onSearch,
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
@@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
Future<Email?> getEmail(String emailId) async {
|
||||
for (final e in _searchResults) {
|
||||
if (e.id == emailId) return e;
|
||||
}
|
||||
for (final e in _emails) {
|
||||
if (e.id == emailId) return e;
|
||||
}
|
||||
return _emailDetail;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||
@@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async =>
|
||||
_searchResults;
|
||||
) async {
|
||||
if (onSearch != null) return onSearch!(query);
|
||||
return _searchResults;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
@@ -565,6 +580,7 @@ Widget buildApp({
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -572,6 +588,7 @@ Widget buildApp({
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||