Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 f6ca6a1108 ci: add macOS and Windows debug build jobs (D1)
Add build-macos and build-windows CI jobs that run flutter build
for each platform after the main check suite passes on main.

Both jobs use FVM to install the pinned Flutter version and run
codegen before building. They require platform-specific runners
labelled 'macos-latest' / 'windows-latest'; the jobs are skipped
automatically if no matching runner is registered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:55:46 +02:00
Bot of Thomas Güttler 2f1bff8922 ci: enforce ui/→data/ layer boundary (A5) (#53) 2026-05-14 11:41:34 +02:00
Bot of Thomas Güttler dd66c3834d test: golden tests for key EmailListScreen states (T5) (#52) 2026-05-14 11:33:45 +02:00
Bot of Thomas Güttler 548f4e92dc perf: cache formatted date strings in EmailListScreen (P5) (#51) 2026-05-14 11:31:19 +02:00
Bot of Thomas Güttler 5311720a7e fix: open HTML email links in external browser (S4) (#50) 2026-05-14 11:26:33 +02:00
Bot of Thomas Güttler a723380560 perf: defer HTML-to-plain conversion off the UI thread (P3) (#49) 2026-05-14 11:14:23 +02:00
Bot of Thomas Güttler 499774d1a6 feat: add 'Mark all as read' to mailbox overflow menu (U8) (#48) 2026-05-14 10:58:33 +02:00
Bot of Thomas Güttler 132b6aeb9a feat: recent searches history in SearchScreen (U3) (#47) 2026-05-14 10:51:28 +02:00
Bot of Thomas Güttler efd5a1fc17 test: AccountSyncManager integration tests without real servers (A3) (#46) 2026-05-14 10:49:29 +02:00
20 changed files with 498 additions and 15 deletions
+66
View File
@@ -40,3 +40,69 @@ jobs:
- name: Build Linux
run: nix develop --command task build-linux-release
build-macos:
name: Build macOS Debug
runs-on: macos-latest
needs: check
if: github.ref == 'refs/heads/main'
# Requires a macOS runner labelled 'macos-latest'.
# Jobs are skipped automatically when no matching runner is registered.
steps:
- uses: actions/checkout@v4
- name: Install FVM
run: dart pub global activate fvm
- name: Install Flutter via FVM
run: |
fvm install --skip-pub-get
fvm use --skip-pub-get
- name: Pub get
run: fvm flutter pub get --suppress-analytics
- name: Generate code
run: fvm flutter pub run build_runner build
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 --pretty=format:"* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s" --date=short > assets/changelog.txt
- name: Build macOS
run: fvm flutter build macos --debug --no-pub
build-windows:
name: Build Windows Debug
runs-on: windows-latest
needs: check
if: github.ref == 'refs/heads/main'
# Requires a Windows runner labelled 'windows-latest'.
# Jobs are skipped automatically when no matching runner is registered.
steps:
- uses: actions/checkout@v4
- name: Install FVM
run: dart pub global activate fvm
- name: Install Flutter via FVM
run: |
fvm install --skip-pub-get
fvm use --skip-pub-get
- name: Pub get
run: fvm flutter pub get --suppress-analytics
- name: Generate code
run: fvm flutter pub run build_runner build
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 "--pretty=format:* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s" --date=short > assets/changelog.txt
- name: Build Windows
run: fvm flutter build windows --debug --no-pub
+12 -1
View File
@@ -368,7 +368,18 @@ tasks:
check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene]
deps: [analyze, check-coverage, check-hygiene, check-layers]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
cmds:
- |
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
echo "$VIOLATIONS"
exit 1
fi
check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked
@@ -27,6 +27,7 @@ abstract class EmailRepository {
Future<EmailBody> getEmailBody(String emailId);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
Future<void> markAllAsRead(String accountId, String mailboxPath);
Future<void> moveEmail(String emailId, String destMailboxPath);
/// Deletes the email. Returns the path of the mailbox it was moved to
@@ -1520,6 +1520,63 @@ class EmailRepositoryImpl implements EmailRepository {
);
}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
final account = (await _accounts.getAccount(accountId))!;
final unread = await (_db.select(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath) &
t.isSeen.equals(false),
))
.get();
if (unread.isEmpty) return;
await _db.transaction(() async {
for (final row in unread) {
if (account.type == account_model.AccountType.jmap) {
await _enqueueChange(
accountId,
row.id,
'flag_seen',
jsonEncode({'seen': true}),
);
} else {
await _enqueueChange(
accountId,
row.id,
'flag_seen',
jsonEncode({
'uid': row.uid,
'mailboxPath': row.mailboxPath,
'seen': true,
}),
);
}
}
// Bulk mark all unread emails in this mailbox as seen.
await (_db.update(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath) &
t.isSeen.equals(false),
))
.write(const EmailsCompanion(isSeen: Value(true)));
// Update all threads in this mailbox to reflect no unread.
await (_db.update(_db.threads)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath),
))
.write(const ThreadsCompanion(hasUnread: Value(false)));
});
}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
final row = await (_db.select(
+43 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -17,6 +18,14 @@ import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
void _openLink(String? url, Map<String, String> attrs, dynamic _) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(launchUrl(uri, mode: LaunchMode.externalApplication));
}
}
class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId});
final String emailId;
@@ -60,20 +69,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Reply',
onPressed: header == null
? null
: () => _reply(context, header, body, replyAll: false),
: () {
unawaited(_reply(context, header, body, replyAll: false));
},
),
IconButton(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: header == null
? null
: () => _reply(context, header, body, replyAll: true),
: () {
unawaited(_reply(context, header, body, replyAll: true));
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed:
header == null ? null : () => _forward(context, header, body),
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
@@ -263,26 +279,31 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
String _quotedBody(Email header, EmailBody? body) {
Future<String> _quotedBody(Email header, EmailBody? body) async {
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
final from =
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
final rawText = body?.textBody;
final text = (rawText != null && rawText.isNotEmpty)
? rawText
: await compute(htmlToPlain, body?.htmlBody ?? '');
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
return '\n\n— On $date, $from wrote:\n$quoted';
}
void _reply(
Future<void> _reply(
BuildContext context,
Email header,
EmailBody? body, {
required bool replyAll,
}) {
}) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject!
: 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited(
context.push(
'/compose',
@@ -290,23 +311,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'replyToEmailId': widget.emailId,
'prefillTo': to,
'prefillSubject': subject,
'prefillBody': _quotedBody(header, body),
'prefillBody': quoted,
if (cc.isNotEmpty) 'prefillCc': cc,
},
),
);
}
void _forward(BuildContext context, Email header, EmailBody? body) {
Future<void> _forward(
BuildContext context,
Email header,
EmailBody? body,
) async {
final subject = (header.subject?.startsWith('Fwd:') ?? false)
? header.subject!
: 'Fwd: ${header.subject ?? ''}';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited(
context.push(
'/compose',
extra: {
'prefillSubject': subject,
'prefillBody': _quotedBody(header, body),
'prefillBody': quoted,
},
),
);
@@ -534,7 +561,11 @@ class _SafeHtmlState extends State<_SafeHtml> {
(_) => ErrorWidget.builder = prev,
);
return Html(data: widget.data, extensions: widget.extensions);
return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
}
}
+25 -1
View File
@@ -15,6 +15,14 @@ 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);
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
@@ -193,6 +201,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
extra: {'accountId': widget.accountId},
),
),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'mark_all_read') {
await emailRepo.markAllAsRead(
widget.accountId,
widget.mailboxPath,
);
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'mark_all_read',
child: Text('Mark all as read'),
),
],
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
@@ -625,7 +649,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_dateFmt.format(t.latestDate),
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
+13
View File
@@ -10,6 +10,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
@@ -168,6 +169,18 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
extensions: [
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
],
onLinkTap: (url, _, __) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(
launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
}
},
),
] else
SelectableText(
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
@@ -10,8 +12,16 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
Future<imap.ImapClient> _fakeImapConnect(
Account account,
String username,
String password,
) async =>
throw const SocketException('fake — no real IMAP server in tests');
void main() {
test('AccountSyncManager schedules sync for multiple accounts', () async {
test('AccountSyncManager schedules IMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
@@ -22,6 +32,7 @@ void main() {
mailboxes,
emails,
syncLog: logs,
imapConnect: _fakeImapConnect,
);
final a1 = _account('1');
@@ -38,6 +49,34 @@ void main() {
manager.dispose();
});
test('AccountSyncManager schedules JMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager(
accounts,
mailboxes,
emails,
syncLog: logs,
);
final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2');
manager.start();
accounts.push([a1, a2]);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose();
});
}
Account _account(String id) => Account(
@@ -52,6 +91,17 @@ Account _account(String id) => Account(
smtpSsl: false,
);
Account _jmapAccount(String id) => Account(
id: id,
displayName: 'Account $id',
email: '$id@example.com',
type: AccountType.jmap,
jmapUrl: 'http://localhost:8080/.well-known/jmap',
smtpHost: 'localhost',
smtpPort: 25,
smtpSsl: false,
);
class _FakeAccounts implements AccountRepository {
_FakeAccounts(this.password);
final String password;
@@ -140,6 +190,9 @@ class _FakeEmails implements EmailRepository {
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
+2
View File
@@ -61,6 +61,8 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@override
@@ -337,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#markAllAsRead,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> moveEmail(
String? emailId,
@@ -126,6 +126,35 @@ abstract class EmailRepositoryContract {
expect(email!.isFlagged, isTrue);
});
test('markAllAsRead marks every unread email in the mailbox', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:20',
mailboxPath: 'INBOX',
isSeen: false,
);
await insertEmail(
repo,
id: 'er-acc:21',
mailboxPath: 'INBOX',
isSeen: false,
);
await insertEmail(
repo,
id: 'er-acc:22',
mailboxPath: 'Sent',
isSeen: false,
);
await repo.markAllAsRead(_account.id, 'INBOX');
expect((await repo.getEmail('er-acc:20'))!.isSeen, isTrue);
expect((await repo.getEmail('er-acc:21'))!.isSeen, isTrue);
// Email in a different mailbox should be untouched.
expect((await repo.getEmail('er-acc:22'))!.isSeen, isFalse);
});
test('observeThreads starts empty', () async {
final repo = await makeRepo();
expect(
+2
View File
@@ -103,6 +103,8 @@ class _CountingEmails implements EmailRepository {
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@override
Future<String?> deleteEmail(String id) async => null;
+17
View File
@@ -197,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#markAllAsRead,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> moveEmail(
String? emailId,
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fixed-date emails so golden files don't change day to day.
final _kDate = DateTime(2024, 6);
Email _email({
String id = 'acc-1:1',
String subject = 'Hello world',
bool isSeen = true,
bool isFlagged = false,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
List<Override> _overrides({
List<Email> emails = const [],
List<Email> searchResults = const [],
String? syncError,
}) =>
[
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: emails, searchResults: searchResults),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith(
(ref, _) => Stream.value(syncError),
),
];
void main() {
group('EmailListScreen goldens', () {
testWidgets('golden: empty state', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_empty.png'),
);
});
testWidgets('golden: list with emails', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review', isFlagged: true),
_email(id: 'acc-1:3', subject: 'Welcome to the project'),
],
),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_with_emails.png'),
);
});
testWidgets('golden: selection mode', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review'),
],
),
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Team standup notes'));
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_selection.png'),
);
});
testWidgets('golden: search with results', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
searchResults: [
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'project');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_search_results.png'),
);
});
testWidgets('golden: error banner', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(syncError: 'Connection refused'),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_error_banner.png'),
);
});
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+2
View File
@@ -214,6 +214,8 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {}