From 72e2b599bf34aa9e0482df4bdf3473f36f4c8948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Thu, 16 Apr 2026 07:51:52 +0200 Subject: [PATCH] Fix API mismatches, add Linux desktop entry point, reply prefill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API fixes (against vendored enough_mail 2.1.7): - listMailboxes() returns List directly — remove .mailboxes - Use statusMailbox() for unread/total counts per mailbox - fetchMessages(MessageSequence.fromAll(), ...) replaces nonexistent fetchAllMessages(); fetchMessage() takes isUidSequence flag - FetchImapResult.messages are already MimeMessages — no need to re-parse rawData; use msg.decodeTextPlainPart() / decodeTextHtmlPart() - msg.hasAttachments() (method) not msg.body?.hasAttachments (field) - SmtpClient clientDomain = sender domain, not display name; quit() instead of nonexistent disconnect(); STARTTLS wrapped in try/catch - ContentInfo.size is nullable; use a.fileName / a.size getters Other fixes: - main.dart: move sync start to initState, not build() - account_list_screen: remove dead/invalid Riverpod select() code - account_sync_manager: subscribe to account changes; cancel sub on dispose; use Future.any([newMsg, 25-min timeout]) for IDLE - email_repository: add getEmail(id) to interface + impl - email_detail_screen: load header + body together via Future.wait; reply prefills To/Cc/Subject correctly - compose_screen + router: thread prefillCc through Add Linux desktop entry point: - linux/CMakeLists.txt, main.cc, my_application.h/.cc (GTK3 runner) Add flake.lock (generated by nix flake update). Co-Authored-By: Claude Sonnet 4.6 --- flake.lock | 139 ++++++++++++ lib/core/repositories/email_repository.dart | 1 + lib/core/sync/account_sync_manager.dart | 75 ++++--- lib/data/imap/imap_client_factory.dart | 23 +- .../repositories/email_repository_impl.dart | 132 +++++------ .../repositories/mailbox_repository_impl.dart | 25 ++- lib/main.dart | 17 +- lib/ui/router.dart | 1 + lib/ui/screens/account_list_screen.dart | 13 +- lib/ui/screens/compose_screen.dart | 3 + lib/ui/screens/email_detail_screen.dart | 209 ++++++++++-------- linux/CMakeLists.txt | 34 +++ linux/main.cc | 6 + linux/my_application.cc | 74 +++++++ linux/my_application.h | 8 + 15 files changed, 538 insertions(+), 222 deletions(-) create mode 100644 flake.lock create mode 100644 linux/CMakeLists.txt create mode 100644 linux/main.cc create mode 100644 linux/my_application.cc create mode 100644 linux/my_application.h diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3e5bb9f --- /dev/null +++ b/flake.lock @@ -0,0 +1,139 @@ +{ + "nodes": { + "android-nixpkgs": { + "inputs": { + "devshell": "devshell", + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776286015, + "narHash": "sha256-Is4Efj9Jzdy+vo0xoS7ak5i6yYPhviYsRG9ZijzkAXE=", + "owner": "tadfisher", + "repo": "android-nixpkgs", + "rev": "d2793eb50d8fd8e9ad2937cfeafa8e87f08480d8", + "type": "github" + }, + "original": { + "owner": "tadfisher", + "ref": "stable", + "repo": "android-nixpkgs", + "type": "github" + } + }, + "devshell": { + "inputs": { + "nixpkgs": [ + "android-nixpkgs", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776067740, + "narHash": "sha256-B35lpsqnSZwn1Lmz06BpwF7atPgFmUgw1l8KAV3zpVQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7e495b747b51f95ae15e74377c5ce1fe69c1765f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "android-nixpkgs": "android-nixpkgs", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index eeca481..c02bde2 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -2,6 +2,7 @@ import '../models/email.dart'; abstract class EmailRepository { Stream> observeEmails(String accountId, String mailboxPath); + Future getEmail(String emailId); Future getEmailBody(String emailId); Future syncEmails(String accountId, String mailboxPath); Future setFlag( diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 1ce9b99..a501b21 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -9,7 +9,7 @@ import '../repositories/mailbox_repository.dart'; import '../../data/imap/imap_client_factory.dart'; /// Manages one IMAP IDLE connection per account. -/// On new message notification it triggers a sync and notifies listeners. +/// On a new-message notification it triggers a re-sync then goes back to IDLE. class AccountSyncManager { AccountSyncManager(this._accounts, this._mailboxes, this._emails); @@ -18,32 +18,32 @@ class AccountSyncManager { final EmailRepository _emails; final Map _active = {}; + StreamSubscription>? _accountsSub; - Future start() async { - _accounts.observeAccounts().listen((accounts) { - final ids = accounts.map((a) => a.id).toSet(); - // Start new + void start() { + _accountsSub = _accounts.observeAccounts().listen((accounts) { + final currentIds = accounts.map((a) => a.id).toSet(); + + // Start sync for newly added accounts. for (final account in accounts) { if (!_active.containsKey(account.id)) { - _start(account); + final sync = _AccountSync(account, _accounts, _mailboxes, _emails); + _active[account.id] = sync; + sync.start(); } } - // Stop removed + + // Stop sync for removed accounts. for (final id in _active.keys.toList()) { - if (!ids.contains(id)) { + if (!currentIds.contains(id)) { _active.remove(id)?.stop(); } } }); } - void _start(Account account) { - final sync = _AccountSync(account, _accounts, _mailboxes, _emails); - _active[account.id] = sync; - sync.start(); - } - void dispose() { + _accountsSub?.cancel(); for (final s in _active.values) { s.stop(); } @@ -97,21 +97,36 @@ class _AccountSync { final password = await _accounts.getPassword(account.id); final client = await connectImap(account, password); _idleClient = client; - await client.selectMailboxByPath('INBOX'); - final idleDone = Completer(); - await client.idleStart(); - client.eventBus - .on() - .where((e) => e is imap.ImapMessagesExistEvent) - .first - .then((_) => idleDone.complete()); - // Stop idle after 25 minutes to stay within server limits - Future.delayed(const Duration(minutes: 25)).then((_) { - if (!idleDone.isCompleted) idleDone.complete(); - }); - await idleDone.future; - await client.idleDone(); - await client.logout(); - _idleClient = null; + try { + await client.selectMailboxByPath('INBOX'); + + final newMessageCompleter = Completer(); + + // Wake up when new messages arrive or messages are expunged. + final sub = client.eventBus + .on() + .where( + (e) => + e is imap.ImapMessagesExistEvent || + e is imap.ImapExpungeEvent, + ) + .listen((_) { + if (!newMessageCompleter.isCompleted) newMessageCompleter.complete(); + }); + + await client.idleStart(); + + // Cap IDLE at 25 minutes to stay within the RFC 2177 recommendation. + await Future.any([ + newMessageCompleter.future, + Future.delayed(const Duration(minutes: 25)), + ]); + + await client.idleDone(); + await sub.cancel(); + } finally { + await client.logout(); + _idleClient = null; + } } } diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart index 2e3737d..b2ea89f 100644 --- a/lib/data/imap/imap_client_factory.dart +++ b/lib/data/imap/imap_client_factory.dart @@ -2,7 +2,7 @@ import 'package:enough_mail/enough_mail.dart'; import '../../core/models/account.dart'; -/// Opens an authenticated IMAP client for the given account. +/// Opens an authenticated IMAP client for [account]. Future connectImap(Account account, String password) async { final client = ImapClient(isLogEnabled: false); await client.connectToServer( @@ -14,9 +14,17 @@ Future connectImap(Account account, String password) async { return client; } -/// Opens an authenticated SMTP client for the given account. +/// Opens an authenticated SMTP client for [account]. +/// +/// Caller is responsible for calling [SmtpClient.quit] when done. Future connectSmtp(Account account, String password) async { - final client = SmtpClient(account.displayName, isLogEnabled: false); + // clientDomain is the sending domain advertised in EHLO — use the host part + // of the sender email, falling back to the SMTP host. + final atIndex = account.email.lastIndexOf('@'); + final clientDomain = + atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost; + + final client = SmtpClient(clientDomain, isLogEnabled: false); await client.connectToServer( account.smtpHost, account.smtpPort, @@ -24,8 +32,13 @@ Future connectSmtp(Account account, String password) async { ); await client.ehlo(); if (!account.smtpSsl) { - await client.startTls(); + // Opportunistic TLS on submission port (587) + try { + await client.startTls(); + } catch (_) { + // Server doesn't support STARTTLS — proceed without it. + } } - await client.authenticate(account.email, password, AuthMechanism.plain); + await client.authenticate(account.email, password); return client; } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index ac7861e..f8d2be6 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -4,11 +4,11 @@ import 'package:drift/drift.dart'; import 'package:enough_mail/enough_mail.dart' as imap; import '../../core/models/email.dart'; +import '../../core/repositories/account_repository.dart'; import '../../core/repositories/email_repository.dart'; import '../db/database.dart'; import '../db/database.dart' as db show Email, EmailBody; import '../imap/imap_client_factory.dart'; -import '../../core/repositories/account_repository.dart'; class EmailRepositoryImpl implements EmailRepository { EmailRepositoryImpl(this._db, this._accounts); @@ -31,6 +31,14 @@ class EmailRepositoryImpl implements EmailRepository { .map((rows) => rows.map(_toModel).toList()); } + @override + Future getEmail(String emailId) async { + final row = await (_db.select(_db.emails) + ..where((t) => t.id.equals(emailId))) + .getSingleOrNull(); + return row == null ? null : _toModel(row); + } + // ── Body (on-demand) ─────────────────────────────────────────────────────── @override @@ -38,7 +46,7 @@ class EmailRepositoryImpl implements EmailRepository { final cached = await (_db.select(_db.emailBodies) ..where((t) => t.emailId.equals(emailId))) .getSingleOrNull(); - if (cached != null) return _toBodyModel(cached); + if (cached != null) return _bodyRowToModel(cached); final emailRow = await (_db.select(_db.emails) ..where((t) => t.id.equals(emailId))) @@ -47,25 +55,21 @@ class EmailRepositoryImpl implements EmailRepository { final password = await _accounts.getPassword(account.id); final client = await connectImap(account, password); try { - await client.selectMailbox( - imap.Mailbox.fromStatus( - imap.MailboxStatus(emailRow.mailboxPath, 0, 0, 0), - ), - ); + await client.selectMailboxByPath(emailRow.mailboxPath); final fetch = await client.fetchMessage( imap.MessageSequence.fromId(emailRow.uid, isUid: true), - '(RFC822)', + '(BODY[])', + isUidSequence: true, ); final msg = fetch.messages.first; - final mime = imap.MimeMessage.parseFromData(msg.rawData!); - final textBody = mime.decodeTextPlainPart(); - final htmlBody = mime.decodeTextHtmlPart(); - final attachments = mime.findContentInfo( + final textBody = msg.decodeTextPlainPart(); + final htmlBody = msg.decodeTextHtmlPart(); + final contentInfos = msg.findContentInfo( disposition: imap.ContentDisposition.attachment, ); final attachmentsJson = jsonEncode( - attachments + contentInfos .map( (a) => { 'filename': a.fileName ?? '', @@ -84,13 +88,11 @@ class EmailRepositoryImpl implements EmailRepository { attachmentsJson: Value(attachmentsJson), ), ); - return _toBodyModel( - EmailBody( - emailId: emailId, - textBody: textBody, - htmlBody: htmlBody, - attachments: _parseAttachments(attachmentsJson), - ), + return EmailBody( + emailId: emailId, + textBody: textBody, + htmlBody: htmlBody, + attachments: _parseAttachments(attachmentsJson), ); } finally { await client.logout(); @@ -105,61 +107,33 @@ class EmailRepositoryImpl implements EmailRepository { final password = await _accounts.getPassword(accountId); final client = await connectImap(account, password); try { - final mailbox = await client.selectMailboxByPath(mailboxPath); - final sequence = imap.MessageSequence.fromAll(); - final messages = await client.fetchMessages( - sequence, + await client.selectMailboxByPath(mailboxPath); + final fetch = await client.fetchMessages( + imap.MessageSequence.fromAll(), '(UID FLAGS ENVELOPE BODYSTRUCTURE)', ); - for (final msg in messages.messages) { - final mime = msg.envelope; - if (mime == null) continue; - final emailId = '${accountId}:${msg.uid}'; + for (final msg in fetch.messages) { + final envelope = msg.envelope; + if (envelope == null) continue; + final uid = msg.uid; + if (uid == null) continue; + final emailId = '${accountId}:$uid'; + await _db.into(_db.emails).insertOnConflictUpdate( EmailsCompanion.insert( id: emailId, accountId: accountId, mailboxPath: mailboxPath, - uid: Value(msg.uid ?? 0), - subject: Value(mime.subject), - sentAt: Value(mime.date), - receivedAt: Value(mime.date ?? DateTime.now()), - fromJson: Value( - jsonEncode( - mime.from - ?.map((a) => { - 'name': a.personalName, - 'email': a.email ?? '', - }) - .toList() ?? - [], - ), - ), - toJson: Value( - jsonEncode( - mime.to - ?.map((a) => { - 'name': a.personalName, - 'email': a.email ?? '', - }) - .toList() ?? - [], - ), - ), - ccJson: Value( - jsonEncode( - mime.cc - ?.map((a) => { - 'name': a.personalName, - 'email': a.email ?? '', - }) - .toList() ?? - [], - ), - ), + uid: Value(uid), + subject: Value(envelope.subject), + sentAt: Value(envelope.date), + receivedAt: Value(envelope.date ?? DateTime.now()), + fromJson: Value(_encodeAddresses(envelope.from)), + toJson: Value(_encodeAddresses(envelope.to)), + ccJson: Value(_encodeAddresses(envelope.cc)), isSeen: Value(msg.flags?.contains(r'\Seen') ?? false), isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false), - hasAttachment: Value(msg.body?.hasAttachments ?? false), + hasAttachment: Value(msg.hasAttachments()), ), ); } @@ -180,8 +154,7 @@ class EmailRepositoryImpl implements EmailRepository { final client = await connectImap(account, password); try { await client.selectMailboxByPath(row.mailboxPath); - final seq = - imap.MessageSequence.fromId(row.uid, isUid: true); + final seq = imap.MessageSequence.fromId(row.uid, isUid: true); if (seen != null) { seen ? await client.markSeen(seq, isUidSequence: true) @@ -252,9 +225,7 @@ class EmailRepositoryImpl implements EmailRepository { final smtpClient = await connectSmtp(account, password); try { final builder = imap.MessageBuilder() - ..from = [ - imap.MailAddress(draft.from.name, draft.from.email), - ] + ..from = [imap.MailAddress(draft.from.name, draft.from.email)] ..to = draft.to .map((a) => imap.MailAddress(a.name, a.email)) .toList() @@ -263,15 +234,21 @@ class EmailRepositoryImpl implements EmailRepository { .toList() ..subject = draft.subject ..text = draft.body; - final message = builder.buildMimeMessage(); - await smtpClient.sendMessage(message); + await smtpClient.sendMessage(builder.buildMimeMessage()); } finally { - smtpClient.disconnect(); + await smtpClient.quit(); } } // ── Helpers ──────────────────────────────────────────────────────────────── + String _encodeAddresses(List? addresses) => + jsonEncode( + (addresses ?? const []) + .map((a) => {'name': a.personalName, 'email': a.email}) + .toList(), + ); + Email _toModel(db.Email row) { List parseAddresses(String json) { final list = jsonDecode(json) as List; @@ -303,17 +280,12 @@ class EmailRepositoryImpl implements EmailRepository { ); } - EmailBody _toBodyModel(dynamic row) { - if (row is db.EmailBody) { - return EmailBody( + EmailBody _bodyRowToModel(db.EmailBody row) => EmailBody( emailId: row.emailId, textBody: row.textBody, htmlBody: row.htmlBody, attachments: _parseAttachments(row.attachmentsJson), ); - } - return row as EmailBody; - } List _parseAttachments(String json) { final list = jsonDecode(json) as List; diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index 18d6e25..49cda35 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -29,18 +29,33 @@ class MailboxRepositoryImpl implements MailboxRepository { final password = await _accounts.getPassword(accountId); final client = await connectImap(account, password); try { - final response = await client.listMailboxes(); - for (final mb in response.mailboxes ?? const []) { - final path = mb.path ?? mb.name; + // listMailboxes() returns List + final mailboxes = await client.listMailboxes(recursive: true); + for (final mb in mailboxes) { + final path = mb.path; final id = '${accountId}:$path'; + + // Fetch STATUS (unread + total counts) for each mailbox. + // Suppress errors — some mailboxes (e.g. \Noselect) can't be selected. + int unread = 0; + int total = 0; + try { + final status = await client.statusMailbox( + mb, + [imap.StatusFlags.messages, imap.StatusFlags.unseen], + ); + unread = status.messagesUnseen; + total = status.messagesExists; + } catch (_) {} + await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: id, accountId: accountId, path: path, name: mb.name, - unreadCount: Value(mb.messagesUnseen ?? 0), - totalCount: Value(mb.messagesExists ?? 0), + unreadCount: Value(unread), + totalCount: Value(total), ), ); } diff --git a/lib/main.dart b/lib/main.dart index 6dbd9fd..0e0362a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,14 +8,23 @@ void main() { runApp(const ProviderScope(child: SharedInboxApp())); } -class SharedInboxApp extends ConsumerWidget { +class SharedInboxApp extends ConsumerStatefulWidget { const SharedInboxApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - // Start background sync - ref.watch(syncManagerProvider).start(); + ConsumerState createState() => _SharedInboxAppState(); +} +class _SharedInboxAppState extends ConsumerState { + @override + void initState() { + super.initState(); + // Start background IMAP sync once — runs for the lifetime of the app. + ref.read(syncManagerProvider).start(); + } + + @override + Widget build(BuildContext context) { return MaterialApp.router( title: 'SharedInbox', theme: ThemeData( diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 37025e6..482c834 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -51,6 +51,7 @@ final router = GoRouter( accountId: extra?['accountId'] as String?, replyToEmailId: extra?['replyToEmailId'] as String?, prefillTo: extra?['prefillTo'] as String?, + prefillCc: extra?['prefillCc'] as String?, prefillSubject: extra?['prefillSubject'] as String?, prefillBody: extra?['prefillBody'] as String?, ); diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 91f29b6..3e53fa6 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -9,12 +9,6 @@ class AccountListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final accountsAsync = ref.watch( - accountRepositoryProvider.select( - (r) => r.observeAccounts(), - ).stream, - ); - return Scaffold( appBar: AppBar( title: const Text('SharedInbox'), @@ -28,7 +22,9 @@ class AccountListScreen extends ConsumerWidget { body: StreamBuilder( stream: ref.watch(accountRepositoryProvider).observeAccounts(), builder: (ctx, snap) { - if (!snap.hasData) return const Center(child: CircularProgressIndicator()); + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } final accounts = snap.data!; if (accounts.isEmpty) { return Center( @@ -54,8 +50,7 @@ class AccountListScreen extends ConsumerWidget { leading: const Icon(Icons.account_circle), title: Text(a.displayName), subtitle: Text(a.email), - onTap: () => - context.push('/accounts/${a.id}/mailboxes'), + onTap: () => context.push('/accounts/${a.id}/mailboxes'), ); }, ); diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index 5799fc5..6121677 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -11,6 +11,7 @@ class ComposeScreen extends ConsumerStatefulWidget { this.accountId, this.replyToEmailId, this.prefillTo, + this.prefillCc, this.prefillSubject, this.prefillBody, }); @@ -18,6 +19,7 @@ class ComposeScreen extends ConsumerStatefulWidget { final String? accountId; final String? replyToEmailId; final String? prefillTo; + final String? prefillCc; final String? prefillSubject; final String? prefillBody; @@ -37,6 +39,7 @@ class _ComposeScreenState extends ConsumerState { void initState() { super.initState(); if (widget.prefillTo != null) _to.text = widget.prefillTo!; + if (widget.prefillCc != null) _cc.text = widget.prefillCc!; if (widget.prefillSubject != null) _subject.text = widget.prefillSubject!; if (widget.prefillBody != null) _body.text = widget.prefillBody!; _accountId = widget.accountId; diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index ab7d6ad..2d68591 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -17,120 +17,151 @@ class EmailDetailScreen extends ConsumerStatefulWidget { } class _EmailDetailScreenState extends ConsumerState { - late Future _bodyFuture; + late final Future<(Email?, EmailBody)> _dataFuture; @override void initState() { super.initState(); - _loadBody(); - _markSeen(); - } - - void _loadBody() { - _bodyFuture = - ref.read(emailRepositoryProvider).getEmailBody(widget.emailId); - } - - Future _markSeen() async { - await ref - .read(emailRepositoryProvider) - .setFlag(widget.emailId, seen: true); + final repo = ref.read(emailRepositoryProvider); + _dataFuture = Future.wait([ + repo.getEmail(widget.emailId), + repo.getEmailBody(widget.emailId), + ]).then((results) => (results[0] as Email?, results[1] as EmailBody)); + repo.setFlag(widget.emailId, seen: true); } @override Widget build(BuildContext context) { final repo = ref.watch(emailRepositoryProvider); - return Scaffold( - appBar: AppBar( - title: const Text('Email'), - actions: [ - IconButton( - icon: const Icon(Icons.reply), - tooltip: 'Reply', - onPressed: () => _reply(context, replyAll: false), - ), - IconButton( - icon: const Icon(Icons.reply_all), - tooltip: 'Reply all', - onPressed: () => _reply(context, replyAll: true), - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () async { - await repo.deleteEmail(widget.emailId); - if (context.mounted) context.pop(); - }, - ), - ], - ), - body: FutureBuilder( - future: _bodyFuture, - builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center(child: Text('Error: ${snap.error}')); - } - final body = snap.data!; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildHeader(ctx, body), - const Divider(), - SelectableText( - body.textBody ?? - _htmlToPlain(body.htmlBody ?? '') , + return FutureBuilder<(Email?, EmailBody)>( + future: _dataFuture, + builder: (ctx, snap) { + final header = snap.data?.$1; + final body = snap.data?.$2; + + return Scaffold( + appBar: AppBar( + title: Text( + header?.subject ?? '(loading…)', + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + icon: const Icon(Icons.reply), + tooltip: 'Reply', + onPressed: + header == null ? null : () => _reply(context, header, replyAll: false), + ), + IconButton( + icon: const Icon(Icons.reply_all), + tooltip: 'Reply all', + onPressed: + header == null ? null : () => _reply(context, header, replyAll: true), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + await repo.deleteEmail(widget.emailId); + if (context.mounted) context.pop(); + }, ), - if (body.attachments.isNotEmpty) ...[ - const Divider(), - const Text( - 'Attachments', - style: TextStyle(fontWeight: FontWeight.bold), - ), - for (final att in body.attachments) - ListTile( - leading: const Icon(Icons.attach_file), - title: Text(att.filename), - subtitle: Text(_fmtSize(att.size)), - ), - ], ], - ); - }, - ), + ), + body: snap.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : snap.hasError + ? Center(child: Text('Error: ${snap.error}')) + : _buildBody(ctx, header, body!), + ); + }, ); } - Widget _buildHeader(BuildContext ctx, EmailBody body) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) { + return ListView( + padding: const EdgeInsets.all(16), children: [ - Text( - body.emailId, // we'd look up subject from DB in real code - style: Theme.of(ctx).textTheme.titleMedium, + if (header != null) ...[ + _buildHeader(ctx, header), + const Divider(), + ], + SelectableText( + body.textBody ?? _htmlToPlain(body.htmlBody ?? ''), + style: Theme.of(ctx).textTheme.bodyMedium, ), - const SizedBox(height: 8), + if (body.attachments.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + 'Attachments', + style: Theme.of(ctx).textTheme.titleSmall, + ), + ), + for (final att in body.attachments) + ListTile( + dense: true, + leading: const Icon(Icons.attach_file), + title: Text(att.filename), + subtitle: Text(_fmtSize(att.size)), + ), + ], ], ); } - void _reply(BuildContext context, {required bool replyAll}) { + Widget _buildHeader(BuildContext ctx, Email email) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + email.subject ?? '(no subject)', + style: Theme.of(ctx).textTheme.titleMedium, + ), + const SizedBox(height: 4), + if (email.from.isNotEmpty) + Text( + 'From: ${email.from.first}', + style: Theme.of(ctx).textTheme.bodySmall, + ), + if (email.to.isNotEmpty) + Text( + 'To: ${email.to.map((a) => a.toString()).join(', ')}', + style: Theme.of(ctx).textTheme.bodySmall, + ), + if (email.sentAt != null) + Text( + _dateFmt.format(email.sentAt!), + style: Theme.of(ctx).textTheme.bodySmall, + ), + ], + ); + } + + void _reply(BuildContext context, Email header, {required bool replyAll}) { + 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(', ') + : ''; context.push('/compose', extra: { 'replyToEmailId': widget.emailId, + 'prefillTo': to, + 'prefillSubject': subject, + if (cc.isNotEmpty) 'prefillCc': cc, }); } - String _htmlToPlain(String html) { - return html - .replaceAll(RegExp(r''), '\n') - .replaceAll(RegExp(r'<[^>]+>'), '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(' ', ' '); - } + String _htmlToPlain(String html) => html + .replaceAll(RegExp(r''), '\n') + .replaceAll(RegExp(r'<[^>]+>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(' ', ' '); String _fmtSize(int bytes) { if (bytes < 1024) return '$bytes B'; diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..7e4f4a1 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.13) +project(sharedinbox LANGUAGES CXX) + +set(BINARY_NAME "sharedinbox") +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure the Flutter app plugin list — generated by flutter pub get. +include(flutter/generated_plugins.cmake OPTIONAL RESULT_VARIABLE HAVE_PLUGINS) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_subdirectory(${FLUTTER_MANAGED_DIR} managed_flutter) + +add_executable(${BINARY_NAME} "main.cc" "my_application.cc") +apply_standard_settings(${BINARY_NAME}) +target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION="${FLUTTER_VERSION}") +target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}) +target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}) +target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}) +target_compile_definitions(${BINARY_NAME} PRIVATE FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter PkgConfig::GTK) + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +apply_flutter_bundle_properties(${BINARY_NAME} + BUNDLE_ID "de.sharedinbox.sharedinbox" + BUNDLE_EXECUTABLE "${BINARY_NAME}" +) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 0000000..3a8f91c --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,74 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + gtk_window_set_default_size(window, 1280, 800); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +static void my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return; + } + + g_application_activate(application); + *exit_status = 0; +} + +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", "de.sharedinbox.app", + "flags", G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 0000000..f313169 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +MyApplication* my_application_new();