commit 5ebda521d6894794037cee7b3b85d3a58fe8d3f0 Author: Thomas Güttler Date: Thu Apr 16 07:35:56 2026 +0200 Initial Flutter/Dart port of SharedInbox IMAP/SMTP email client with offline-first architecture: sync engine writes to Drift (SQLite), UI reads reactively from the local DB. enough_mail vendored under packages/. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23d0fe1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Flutter/Dart +.dart_tool/ +.packages +pubspec.lock +build/ +*.g.dart +*.freezed.dart + +# IDE +.idea/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db + +# Android +android/.gradle/ +android/local.properties +android/app/google-services.json + +# iOS / macOS +ios/Pods/ +macos/Pods/ +*.xcworkspace/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..10fdfe6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# SharedInbox Flutter — Agent Guide + +## Code conventions + +- No `if` chains where a pattern/match or data-driven approach works. +- Fail loudly — `throw StateError(...)` beats silent fallbacks. +- New source files go under `lib/core/` (interfaces/models), `lib/data/` (implementations), or `lib/ui/` (screens/widgets). + +## Drift (DB) + +- Schema in `lib/data/db/database.dart`. +- After any schema change run: `dart run build_runner build --delete-conflicting-outputs` +- Generated `database.g.dart` is committed — do not hand-edit it. + +## enough_mail (vendored) + +- Located at `packages/enough_mail/` — edit freely. +- IMAP client helpers are in `lib/data/imap/imap_client_factory.dart`. + +## Running + +```bash +# Code generation (must run after schema changes) +dart run build_runner build --delete-conflicting-outputs + +# Desktop +flutter run -d linux + +# Tests +flutter test +``` + +## Adding a screen + +1. Create `lib/ui/screens/my_screen.dart`. +2. Add a `GoRoute` in `lib/ui/router.dart`. +3. No separate ViewModel file needed — use `ConsumerWidget` / `ConsumerStatefulWidget` directly with Riverpod providers. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..318d985 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,38 @@ +# SharedInbox Flutter — Plan + +## Architecture + +``` +IMAP/SMTP server + ↓ + AccountSyncManager (IMAP IDLE per account) + ↓ writes + Drift (SQLite, local DB) + ↓ reads + UI (Riverpod + go_router) +``` + +UI never touches the network. The sync layer runs independently. + +## Phases + +| Phase | Scope | Status | +|---|---|---| +| 0 — Scaffold | pubspec, Drift schema, DI, router, enough_mail vendored | Done | +| 1 — Core models | `Account`, `Mailbox`, `Email`, `EmailBody`, repository interfaces | Done | +| 2 — DB layer | Drift tables, `AccountRepositoryImpl`, `MailboxRepositoryImpl`, `EmailRepositoryImpl` | Done | +| 3 — IMAP sync | `connectImap`, `MailboxRepositoryImpl.syncMailboxes`, `EmailRepositoryImpl.syncEmails` | Done | +| 4 — IMAP IDLE | `AccountSyncManager` with exponential-backoff reconnect | Done | +| 5 — SMTP send | `connectSmtp`, `EmailRepositoryImpl.sendEmail` | Done | +| 6 — UI | All screens: AccountList, AddAccount, MailboxList, EmailList, EmailDetail, Compose, Settings | Done | +| 7 — Code-gen | Run `dart run build_runner build` to generate `database.g.dart` | Pending | +| 8 — Platform targets | Android, iOS, Linux, macOS, Windows entry points | Pending | +| 9 — Polish | Reply prefill, attachment open, thread view, search | Next | + +## Next candidates + +- Reply-with-prefill (subject/body/from populated from original email) +- Thread view (group by `References` / `In-Reply-To`) +- Search (IMAP `SEARCH` command) +- Attachment download + open +- Draft auto-save diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f63b20 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# SharedInbox [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](packages/enough_mail/LICENSE) + +IMAP/SMTP email client written in [Flutter](https://flutter.dev). + +Targets **Android, iOS, and Desktop** (Linux, macOS, Windows). +Supports **multiple accounts** — each synced independently via IMAP IDLE. + +## Design philosophy: offline-first + +``` +IMAP/SMTP server + ↓ + AccountSyncManager ←→ Drift (SQLite, local DB) + ↓ + UI (reads only from DB) +``` + +The UI never touches the network. The sync engine runs in the background and writes to a local Drift database. Screens observe reactive streams from that DB. + +## Key packages + +| Package | Role | +|---|---| +| `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME | +| `drift` | Local SQLite ORM | +| `flutter_riverpod` | State management / DI | +| `go_router` | Navigation | + +## Building + +```bash +# Install dependencies and run code generation +dart pub get +dart run build_runner build --delete-conflicting-outputs + +# Run on desktop +flutter run -d linux # or macos / windows + +# Run on Android / iOS +flutter run +``` + +## Vendored enough_mail + +`packages/enough_mail/` is a local copy of [enough_mail](https://github.com/Enough-Software/enough_mail) so it can be modified if needed. It is referenced via a `path:` dependency in `pubspec.yaml`. diff --git a/lib/core/models/account.dart b/lib/core/models/account.dart new file mode 100644 index 0000000..73762df --- /dev/null +++ b/lib/core/models/account.dart @@ -0,0 +1,24 @@ +/// Represents a configured IMAP/SMTP account stored in the local DB. +class Account { + final String id; + final String displayName; + final String email; + final String imapHost; + final int imapPort; + final bool imapSsl; + final String smtpHost; + final int smtpPort; + final bool smtpSsl; + + const Account({ + required this.id, + required this.displayName, + required this.email, + required this.imapHost, + required this.imapPort, + required this.imapSsl, + required this.smtpHost, + required this.smtpPort, + required this.smtpSsl, + }); +} diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart new file mode 100644 index 0000000..fe50af7 --- /dev/null +++ b/lib/core/models/email.dart @@ -0,0 +1,88 @@ +/// Email header — stored locally after sync, body fetched on demand. +class Email { + final String id; // ":" + final String accountId; + final String mailboxPath; + final int uid; + final String? subject; + final DateTime? sentAt; + final DateTime receivedAt; + final List from; + final List to; + final List cc; + final String? preview; + final bool isSeen; + final bool isFlagged; + final bool hasAttachment; + + const Email({ + required this.id, + required this.accountId, + required this.mailboxPath, + required this.uid, + this.subject, + this.sentAt, + required this.receivedAt, + required this.from, + required this.to, + required this.cc, + this.preview, + required this.isSeen, + required this.isFlagged, + required this.hasAttachment, + }); +} + +class EmailAddress { + final String? name; + final String email; + + const EmailAddress({this.name, required this.email}); + + @override + String toString() => name != null ? '$name <$email>' : email; +} + +/// Full message body — fetched on demand, cached in the local DB. +class EmailBody { + final String emailId; + final String? textBody; + final String? htmlBody; + final List attachments; + + const EmailBody({ + required this.emailId, + this.textBody, + this.htmlBody, + required this.attachments, + }); +} + +class EmailAttachment { + final String filename; + final String contentType; + final int size; + + const EmailAttachment({ + required this.filename, + required this.contentType, + required this.size, + }); +} + +/// Outgoing email — used for compose / reply. +class EmailDraft { + final EmailAddress from; + final List to; + final List cc; + final String subject; + final String body; + + const EmailDraft({ + required this.from, + required this.to, + required this.cc, + required this.subject, + required this.body, + }); +} diff --git a/lib/core/models/mailbox.dart b/lib/core/models/mailbox.dart new file mode 100644 index 0000000..9f4dcab --- /dev/null +++ b/lib/core/models/mailbox.dart @@ -0,0 +1,18 @@ +/// A mailbox / folder within an account (maps to an IMAP mailbox). +class Mailbox { + final String id; // ":" + final String accountId; + final String path; // e.g. "INBOX", "Sent", "INBOX/Work" + final String name; // last path component + final int unreadCount; + final int totalCount; + + const Mailbox({ + required this.id, + required this.accountId, + required this.path, + required this.name, + required this.unreadCount, + required this.totalCount, + }); +} diff --git a/lib/core/repositories/account_repository.dart b/lib/core/repositories/account_repository.dart new file mode 100644 index 0000000..4763eab --- /dev/null +++ b/lib/core/repositories/account_repository.dart @@ -0,0 +1,9 @@ +import '../models/account.dart'; + +abstract class AccountRepository { + Stream> observeAccounts(); + Future getAccount(String id); + Future addAccount(Account account, String password); + Future removeAccount(String id); + Future getPassword(String accountId); +} diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart new file mode 100644 index 0000000..eeca481 --- /dev/null +++ b/lib/core/repositories/email_repository.dart @@ -0,0 +1,15 @@ +import '../models/email.dart'; + +abstract class EmailRepository { + Stream> observeEmails(String accountId, String mailboxPath); + Future getEmailBody(String emailId); + Future syncEmails(String accountId, String mailboxPath); + Future setFlag( + String emailId, { + bool? seen, + bool? flagged, + }); + Future moveEmail(String emailId, String destMailboxPath); + Future deleteEmail(String emailId); + Future sendEmail(String accountId, EmailDraft draft); +} diff --git a/lib/core/repositories/mailbox_repository.dart b/lib/core/repositories/mailbox_repository.dart new file mode 100644 index 0000000..00698be --- /dev/null +++ b/lib/core/repositories/mailbox_repository.dart @@ -0,0 +1,6 @@ +import '../models/mailbox.dart'; + +abstract class MailboxRepository { + Stream> observeMailboxes(String accountId); + Future syncMailboxes(String accountId); +} diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart new file mode 100644 index 0000000..1ce9b99 --- /dev/null +++ b/lib/core/sync/account_sync_manager.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:enough_mail/enough_mail.dart' as imap; + +import '../models/account.dart'; +import '../repositories/account_repository.dart'; +import '../repositories/email_repository.dart'; +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. +class AccountSyncManager { + AccountSyncManager(this._accounts, this._mailboxes, this._emails); + + final AccountRepository _accounts; + final MailboxRepository _mailboxes; + final EmailRepository _emails; + + final Map _active = {}; + + Future start() async { + _accounts.observeAccounts().listen((accounts) { + final ids = accounts.map((a) => a.id).toSet(); + // Start new + for (final account in accounts) { + if (!_active.containsKey(account.id)) { + _start(account); + } + } + // Stop removed + for (final id in _active.keys.toList()) { + if (!ids.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() { + for (final s in _active.values) { + s.stop(); + } + _active.clear(); + } +} + +class _AccountSync { + _AccountSync(this.account, this._accounts, this._mailboxes, this._emails); + + final Account account; + final AccountRepository _accounts; + final MailboxRepository _mailboxes; + final EmailRepository _emails; + + imap.ImapClient? _idleClient; + bool _running = false; + int _backoffSeconds = 5; + + void start() { + _running = true; + _loop(); + } + + void stop() { + _running = false; + _idleClient?.logout().ignore(); + _idleClient = null; + } + + Future _loop() async { + while (_running) { + try { + await _sync(); + await _idle(); + _backoffSeconds = 5; + } catch (_) { + await Future.delayed(Duration(seconds: _backoffSeconds)); + _backoffSeconds = (_backoffSeconds * 2).clamp(5, 300); + } + } + } + + Future _sync() async { + await _mailboxes.syncMailboxes(account.id); + await _emails.syncEmails(account.id, 'INBOX'); + } + + Future _idle() async { + if (!_running) return; + 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; + } +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart new file mode 100644 index 0000000..7a78bfc --- /dev/null +++ b/lib/data/db/database.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +part 'database.g.dart'; + +// ── Tables ──────────────────────────────────────────────────────────────────── + +class Accounts extends Table { + TextColumn get id => text()(); + TextColumn get displayName => text()(); + TextColumn get email => text()(); + TextColumn get imapHost => text()(); + IntColumn get imapPort => integer()(); + BoolColumn get imapSsl => boolean()(); + TextColumn get smtpHost => text()(); + IntColumn get smtpPort => integer()(); + BoolColumn get smtpSsl => boolean()(); + + @override + Set get primaryKey => {id}; +} + +class Mailboxes extends Table { + TextColumn get id => text()(); + TextColumn get accountId => + text().references(Accounts, #id, onDelete: KeyAction.cascade)(); + TextColumn get path => text()(); + TextColumn get name => text()(); + IntColumn get unreadCount => integer().withDefault(const Constant(0))(); + IntColumn get totalCount => integer().withDefault(const Constant(0))(); + + @override + Set get primaryKey => {id}; +} + +class Emails extends Table { + TextColumn get id => text()(); + TextColumn get accountId => + text().references(Accounts, #id, onDelete: KeyAction.cascade)(); + TextColumn get mailboxPath => text()(); + IntColumn get uid => integer()(); + TextColumn get subject => text().nullable()(); + DateTimeColumn get sentAt => dateTime().nullable()(); + DateTimeColumn get receivedAt => dateTime()(); + // JSON-encoded List<{name,email}> + TextColumn get fromJson => text().withDefault(const Constant('[]'))(); + TextColumn get toJson => text().withDefault(const Constant('[]'))(); + TextColumn get ccJson => text().withDefault(const Constant('[]'))(); + TextColumn get preview => text().nullable()(); + BoolColumn get isSeen => boolean().withDefault(const Constant(false))(); + BoolColumn get isFlagged => boolean().withDefault(const Constant(false))(); + BoolColumn get hasAttachment => boolean().withDefault(const Constant(false))(); + + @override + Set get primaryKey => {id}; +} + +class EmailBodies extends Table { + TextColumn get emailId => + text().references(Emails, #id, onDelete: KeyAction.cascade)(); + TextColumn get textBody => text().nullable()(); + TextColumn get htmlBody => text().nullable()(); + // JSON-encoded List<{filename,contentType,size}> + TextColumn get attachmentsJson => + text().withDefault(const Constant('[]'))(); + + @override + Set get primaryKey => {emailId}; +} + +// ── Database ────────────────────────────────────────────────────────────────── + +@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + @override + int get schemaVersion => 1; +} + +LazyDatabase _openConnection() { + return LazyDatabase(() async { + final dir = await getApplicationDocumentsDirectory(); + final file = File(p.join(dir.path, 'sharedinbox.db')); + return NativeDatabase.createInBackground(file); + }); +} diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart new file mode 100644 index 0000000..2e3737d --- /dev/null +++ b/lib/data/imap/imap_client_factory.dart @@ -0,0 +1,31 @@ +import 'package:enough_mail/enough_mail.dart'; + +import '../../core/models/account.dart'; + +/// Opens an authenticated IMAP client for the given account. +Future connectImap(Account account, String password) async { + final client = ImapClient(isLogEnabled: false); + await client.connectToServer( + account.imapHost, + account.imapPort, + isSecure: account.imapSsl, + ); + await client.login(account.email, password); + return client; +} + +/// Opens an authenticated SMTP client for the given account. +Future connectSmtp(Account account, String password) async { + final client = SmtpClient(account.displayName, isLogEnabled: false); + await client.connectToServer( + account.smtpHost, + account.smtpPort, + isSecure: account.smtpSsl, + ); + await client.ehlo(); + if (!account.smtpSsl) { + await client.startTls(); + } + await client.authenticate(account.email, password, AuthMechanism.plain); + return client; +} diff --git a/lib/data/repositories/account_repository_impl.dart b/lib/data/repositories/account_repository_impl.dart new file mode 100644 index 0000000..f247ee9 --- /dev/null +++ b/lib/data/repositories/account_repository_impl.dart @@ -0,0 +1,75 @@ +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../../core/models/account.dart'; +import '../../core/repositories/account_repository.dart'; +import '../db/database.dart'; +import '../db/database.dart' as db show Account; + +class AccountRepositoryImpl implements AccountRepository { + AccountRepositoryImpl(this._db); + + final AppDatabase _db; + final _storage = const FlutterSecureStorage(); + + @override + Stream> observeAccounts() { + return _db.select(_db.accounts).watch().map( + (rows) => rows.map(_toModel).toList(), + ); + } + + @override + Future getAccount(String id) async { + final row = await (_db.select(_db.accounts) + ..where((t) => t.id.equals(id))) + .getSingleOrNull(); + return row == null ? null : _toModel(row); + } + + @override + Future addAccount(Account account, String password) async { + await _db.into(_db.accounts).insertOnConflictUpdate( + AccountsCompanion.insert( + id: account.id, + displayName: account.displayName, + email: account.email, + imapHost: account.imapHost, + imapPort: account.imapPort, + imapSsl: account.imapSsl, + smtpHost: account.smtpHost, + smtpPort: account.smtpPort, + smtpSsl: account.smtpSsl, + ), + ); + await _storage.write(key: _passwordKey(account.id), value: password); + } + + @override + Future removeAccount(String id) async { + await (_db.delete(_db.accounts)..where((t) => t.id.equals(id))).go(); + await _storage.delete(key: _passwordKey(id)); + } + + @override + Future getPassword(String accountId) async { + final pw = await _storage.read(key: _passwordKey(accountId)); + if (pw == null) throw StateError('No password stored for account $accountId'); + return pw; + } + + String _passwordKey(String accountId) => 'account_password_$accountId'; + + Account _toModel(db.Account row) => Account( + id: row.id, + displayName: row.displayName, + email: row.email, + imapHost: row.imapHost, + imapPort: row.imapPort, + imapSsl: row.imapSsl, + smtpHost: row.smtpHost, + smtpPort: row.smtpPort, + smtpSsl: row.smtpSsl, + ); +} diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart new file mode 100644 index 0000000..ac7861e --- /dev/null +++ b/lib/data/repositories/email_repository_impl.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:enough_mail/enough_mail.dart' as imap; + +import '../../core/models/email.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); + + final AppDatabase _db; + final AccountRepository _accounts; + + // ── Observe ──────────────────────────────────────────────────────────────── + + @override + Stream> observeEmails(String accountId, String mailboxPath) { + return (_db.select(_db.emails) + ..where( + (t) => + t.accountId.equals(accountId) & + t.mailboxPath.equals(mailboxPath), + ) + ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) + .watch() + .map((rows) => rows.map(_toModel).toList()); + } + + // ── Body (on-demand) ─────────────────────────────────────────────────────── + + @override + Future getEmailBody(String emailId) async { + final cached = await (_db.select(_db.emailBodies) + ..where((t) => t.emailId.equals(emailId))) + .getSingleOrNull(); + if (cached != null) return _toBodyModel(cached); + + final emailRow = await (_db.select(_db.emails) + ..where((t) => t.id.equals(emailId))) + .getSingle(); + final account = (await _accounts.getAccount(emailRow.accountId))!; + 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), + ), + ); + final fetch = await client.fetchMessage( + imap.MessageSequence.fromId(emailRow.uid, isUid: true), + '(RFC822)', + ); + final msg = fetch.messages.first; + final mime = imap.MimeMessage.parseFromData(msg.rawData!); + final textBody = mime.decodeTextPlainPart(); + final htmlBody = mime.decodeTextHtmlPart(); + final attachments = mime.findContentInfo( + disposition: imap.ContentDisposition.attachment, + ); + + final attachmentsJson = jsonEncode( + attachments + .map( + (a) => { + 'filename': a.fileName ?? '', + 'contentType': a.contentType?.mediaType.text ?? '', + 'size': a.size ?? 0, + }, + ) + .toList(), + ); + + await _db.into(_db.emailBodies).insertOnConflictUpdate( + EmailBodiesCompanion.insert( + emailId: emailId, + textBody: Value(textBody), + htmlBody: Value(htmlBody), + attachmentsJson: Value(attachmentsJson), + ), + ); + return _toBodyModel( + EmailBody( + emailId: emailId, + textBody: textBody, + htmlBody: htmlBody, + attachments: _parseAttachments(attachmentsJson), + ), + ); + } finally { + await client.logout(); + } + } + + // ── Sync ─────────────────────────────────────────────────────────────────── + + @override + Future syncEmails(String accountId, String mailboxPath) async { + final account = (await _accounts.getAccount(accountId))!; + 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, + '(UID FLAGS ENVELOPE BODYSTRUCTURE)', + ); + for (final msg in messages.messages) { + final mime = msg.envelope; + if (mime == null) continue; + final emailId = '${accountId}:${msg.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() ?? + [], + ), + ), + isSeen: Value(msg.flags?.contains(r'\Seen') ?? false), + isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false), + hasAttachment: Value(msg.body?.hasAttachments ?? false), + ), + ); + } + } finally { + await client.logout(); + } + } + + // ── Mutations ────────────────────────────────────────────────────────────── + + @override + Future setFlag(String emailId, {bool? seen, bool? flagged}) async { + final row = await (_db.select(_db.emails) + ..where((t) => t.id.equals(emailId))) + .getSingle(); + final account = (await _accounts.getAccount(row.accountId))!; + final password = await _accounts.getPassword(account.id); + final client = await connectImap(account, password); + try { + await client.selectMailboxByPath(row.mailboxPath); + final seq = + imap.MessageSequence.fromId(row.uid, isUid: true); + if (seen != null) { + seen + ? await client.markSeen(seq, isUidSequence: true) + : await client.markUnseen(seq, isUidSequence: true); + } + if (flagged != null) { + flagged + ? await client.markFlagged(seq, isUidSequence: true) + : await client.markUnflagged(seq, isUidSequence: true); + } + await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))) + .write( + EmailsCompanion( + isSeen: seen != null ? Value(seen) : const Value.absent(), + isFlagged: flagged != null ? Value(flagged) : const Value.absent(), + ), + ); + } finally { + await client.logout(); + } + } + + @override + Future moveEmail(String emailId, String destMailboxPath) async { + final row = await (_db.select(_db.emails) + ..where((t) => t.id.equals(emailId))) + .getSingle(); + final account = (await _accounts.getAccount(row.accountId))!; + final password = await _accounts.getPassword(account.id); + final client = await connectImap(account, password); + try { + await client.selectMailboxByPath(row.mailboxPath); + await client.move( + imap.MessageSequence.fromId(row.uid, isUid: true), + targetMailboxPath: destMailboxPath, + isUidSequence: true, + ); + await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go(); + } finally { + await client.logout(); + } + } + + @override + Future deleteEmail(String emailId) async { + final row = await (_db.select(_db.emails) + ..where((t) => t.id.equals(emailId))) + .getSingle(); + final account = (await _accounts.getAccount(row.accountId))!; + final password = await _accounts.getPassword(account.id); + final client = await connectImap(account, password); + try { + await client.selectMailboxByPath(row.mailboxPath); + await client.deleteMessages( + imap.MessageSequence.fromId(row.uid, isUid: true), + isUidSequence: true, + ); + await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go(); + } finally { + await client.logout(); + } + } + + @override + Future sendEmail(String accountId, EmailDraft draft) async { + final account = (await _accounts.getAccount(accountId))!; + final password = await _accounts.getPassword(accountId); + final smtpClient = await connectSmtp(account, password); + try { + final builder = imap.MessageBuilder() + ..from = [ + imap.MailAddress(draft.from.name, draft.from.email), + ] + ..to = draft.to + .map((a) => imap.MailAddress(a.name, a.email)) + .toList() + ..cc = draft.cc + .map((a) => imap.MailAddress(a.name, a.email)) + .toList() + ..subject = draft.subject + ..text = draft.body; + final message = builder.buildMimeMessage(); + await smtpClient.sendMessage(message); + } finally { + smtpClient.disconnect(); + } + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + Email _toModel(db.Email row) { + List parseAddresses(String json) { + final list = jsonDecode(json) as List; + return list + .map( + (e) => EmailAddress( + name: e['name'] as String?, + email: e['email'] as String, + ), + ) + .toList(); + } + + return Email( + id: row.id, + accountId: row.accountId, + mailboxPath: row.mailboxPath, + uid: row.uid, + subject: row.subject, + sentAt: row.sentAt, + receivedAt: row.receivedAt, + from: parseAddresses(row.fromJson), + to: parseAddresses(row.toJson), + cc: parseAddresses(row.ccJson), + preview: row.preview, + isSeen: row.isSeen, + isFlagged: row.isFlagged, + hasAttachment: row.hasAttachment, + ); + } + + EmailBody _toBodyModel(dynamic row) { + if (row is db.EmailBody) { + return 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; + return list + .map( + (e) => EmailAttachment( + filename: e['filename'] as String, + contentType: e['contentType'] as String, + size: e['size'] as int, + ), + ) + .toList(); + } +} diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart new file mode 100644 index 0000000..18d6e25 --- /dev/null +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -0,0 +1,60 @@ +import 'package:drift/drift.dart'; +import 'package:enough_mail/enough_mail.dart' as imap; + +import '../../core/models/mailbox.dart'; +import '../../core/repositories/account_repository.dart'; +import '../../core/repositories/mailbox_repository.dart'; +import '../db/database.dart'; +import '../db/database.dart' as db show Mailbox; +import '../imap/imap_client_factory.dart'; + +class MailboxRepositoryImpl implements MailboxRepository { + MailboxRepositoryImpl(this._db, this._accounts); + + final AppDatabase _db; + final AccountRepository _accounts; + + @override + Stream> observeMailboxes(String accountId) { + return (_db.select(_db.mailboxes) + ..where((t) => t.accountId.equals(accountId)) + ..orderBy([(t) => OrderingTerm.asc(t.path)])) + .watch() + .map((rows) => rows.map(_toModel).toList()); + } + + @override + Future syncMailboxes(String accountId) async { + final account = (await _accounts.getAccount(accountId))!; + 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; + final id = '${accountId}:$path'; + 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), + ), + ); + } + } finally { + await client.logout(); + } + } + + Mailbox _toModel(db.Mailbox row) => Mailbox( + id: row.id, + accountId: row.accountId, + path: row.path, + name: row.name, + unreadCount: row.unreadCount, + totalCount: row.totalCount, + ); +} diff --git a/lib/di.dart b/lib/di.dart new file mode 100644 index 0000000..856fb63 --- /dev/null +++ b/lib/di.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/repositories/account_repository.dart'; +import 'core/repositories/email_repository.dart'; +import 'core/repositories/mailbox_repository.dart'; +import 'core/sync/account_sync_manager.dart'; +import 'data/db/database.dart'; +import 'data/repositories/account_repository_impl.dart'; +import 'data/repositories/email_repository_impl.dart'; +import 'data/repositories/mailbox_repository_impl.dart'; + +final dbProvider = Provider((ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +}); + +final accountRepositoryProvider = Provider((ref) { + return AccountRepositoryImpl(ref.watch(dbProvider)); +}); + +final mailboxRepositoryProvider = Provider((ref) { + return MailboxRepositoryImpl( + ref.watch(dbProvider), + ref.watch(accountRepositoryProvider), + ); +}); + +final emailRepositoryProvider = Provider((ref) { + return EmailRepositoryImpl( + ref.watch(dbProvider), + ref.watch(accountRepositoryProvider), + ); +}); + +final syncManagerProvider = Provider((ref) { + final manager = AccountSyncManager( + ref.watch(accountRepositoryProvider), + ref.watch(mailboxRepositoryProvider), + ref.watch(emailRepositoryProvider), + ); + ref.onDispose(manager.dispose); + return manager; +}); diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..6dbd9fd --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'di.dart'; +import 'ui/router.dart'; + +void main() { + runApp(const ProviderScope(child: SharedInboxApp())); +} + +class SharedInboxApp extends ConsumerWidget { + const SharedInboxApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Start background sync + ref.watch(syncManagerProvider).start(); + + return MaterialApp.router( + title: 'SharedInbox', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.dark, + ), + useMaterial3: true, + ), + routerConfig: router, + ); + } +} diff --git a/lib/ui/router.dart b/lib/ui/router.dart new file mode 100644 index 0000000..37025e6 --- /dev/null +++ b/lib/ui/router.dart @@ -0,0 +1,64 @@ +import 'package:go_router/go_router.dart'; + +import 'screens/account_list_screen.dart'; +import 'screens/add_account_screen.dart'; +import 'screens/mailbox_list_screen.dart'; +import 'screens/email_list_screen.dart'; +import 'screens/email_detail_screen.dart'; +import 'screens/compose_screen.dart'; +import 'screens/settings_screen.dart'; + +final router = GoRouter( + initialLocation: '/accounts', + routes: [ + GoRoute( + path: '/accounts', + builder: (ctx, state) => const AccountListScreen(), + routes: [ + GoRoute( + path: 'add', + builder: (ctx, state) => const AddAccountScreen(), + ), + GoRoute( + path: ':accountId/mailboxes', + builder: (ctx, state) => + MailboxListScreen(accountId: state.pathParameters['accountId']!), + routes: [ + GoRoute( + path: ':mailboxPath/emails', + builder: (ctx, state) => EmailListScreen( + accountId: state.pathParameters['accountId']!, + mailboxPath: state.pathParameters['mailboxPath']!, + ), + routes: [ + GoRoute( + path: ':emailId', + builder: (ctx, state) => EmailDetailScreen( + emailId: state.pathParameters['emailId']!, + ), + ), + ], + ), + ], + ), + ], + ), + GoRoute( + path: '/compose', + builder: (ctx, state) { + final extra = state.extra as Map?; + return ComposeScreen( + accountId: extra?['accountId'] as String?, + replyToEmailId: extra?['replyToEmailId'] as String?, + prefillTo: extra?['prefillTo'] as String?, + prefillSubject: extra?['prefillSubject'] as String?, + prefillBody: extra?['prefillBody'] as String?, + ); + }, + ), + GoRoute( + path: '/settings', + builder: (ctx, state) => const SettingsScreen(), + ), + ], +); diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart new file mode 100644 index 0000000..91f29b6 --- /dev/null +++ b/lib/ui/screens/account_list_screen.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../di.dart'; + +class AccountListScreen extends ConsumerWidget { + const AccountListScreen({super.key}); + + @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'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () => context.push('/settings'), + ), + ], + ), + body: StreamBuilder( + stream: ref.watch(accountRepositoryProvider).observeAccounts(), + builder: (ctx, snap) { + if (!snap.hasData) return const Center(child: CircularProgressIndicator()); + final accounts = snap.data!; + if (accounts.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('No accounts yet.'), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () => context.push('/accounts/add'), + icon: const Icon(Icons.add), + label: const Text('Add account'), + ), + ], + ), + ); + } + return ListView.builder( + itemCount: accounts.length, + itemBuilder: (ctx, i) { + final a = accounts[i]; + return ListTile( + leading: const Icon(Icons.account_circle), + title: Text(a.displayName), + subtitle: Text(a.email), + onTap: () => + context.push('/accounts/${a.id}/mailboxes'), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/accounts/add'), + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart new file mode 100644 index 0000000..7f2d7f8 --- /dev/null +++ b/lib/ui/screens/add_account_screen.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/models/account.dart'; +import '../../di.dart'; + +class AddAccountScreen extends ConsumerStatefulWidget { + const AddAccountScreen({super.key}); + + @override + ConsumerState createState() => _AddAccountScreenState(); +} + +class _AddAccountScreenState extends ConsumerState { + final _form = GlobalKey(); + final _displayName = TextEditingController(); + final _email = TextEditingController(); + final _password = TextEditingController(); + final _imapHost = TextEditingController(); + final _imapPort = TextEditingController(text: '993'); + bool _imapSsl = true; + final _smtpHost = TextEditingController(); + final _smtpPort = TextEditingController(text: '587'); + bool _smtpSsl = false; + bool _saving = false; + + @override + void dispose() { + for (final c in [ + _displayName, + _email, + _password, + _imapHost, + _imapPort, + _smtpHost, + _smtpPort, + ]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + if (!_form.currentState!.validate()) return; + setState(() => _saving = true); + try { + final account = Account( + id: DateTime.now().millisecondsSinceEpoch.toString(), + displayName: _displayName.text.trim(), + email: _email.text.trim(), + imapHost: _imapHost.text.trim(), + imapPort: int.parse(_imapPort.text), + imapSsl: _imapSsl, + smtpHost: _smtpHost.text.trim(), + smtpPort: int.parse(_smtpPort.text), + smtpSsl: _smtpSsl, + ); + await ref + .read(accountRepositoryProvider) + .addAccount(account, _password.text); + if (mounted) context.pop(); + } catch (e) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Add account')), + body: Form( + key: _form, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _field(_displayName, 'Display name'), + _field(_email, 'Email address', keyboardType: TextInputType.emailAddress), + _field(_password, 'Password', obscure: true), + const Divider(), + const Text('IMAP', style: TextStyle(fontWeight: FontWeight.bold)), + _field(_imapHost, 'IMAP host'), + _field(_imapPort, 'Port', keyboardType: TextInputType.number), + SwitchListTile( + title: const Text('SSL/TLS'), + value: _imapSsl, + onChanged: (v) => setState(() => _imapSsl = v), + ), + const Divider(), + const Text('SMTP', style: TextStyle(fontWeight: FontWeight.bold)), + _field(_smtpHost, 'SMTP host'), + _field(_smtpPort, 'Port', keyboardType: TextInputType.number), + SwitchListTile( + title: const Text('SSL/TLS'), + value: _smtpSsl, + onChanged: (v) => setState(() => _smtpSsl = v), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + ), + ); + } + + Widget _field( + TextEditingController ctrl, + String label, { + bool obscure = false, + TextInputType? keyboardType, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: TextFormField( + controller: ctrl, + obscureText: obscure, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + ); + } +} diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart new file mode 100644 index 0000000..5799fc5 --- /dev/null +++ b/lib/ui/screens/compose_screen.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/models/email.dart'; +import '../../di.dart'; + +class ComposeScreen extends ConsumerStatefulWidget { + const ComposeScreen({ + super.key, + this.accountId, + this.replyToEmailId, + this.prefillTo, + this.prefillSubject, + this.prefillBody, + }); + + final String? accountId; + final String? replyToEmailId; + final String? prefillTo; + final String? prefillSubject; + final String? prefillBody; + + @override + ConsumerState createState() => _ComposeScreenState(); +} + +class _ComposeScreenState extends ConsumerState { + final _to = TextEditingController(); + final _cc = TextEditingController(); + final _subject = TextEditingController(); + final _body = TextEditingController(); + String? _accountId; + bool _sending = false; + + @override + void initState() { + super.initState(); + if (widget.prefillTo != null) _to.text = widget.prefillTo!; + if (widget.prefillSubject != null) _subject.text = widget.prefillSubject!; + if (widget.prefillBody != null) _body.text = widget.prefillBody!; + _accountId = widget.accountId; + } + + @override + void dispose() { + for (final c in [_to, _cc, _subject, _body]) { + c.dispose(); + } + super.dispose(); + } + + Future _send() async { + if (_accountId == null) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Select an account first'))); + return; + } + setState(() => _sending = true); + try { + final account = + (await ref.read(accountRepositoryProvider).getAccount(_accountId!))!; + final draft = EmailDraft( + from: EmailAddress(name: account.displayName, email: account.email), + to: _to.text + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .map((e) => EmailAddress(email: e)) + .toList(), + cc: _cc.text + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .map((e) => EmailAddress(email: e)) + .toList(), + subject: _subject.text, + body: _body.text, + ); + await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft); + if (mounted) context.pop(); + } catch (e) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Send failed: $e'))); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Compose'), + actions: [ + IconButton( + icon: _sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + onPressed: _sending ? null : _send, + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _field(_to, 'To', keyboardType: TextInputType.emailAddress), + _field(_cc, 'Cc', keyboardType: TextInputType.emailAddress), + _field(_subject, 'Subject'), + const SizedBox(height: 8), + TextFormField( + controller: _body, + maxLines: null, + minLines: 10, + decoration: const InputDecoration( + labelText: 'Body', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + ], + ), + ); + } + + Widget _field( + TextEditingController ctrl, + String label, { + TextInputType? keyboardType, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: TextFormField( + controller: ctrl, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); + } +} diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart new file mode 100644 index 0000000..ab7d6ad --- /dev/null +++ b/lib/ui/screens/email_detail_screen.dart @@ -0,0 +1,140 @@ +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 '../../core/models/email.dart'; +import '../../di.dart'; + +final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm'); + +class EmailDetailScreen extends ConsumerStatefulWidget { + const EmailDetailScreen({super.key, required this.emailId}); + final String emailId; + + @override + ConsumerState createState() => _EmailDetailScreenState(); +} + +class _EmailDetailScreenState extends ConsumerState { + late Future _bodyFuture; + + @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); + } + + @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 ?? '') , + ), + 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)), + ), + ], + ], + ); + }, + ), + ); + } + + Widget _buildHeader(BuildContext ctx, EmailBody body) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + body.emailId, // we'd look up subject from DB in real code + style: Theme.of(ctx).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ], + ); + } + + void _reply(BuildContext context, {required bool replyAll}) { + context.push('/compose', extra: { + 'replyToEmailId': widget.emailId, + }); + } + + String _htmlToPlain(String html) { + return html + .replaceAll(RegExp(r''), '\n') + .replaceAll(RegExp(r'<[^>]+>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(' ', ' '); + } + + String _fmtSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart new file mode 100644 index 0000000..8aaf4d0 --- /dev/null +++ b/lib/ui/screens/email_list_screen.dart @@ -0,0 +1,88 @@ +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 '../../di.dart'; + +final _dateFmt = DateFormat('MMM d'); + +class EmailListScreen extends ConsumerWidget { + const EmailListScreen({ + super.key, + required this.accountId, + required this.mailboxPath, + }); + + final String accountId; + final String mailboxPath; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repo = ref.watch(emailRepositoryProvider); + return Scaffold( + appBar: AppBar( + title: Text(mailboxPath), + actions: [ + IconButton( + icon: const Icon(Icons.sync), + onPressed: () => + repo.syncEmails(accountId, mailboxPath), + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => context.push( + '/compose', + extra: {'accountId': accountId}, + ), + ), + ], + ), + body: StreamBuilder( + stream: repo.observeEmails(accountId, mailboxPath), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final emails = snap.data!; + if (emails.isEmpty) { + return const Center(child: Text('No emails')); + } + return ListView.builder( + itemCount: emails.length, + itemBuilder: (ctx, i) { + final e = emails[i]; + final sender = e.from.isNotEmpty + ? (e.from.first.name ?? e.from.first.email) + : '(unknown)'; + return ListTile( + leading: Icon( + e.isSeen ? Icons.mail_outline : Icons.mail, + color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary, + ), + title: Text( + sender, + style: e.isSeen + ? null + : const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + e.subject ?? '(no subject)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Text( + e.sentAt != null ? _dateFmt.format(e.sentAt!) : '', + style: Theme.of(ctx).textTheme.bodySmall, + ), + onTap: () => context.push( + '/accounts/$accountId/mailboxes/${Uri.encodeComponent(mailboxPath)}/emails/${Uri.encodeComponent(e.id)}', + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/ui/screens/mailbox_list_screen.dart b/lib/ui/screens/mailbox_list_screen.dart new file mode 100644 index 0000000..99a68a5 --- /dev/null +++ b/lib/ui/screens/mailbox_list_screen.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../di.dart'; + +class MailboxListScreen extends ConsumerWidget { + const MailboxListScreen({super.key, required this.accountId}); + final String accountId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repo = ref.watch(mailboxRepositoryProvider); + return Scaffold( + appBar: AppBar(title: const Text('Mailboxes')), + body: StreamBuilder( + stream: repo.observeMailboxes(accountId), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final mailboxes = snap.data!; + return ListView.builder( + itemCount: mailboxes.length, + itemBuilder: (ctx, i) { + final mb = mailboxes[i]; + return ListTile( + leading: const Icon(Icons.folder), + title: Text(mb.name), + trailing: mb.unreadCount > 0 + ? Badge(label: Text('${mb.unreadCount}')) + : null, + onTap: () => context.push( + '/accounts/$accountId/mailboxes/${Uri.encodeComponent(mb.path)}/emails', + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart new file mode 100644 index 0000000..024b457 --- /dev/null +++ b/lib/ui/screens/settings_screen.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../di.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repo = ref.watch(accountRepositoryProvider); + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: StreamBuilder( + stream: repo.observeAccounts(), + builder: (ctx, snap) { + final accounts = snap.data ?? []; + return ListView( + children: [ + const ListTile( + title: Text( + 'Accounts', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final a in accounts) + ListTile( + leading: const Icon(Icons.account_circle), + title: Text(a.displayName), + subtitle: Text(a.email), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () async { + final confirm = await showDialog( + context: ctx, + builder: (ctx) => AlertDialog( + title: const Text('Remove account?'), + content: Text( + 'Remove ${a.displayName}? Local data will be deleted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirm == true) { + await repo.removeAccount(a.id); + } + }, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/packages/enough_mail b/packages/enough_mail new file mode 160000 index 0000000..25320ad --- /dev/null +++ b/packages/enough_mail @@ -0,0 +1 @@ +Subproject commit 25320adab0d9c1d98c3602ebf53fb15e28e3a0e9 diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9167c78 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,48 @@ +name: sharedinbox +description: IMAP email client for Android, iOS, and Desktop. +publish_to: none +version: 0.1.0 + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.22.0' + +dependencies: + flutter: + sdk: flutter + + # Vendored IMAP/SMTP/MIME library + enough_mail: + path: packages/enough_mail + + # Local persistence (offline-first) + drift: ^2.20.3 + sqlite3_flutter_libs: ^0.5.28 + path_provider: ^2.1.5 + path: ^1.9.1 + + # State management + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.4.1 + + # Navigation + go_router: ^14.8.1 + + # Utilities + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 + intl: any + collection: ^1.19.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + drift_dev: ^2.20.3 + riverpod_generator: ^2.6.2 + freezed: ^2.5.8 + json_serializable: ^6.9.0 + build_runner: ^2.4.13 + +flutter: + uses-material-design: true