Fix API mismatches, add Linux desktop entry point, reply prefill

API fixes (against vendored enough_mail 2.1.7):
- listMailboxes() returns List<Mailbox> 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 07:51:52 +02:00
co-authored by Claude Sonnet 4.6
parent 22db4a2dd6
commit 72e2b599bf
15 changed files with 538 additions and 222 deletions
Generated
+139
View File
@@ -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
}
@@ -2,6 +2,7 @@ import '../models/email.dart';
abstract class EmailRepository { abstract class EmailRepository {
Stream<List<Email>> observeEmails(String accountId, String mailboxPath); Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
Future<Email?> getEmail(String emailId);
Future<EmailBody> getEmailBody(String emailId); Future<EmailBody> getEmailBody(String emailId);
Future<void> syncEmails(String accountId, String mailboxPath); Future<void> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag( Future<void> setFlag(
+45 -30
View File
@@ -9,7 +9,7 @@ import '../repositories/mailbox_repository.dart';
import '../../data/imap/imap_client_factory.dart'; import '../../data/imap/imap_client_factory.dart';
/// Manages one IMAP IDLE connection per account. /// 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 { class AccountSyncManager {
AccountSyncManager(this._accounts, this._mailboxes, this._emails); AccountSyncManager(this._accounts, this._mailboxes, this._emails);
@@ -18,32 +18,32 @@ class AccountSyncManager {
final EmailRepository _emails; final EmailRepository _emails;
final Map<String, _AccountSync> _active = {}; final Map<String, _AccountSync> _active = {};
StreamSubscription<List<Account>>? _accountsSub;
Future<void> start() async { void start() {
_accounts.observeAccounts().listen((accounts) { _accountsSub = _accounts.observeAccounts().listen((accounts) {
final ids = accounts.map((a) => a.id).toSet(); final currentIds = accounts.map((a) => a.id).toSet();
// Start new
// Start sync for newly added accounts.
for (final account in accounts) { for (final account in accounts) {
if (!_active.containsKey(account.id)) { 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()) { for (final id in _active.keys.toList()) {
if (!ids.contains(id)) { if (!currentIds.contains(id)) {
_active.remove(id)?.stop(); _active.remove(id)?.stop();
} }
} }
}); });
} }
void _start(Account account) {
final sync = _AccountSync(account, _accounts, _mailboxes, _emails);
_active[account.id] = sync;
sync.start();
}
void dispose() { void dispose() {
_accountsSub?.cancel();
for (final s in _active.values) { for (final s in _active.values) {
s.stop(); s.stop();
} }
@@ -97,21 +97,36 @@ class _AccountSync {
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password); final client = await connectImap(account, password);
_idleClient = client; _idleClient = client;
await client.selectMailboxByPath('INBOX'); try {
final idleDone = Completer<void>(); await client.selectMailboxByPath('INBOX');
await client.idleStart();
client.eventBus final newMessageCompleter = Completer<void>();
.on<imap.ImapEvent>()
.where((e) => e is imap.ImapMessagesExistEvent) // Wake up when new messages arrive or messages are expunged.
.first final sub = client.eventBus
.then((_) => idleDone.complete()); .on<imap.ImapEvent>()
// Stop idle after 25 minutes to stay within server limits .where(
Future.delayed(const Duration(minutes: 25)).then((_) { (e) =>
if (!idleDone.isCompleted) idleDone.complete(); e is imap.ImapMessagesExistEvent ||
}); e is imap.ImapExpungeEvent,
await idleDone.future; )
await client.idleDone(); .listen((_) {
await client.logout(); if (!newMessageCompleter.isCompleted) newMessageCompleter.complete();
_idleClient = null; });
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;
}
} }
} }
+18 -5
View File
@@ -2,7 +2,7 @@ import 'package:enough_mail/enough_mail.dart';
import '../../core/models/account.dart'; import '../../core/models/account.dart';
/// Opens an authenticated IMAP client for the given account. /// Opens an authenticated IMAP client for [account].
Future<ImapClient> connectImap(Account account, String password) async { Future<ImapClient> connectImap(Account account, String password) async {
final client = ImapClient(isLogEnabled: false); final client = ImapClient(isLogEnabled: false);
await client.connectToServer( await client.connectToServer(
@@ -14,9 +14,17 @@ Future<ImapClient> connectImap(Account account, String password) async {
return client; 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<SmtpClient> connectSmtp(Account account, String password) async { Future<SmtpClient> 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( await client.connectToServer(
account.smtpHost, account.smtpHost,
account.smtpPort, account.smtpPort,
@@ -24,8 +32,13 @@ Future<SmtpClient> connectSmtp(Account account, String password) async {
); );
await client.ehlo(); await client.ehlo();
if (!account.smtpSsl) { 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; return client;
} }
@@ -4,11 +4,11 @@ import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import '../../core/models/email.dart'; import '../../core/models/email.dart';
import '../../core/repositories/account_repository.dart';
import '../../core/repositories/email_repository.dart'; import '../../core/repositories/email_repository.dart';
import '../db/database.dart'; import '../db/database.dart';
import '../db/database.dart' as db show Email, EmailBody; import '../db/database.dart' as db show Email, EmailBody;
import '../imap/imap_client_factory.dart'; import '../imap/imap_client_factory.dart';
import '../../core/repositories/account_repository.dart';
class EmailRepositoryImpl implements EmailRepository { class EmailRepositoryImpl implements EmailRepository {
EmailRepositoryImpl(this._db, this._accounts); EmailRepositoryImpl(this._db, this._accounts);
@@ -31,6 +31,14 @@ class EmailRepositoryImpl implements EmailRepository {
.map((rows) => rows.map(_toModel).toList()); .map((rows) => rows.map(_toModel).toList());
} }
@override
Future<Email?> 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) ─────────────────────────────────────────────────────── // ── Body (on-demand) ───────────────────────────────────────────────────────
@override @override
@@ -38,7 +46,7 @@ class EmailRepositoryImpl implements EmailRepository {
final cached = await (_db.select(_db.emailBodies) final cached = await (_db.select(_db.emailBodies)
..where((t) => t.emailId.equals(emailId))) ..where((t) => t.emailId.equals(emailId)))
.getSingleOrNull(); .getSingleOrNull();
if (cached != null) return _toBodyModel(cached); if (cached != null) return _bodyRowToModel(cached);
final emailRow = await (_db.select(_db.emails) final emailRow = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId))) ..where((t) => t.id.equals(emailId)))
@@ -47,25 +55,21 @@ class EmailRepositoryImpl implements EmailRepository {
final password = await _accounts.getPassword(account.id); final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password); final client = await connectImap(account, password);
try { try {
await client.selectMailbox( await client.selectMailboxByPath(emailRow.mailboxPath);
imap.Mailbox.fromStatus(
imap.MailboxStatus(emailRow.mailboxPath, 0, 0, 0),
),
);
final fetch = await client.fetchMessage( final fetch = await client.fetchMessage(
imap.MessageSequence.fromId(emailRow.uid, isUid: true), imap.MessageSequence.fromId(emailRow.uid, isUid: true),
'(RFC822)', '(BODY[])',
isUidSequence: true,
); );
final msg = fetch.messages.first; final msg = fetch.messages.first;
final mime = imap.MimeMessage.parseFromData(msg.rawData!); final textBody = msg.decodeTextPlainPart();
final textBody = mime.decodeTextPlainPart(); final htmlBody = msg.decodeTextHtmlPart();
final htmlBody = mime.decodeTextHtmlPart(); final contentInfos = msg.findContentInfo(
final attachments = mime.findContentInfo(
disposition: imap.ContentDisposition.attachment, disposition: imap.ContentDisposition.attachment,
); );
final attachmentsJson = jsonEncode( final attachmentsJson = jsonEncode(
attachments contentInfos
.map( .map(
(a) => { (a) => {
'filename': a.fileName ?? '', 'filename': a.fileName ?? '',
@@ -84,13 +88,11 @@ class EmailRepositoryImpl implements EmailRepository {
attachmentsJson: Value(attachmentsJson), attachmentsJson: Value(attachmentsJson),
), ),
); );
return _toBodyModel( return EmailBody(
EmailBody( emailId: emailId,
emailId: emailId, textBody: textBody,
textBody: textBody, htmlBody: htmlBody,
htmlBody: htmlBody, attachments: _parseAttachments(attachmentsJson),
attachments: _parseAttachments(attachmentsJson),
),
); );
} finally { } finally {
await client.logout(); await client.logout();
@@ -105,61 +107,33 @@ class EmailRepositoryImpl implements EmailRepository {
final password = await _accounts.getPassword(accountId); final password = await _accounts.getPassword(accountId);
final client = await connectImap(account, password); final client = await connectImap(account, password);
try { try {
final mailbox = await client.selectMailboxByPath(mailboxPath); await client.selectMailboxByPath(mailboxPath);
final sequence = imap.MessageSequence.fromAll(); final fetch = await client.fetchMessages(
final messages = await client.fetchMessages( imap.MessageSequence.fromAll(),
sequence,
'(UID FLAGS ENVELOPE BODYSTRUCTURE)', '(UID FLAGS ENVELOPE BODYSTRUCTURE)',
); );
for (final msg in messages.messages) { for (final msg in fetch.messages) {
final mime = msg.envelope; final envelope = msg.envelope;
if (mime == null) continue; if (envelope == null) continue;
final emailId = '${accountId}:${msg.uid}'; final uid = msg.uid;
if (uid == null) continue;
final emailId = '${accountId}:$uid';
await _db.into(_db.emails).insertOnConflictUpdate( await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert( EmailsCompanion.insert(
id: emailId, id: emailId,
accountId: accountId, accountId: accountId,
mailboxPath: mailboxPath, mailboxPath: mailboxPath,
uid: Value(msg.uid ?? 0), uid: Value(uid),
subject: Value(mime.subject), subject: Value(envelope.subject),
sentAt: Value(mime.date), sentAt: Value(envelope.date),
receivedAt: Value(mime.date ?? DateTime.now()), receivedAt: Value(envelope.date ?? DateTime.now()),
fromJson: Value( fromJson: Value(_encodeAddresses(envelope.from)),
jsonEncode( toJson: Value(_encodeAddresses(envelope.to)),
mime.from ccJson: Value(_encodeAddresses(envelope.cc)),
?.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() ??
[],
),
),
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false), isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? 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); final client = await connectImap(account, password);
try { try {
await client.selectMailboxByPath(row.mailboxPath); await client.selectMailboxByPath(row.mailboxPath);
final seq = final seq = imap.MessageSequence.fromId(row.uid, isUid: true);
imap.MessageSequence.fromId(row.uid, isUid: true);
if (seen != null) { if (seen != null) {
seen seen
? await client.markSeen(seq, isUidSequence: true) ? await client.markSeen(seq, isUidSequence: true)
@@ -252,9 +225,7 @@ class EmailRepositoryImpl implements EmailRepository {
final smtpClient = await connectSmtp(account, password); final smtpClient = await connectSmtp(account, password);
try { try {
final builder = imap.MessageBuilder() final builder = imap.MessageBuilder()
..from = [ ..from = [imap.MailAddress(draft.from.name, draft.from.email)]
imap.MailAddress(draft.from.name, draft.from.email),
]
..to = draft.to ..to = draft.to
.map((a) => imap.MailAddress(a.name, a.email)) .map((a) => imap.MailAddress(a.name, a.email))
.toList() .toList()
@@ -263,15 +234,21 @@ class EmailRepositoryImpl implements EmailRepository {
.toList() .toList()
..subject = draft.subject ..subject = draft.subject
..text = draft.body; ..text = draft.body;
final message = builder.buildMimeMessage(); await smtpClient.sendMessage(builder.buildMimeMessage());
await smtpClient.sendMessage(message);
} finally { } finally {
smtpClient.disconnect(); await smtpClient.quit();
} }
} }
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
String _encodeAddresses(List<imap.MailAddress>? addresses) =>
jsonEncode(
(addresses ?? const [])
.map((a) => {'name': a.personalName, 'email': a.email})
.toList(),
);
Email _toModel(db.Email row) { Email _toModel(db.Email row) {
List<EmailAddress> parseAddresses(String json) { List<EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List; final list = jsonDecode(json) as List;
@@ -303,17 +280,12 @@ class EmailRepositoryImpl implements EmailRepository {
); );
} }
EmailBody _toBodyModel(dynamic row) { EmailBody _bodyRowToModel(db.EmailBody row) => EmailBody(
if (row is db.EmailBody) {
return EmailBody(
emailId: row.emailId, emailId: row.emailId,
textBody: row.textBody, textBody: row.textBody,
htmlBody: row.htmlBody, htmlBody: row.htmlBody,
attachments: _parseAttachments(row.attachmentsJson), attachments: _parseAttachments(row.attachmentsJson),
); );
}
return row as EmailBody;
}
List<EmailAttachment> _parseAttachments(String json) { List<EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List; final list = jsonDecode(json) as List;
@@ -29,18 +29,33 @@ class MailboxRepositoryImpl implements MailboxRepository {
final password = await _accounts.getPassword(accountId); final password = await _accounts.getPassword(accountId);
final client = await connectImap(account, password); final client = await connectImap(account, password);
try { try {
final response = await client.listMailboxes(); // listMailboxes() returns List<imap.Mailbox>
for (final mb in response.mailboxes ?? const <imap.Mailbox>[]) { final mailboxes = await client.listMailboxes(recursive: true);
final path = mb.path ?? mb.name; for (final mb in mailboxes) {
final path = mb.path;
final id = '${accountId}:$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( await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
accountId: accountId, accountId: accountId,
path: path, path: path,
name: mb.name, name: mb.name,
unreadCount: Value(mb.messagesUnseen ?? 0), unreadCount: Value(unread),
totalCount: Value(mb.messagesExists ?? 0), totalCount: Value(total),
), ),
); );
} }
+13 -4
View File
@@ -8,14 +8,23 @@ void main() {
runApp(const ProviderScope(child: SharedInboxApp())); runApp(const ProviderScope(child: SharedInboxApp()));
} }
class SharedInboxApp extends ConsumerWidget { class SharedInboxApp extends ConsumerStatefulWidget {
const SharedInboxApp({super.key}); const SharedInboxApp({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
// Start background sync }
ref.watch(syncManagerProvider).start();
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
@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( return MaterialApp.router(
title: 'SharedInbox', title: 'SharedInbox',
theme: ThemeData( theme: ThemeData(
+1
View File
@@ -51,6 +51,7 @@ final router = GoRouter(
accountId: extra?['accountId'] as String?, accountId: extra?['accountId'] as String?,
replyToEmailId: extra?['replyToEmailId'] as String?, replyToEmailId: extra?['replyToEmailId'] as String?,
prefillTo: extra?['prefillTo'] as String?, prefillTo: extra?['prefillTo'] as String?,
prefillCc: extra?['prefillCc'] as String?,
prefillSubject: extra?['prefillSubject'] as String?, prefillSubject: extra?['prefillSubject'] as String?,
prefillBody: extra?['prefillBody'] as String?, prefillBody: extra?['prefillBody'] as String?,
); );
+4 -9
View File
@@ -9,12 +9,6 @@ class AccountListScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final accountsAsync = ref.watch(
accountRepositoryProvider.select(
(r) => r.observeAccounts(),
).stream,
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('SharedInbox'), title: const Text('SharedInbox'),
@@ -28,7 +22,9 @@ class AccountListScreen extends ConsumerWidget {
body: StreamBuilder( body: StreamBuilder(
stream: ref.watch(accountRepositoryProvider).observeAccounts(), stream: ref.watch(accountRepositoryProvider).observeAccounts(),
builder: (ctx, snap) { builder: (ctx, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator()); if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final accounts = snap.data!; final accounts = snap.data!;
if (accounts.isEmpty) { if (accounts.isEmpty) {
return Center( return Center(
@@ -54,8 +50,7 @@ class AccountListScreen extends ConsumerWidget {
leading: const Icon(Icons.account_circle), leading: const Icon(Icons.account_circle),
title: Text(a.displayName), title: Text(a.displayName),
subtitle: Text(a.email), subtitle: Text(a.email),
onTap: () => onTap: () => context.push('/accounts/${a.id}/mailboxes'),
context.push('/accounts/${a.id}/mailboxes'),
); );
}, },
); );
+3
View File
@@ -11,6 +11,7 @@ class ComposeScreen extends ConsumerStatefulWidget {
this.accountId, this.accountId,
this.replyToEmailId, this.replyToEmailId,
this.prefillTo, this.prefillTo,
this.prefillCc,
this.prefillSubject, this.prefillSubject,
this.prefillBody, this.prefillBody,
}); });
@@ -18,6 +19,7 @@ class ComposeScreen extends ConsumerStatefulWidget {
final String? accountId; final String? accountId;
final String? replyToEmailId; final String? replyToEmailId;
final String? prefillTo; final String? prefillTo;
final String? prefillCc;
final String? prefillSubject; final String? prefillSubject;
final String? prefillBody; final String? prefillBody;
@@ -37,6 +39,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
void initState() { void initState() {
super.initState(); super.initState();
if (widget.prefillTo != null) _to.text = widget.prefillTo!; 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.prefillSubject != null) _subject.text = widget.prefillSubject!;
if (widget.prefillBody != null) _body.text = widget.prefillBody!; if (widget.prefillBody != null) _body.text = widget.prefillBody!;
_accountId = widget.accountId; _accountId = widget.accountId;
+120 -89
View File
@@ -17,120 +17,151 @@ class EmailDetailScreen extends ConsumerStatefulWidget {
} }
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> { class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
late Future<EmailBody> _bodyFuture; late final Future<(Email?, EmailBody)> _dataFuture;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadBody(); final repo = ref.read(emailRepositoryProvider);
_markSeen(); _dataFuture = Future.wait([
} repo.getEmail(widget.emailId),
repo.getEmailBody(widget.emailId),
void _loadBody() { ]).then((results) => (results[0] as Email?, results[1] as EmailBody));
_bodyFuture = repo.setFlag(widget.emailId, seen: true);
ref.read(emailRepositoryProvider).getEmailBody(widget.emailId);
}
Future<void> _markSeen() async {
await ref
.read(emailRepositoryProvider)
.setFlag(widget.emailId, seen: true);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
return Scaffold( return FutureBuilder<(Email?, EmailBody)>(
appBar: AppBar( future: _dataFuture,
title: const Text('Email'), builder: (ctx, snap) {
actions: [ final header = snap.data?.$1;
IconButton( final body = snap.data?.$2;
icon: const Icon(Icons.reply),
tooltip: 'Reply', return Scaffold(
onPressed: () => _reply(context, replyAll: false), appBar: AppBar(
), title: Text(
IconButton( header?.subject ?? '(loading…)',
icon: const Icon(Icons.reply_all), overflow: TextOverflow.ellipsis,
tooltip: 'Reply all', ),
onPressed: () => _reply(context, replyAll: true), actions: [
), IconButton(
IconButton( icon: const Icon(Icons.reply),
icon: const Icon(Icons.delete), tooltip: 'Reply',
onPressed: () async { onPressed:
await repo.deleteEmail(widget.emailId); header == null ? null : () => _reply(context, header, replyAll: false),
if (context.mounted) context.pop(); ),
}, IconButton(
), icon: const Icon(Icons.reply_all),
], tooltip: 'Reply all',
), onPressed:
body: FutureBuilder<EmailBody>( header == null ? null : () => _reply(context, header, replyAll: true),
future: _bodyFuture, ),
builder: (ctx, snap) { IconButton(
if (snap.connectionState == ConnectionState.waiting) { icon: const Icon(Icons.delete),
return const Center(child: CircularProgressIndicator()); onPressed: () async {
} await repo.deleteEmail(widget.emailId);
if (snap.hasError) { if (context.mounted) context.pop();
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 ?? '') ,
), ),
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) { Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
return Column( return ListView(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ children: [
Text( if (header != null) ...[
body.emailId, // we'd look up subject from DB in real code _buildHeader(ctx, header),
style: Theme.of(ctx).textTheme.titleMedium, 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: { context.push('/compose', extra: {
'replyToEmailId': widget.emailId, 'replyToEmailId': widget.emailId,
'prefillTo': to,
'prefillSubject': subject,
if (cc.isNotEmpty) 'prefillCc': cc,
}); });
} }
String _htmlToPlain(String html) { String _htmlToPlain(String html) => html
return html .replaceAll(RegExp(r'<br\s*/?>'), '\n')
.replaceAll(RegExp(r'<br\s*/?>'), '\n') .replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll(RegExp(r'<[^>]+>'), '') .replaceAll('&amp;', '&')
.replaceAll('&amp;', '&') .replaceAll('&lt;', '<')
.replaceAll('&lt;', '<') .replaceAll('&gt;', '>')
.replaceAll('&gt;', '>') .replaceAll('&quot;', '"')
.replaceAll('&quot;', '"') .replaceAll('&nbsp;', ' ');
.replaceAll('&nbsp;', ' ');
}
String _fmtSize(int bytes) { String _fmtSize(int bytes) {
if (bytes < 1024) return '$bytes B'; if (bytes < 1024) return '$bytes B';
+34
View File
@@ -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}"
)
+6
View File
@@ -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);
}
+74
View File
@@ -0,0 +1,74 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#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));
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
MyApplication* my_application_new();