Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92eb72c7fc | ||
|
|
86f81c0515 | ||
|
|
2b12cb61e7 | ||
|
|
de0c065588 | ||
|
|
fba5f065c4 |
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 38;
|
const int dbSchemaVersion = 39;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class EmailNote {
|
||||||
|
final String id; // UUID (X-SharedInbox-Note-Id)
|
||||||
|
final String accountId;
|
||||||
|
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
|
||||||
|
final String noteText;
|
||||||
|
final String serverId; // IMAP UID (as string) or JMAP email ID
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const EmailNote({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.messageId,
|
||||||
|
required this.noteText,
|
||||||
|
required this.serverId,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
|
||||||
|
abstract class NoteRepository {
|
||||||
|
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Fetches notes from the server into the local cache.
|
||||||
|
Future<void> syncNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Creates a new note on the server and caches it locally.
|
||||||
|
Future<void> addNote(String accountId, String messageId, String text);
|
||||||
|
|
||||||
|
/// Deletes a note from the server and removes it from the local cache.
|
||||||
|
Future<void> deleteNote(String noteId);
|
||||||
|
}
|
||||||
@@ -318,6 +318,26 @@ class ImageTrustedSenders extends Table {
|
|||||||
Set<Column> get primaryKey => {senderEmail};
|
Set<Column> get primaryKey => {senderEmail};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
|
||||||
|
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
|
||||||
|
// Added in schema v39.
|
||||||
|
@DataClassName('EmailNoteRow')
|
||||||
|
class EmailNotes extends Table {
|
||||||
|
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
|
||||||
|
TextColumn get id => text()();
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
|
||||||
|
TextColumn get messageId => text()();
|
||||||
|
TextColumn get noteText => text()();
|
||||||
|
// IMAP UID (as string) or JMAP email ID of the note message on the server.
|
||||||
|
TextColumn get serverId => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
@DataClassName('UserPreferencesRow')
|
@DataClassName('UserPreferencesRow')
|
||||||
class UserPreferences extends Table {
|
class UserPreferences extends Table {
|
||||||
@@ -363,6 +383,7 @@ class UserPreferences extends Table {
|
|||||||
ShareKeys,
|
ShareKeys,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
ImageTrustedSenders,
|
ImageTrustedSenders,
|
||||||
|
EmailNotes,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -639,6 +660,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
userPreferences.bodyCacheLimitMb,
|
userPreferences.bodyCacheLimitMb,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 39) {
|
||||||
|
await m.createTable(emailNotes);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||||
|
|
||||||
|
const _notesFolder = 'Notes';
|
||||||
|
const _headerNoteFor = 'X-SharedInbox-Note-For';
|
||||||
|
const _headerNoteId = 'X-SharedInbox-Note-Id';
|
||||||
|
|
||||||
|
class NoteRepositoryImpl implements NoteRepository {
|
||||||
|
NoteRepositoryImpl(
|
||||||
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn imapConnect = connectImap,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) : _imapConnect = imapConnect,
|
||||||
|
_httpClient = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accounts;
|
||||||
|
final ImapConnectFn _imapConnect;
|
||||||
|
final http.Client _httpClient;
|
||||||
|
|
||||||
|
String _effectiveUsername(account_model.Account account) =>
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
|
||||||
|
// ── Observe (local cache) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
|
||||||
|
return (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||||
|
)
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toModel).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync (server → local cache) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncNotes(String accountId, String messageId) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _syncNotesImap(account, password, messageId);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _syncNotesJmap(account, password, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder doesn't exist — nothing to sync.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||||
|
final searchResult = await client.uidSearchMessages(
|
||||||
|
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
|
||||||
|
);
|
||||||
|
final uids = searchResult.matchingSequence?.toList() ?? [];
|
||||||
|
|
||||||
|
if (uids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
|
||||||
|
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final msg in fetch.messages) {
|
||||||
|
final uid = msg.uid;
|
||||||
|
if (uid == null) continue;
|
||||||
|
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: msg.decodeTextPlainPart() ?? '',
|
||||||
|
serverId: uid.toString(),
|
||||||
|
createdAt: msg.decodeDate() ?? DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes (deleted on the server).
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (mailboxId == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'filter': {'inMailbox': mailboxId},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final ids = List<String>.from(
|
||||||
|
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final getResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': ids,
|
||||||
|
'properties': [
|
||||||
|
'id',
|
||||||
|
'receivedAt',
|
||||||
|
'textBody',
|
||||||
|
'bodyValues',
|
||||||
|
'header:$_headerNoteFor:asText',
|
||||||
|
'header:$_headerNoteId:asText',
|
||||||
|
],
|
||||||
|
'fetchTextBodyValues': true,
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list =
|
||||||
|
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final e in list) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
|
||||||
|
if (noteFor != messageId) continue;
|
||||||
|
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
final jmapEmailId = m['id'] as String;
|
||||||
|
|
||||||
|
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
|
||||||
|
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
|
||||||
|
var noteText = '';
|
||||||
|
if (textBodyParts.isNotEmpty) {
|
||||||
|
final partId =
|
||||||
|
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
|
||||||
|
if (partId != null) {
|
||||||
|
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
|
||||||
|
as String? ??
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final createdAt =
|
||||||
|
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: noteText,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes.
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) & t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addNote(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
final noteId = _generateId();
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _addNoteImap(account, password, messageId, noteId, text);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _addNoteJmap(account, password, messageId, noteId, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.createMailbox(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Already exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
final builder = imap.MessageBuilder()
|
||||||
|
..subject = 'Note'
|
||||||
|
..text = text;
|
||||||
|
builder.addHeader(_headerNoteFor, messageId);
|
||||||
|
builder.addHeader(_headerNoteId, noteId);
|
||||||
|
final mime = builder.buildMimeMessage();
|
||||||
|
|
||||||
|
final appendResult = await client.appendMessage(
|
||||||
|
mime,
|
||||||
|
targetMailboxPath: _notesFolder,
|
||||||
|
);
|
||||||
|
final uidList =
|
||||||
|
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||||
|
final serverId = (uidList != null && uidList.isNotEmpty)
|
||||||
|
? uidList.first.toString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: serverId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
|
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
|
||||||
|
|
||||||
|
const bodyPartId = '1';
|
||||||
|
final setResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-note': {
|
||||||
|
'mailboxIds': {mailboxId: true},
|
||||||
|
'subject': 'Note',
|
||||||
|
'keywords': {r'$seen': true},
|
||||||
|
'headers': [
|
||||||
|
{'name': _headerNoteFor, 'value': ' $messageId'},
|
||||||
|
{'name': _headerNoteId, 'value': ' $noteId'},
|
||||||
|
],
|
||||||
|
'bodyValues': {
|
||||||
|
bodyPartId: {
|
||||||
|
'value': text,
|
||||||
|
'isEncodingProblem': false,
|
||||||
|
'isTruncated': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'textBody': [
|
||||||
|
{'partId': bodyPartId, 'type': 'text/plain'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
final result = _responseArgs(setResp, 0, 'Email/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newEmail = created?['new-note'] as Map<String, dynamic>?;
|
||||||
|
final jmapEmailId = newEmail?['id'] as String? ?? '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteNote(String noteId) async {
|
||||||
|
final noteRow = await (_db.select(_db.emailNotes)
|
||||||
|
..where((t) => t.id.equals(noteId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (noteRow == null) return;
|
||||||
|
|
||||||
|
final account = await _accounts.getAccount(noteRow.accountId);
|
||||||
|
if (account == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _deleteNoteImap(account, password, noteRow);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _deleteNoteJmap(account, password, noteRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
final uid = int.tryParse(noteRow.serverId);
|
||||||
|
if (uid != null) {
|
||||||
|
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||||
|
await client.uidMarkDeleted(seq);
|
||||||
|
await client.uidExpunge(seq);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder gone or message already deleted — clean up locally.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noteRow.serverId.isNotEmpty) {
|
||||||
|
await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'destroy': [noteRow.serverId],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JMAP helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/get',
|
||||||
|
{'accountId': jmap.accountId, 'ids': null},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
|
||||||
|
for (final m in list) {
|
||||||
|
final map = m as Map<String, dynamic>;
|
||||||
|
if (map['name'] == _notesFolder) return map['id'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final existing = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-notes': {'name': _notesFolder},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(resp, 0, 'Mailbox/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
|
||||||
|
return newMailbox?['id'] as String? ?? _notesFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _responseArgs(
|
||||||
|
List<dynamic> responses,
|
||||||
|
int index,
|
||||||
|
String expectedMethod,
|
||||||
|
) {
|
||||||
|
final triple = responses[index] as List<dynamic>;
|
||||||
|
final method = triple[0] as String;
|
||||||
|
if (method == 'error') {
|
||||||
|
final err = triple[1] as Map<String, dynamic>;
|
||||||
|
throw JmapException('$expectedMethod error: ${err['type']}');
|
||||||
|
}
|
||||||
|
return triple[1] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailNote _toModel(EmailNoteRow row) => EmailNote(
|
||||||
|
id: row.id,
|
||||||
|
accountId: row.accountId,
|
||||||
|
messageId: row.messageId,
|
||||||
|
noteText: row.noteText,
|
||||||
|
serverId: row.serverId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generates a random UUID v4.
|
||||||
|
static String _generateId() {
|
||||||
|
final rng = math.Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
|
||||||
|
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
|
||||||
|
'-${hex.substring(20)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
@@ -32,6 +34,7 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
|||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
@@ -282,3 +285,18 @@ final trustedImageSendersProvider =
|
|||||||
.watch(userPreferencesRepositoryProvider)
|
.watch(userPreferencesRepositoryProvider)
|
||||||
.observeTrustedImageSenders();
|
.observeTrustedImageSenders();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
||||||
|
return NoteRepositoryImpl(
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(accountRepositoryProvider),
|
||||||
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
||||||
|
final notesProvider =
|
||||||
|
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
||||||
|
(ref, params) =>
|
||||||
|
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
|
||||||
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
@@ -37,6 +38,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
bool _isFlagged = false;
|
bool _isFlagged = false;
|
||||||
bool _loadRemoteImages = false;
|
bool _loadRemoteImages = false;
|
||||||
final Set<String> _downloading = {};
|
final Set<String> _downloading = {};
|
||||||
|
bool _notesSynced = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -50,6 +52,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (email != null && mounted) {
|
if (email != null && mounted) {
|
||||||
setState(() => _isFlagged = email.isFlagged);
|
setState(() => _isFlagged = email.isFlagged);
|
||||||
}
|
}
|
||||||
|
if (!_notesSynced && email?.messageId != null) {
|
||||||
|
_notesSynced = true;
|
||||||
|
unawaited(
|
||||||
|
ref.read(noteRepositoryProvider).syncNotes(
|
||||||
|
email!.accountId,
|
||||||
|
email.messageId!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -257,6 +268,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
body.textBody ?? '',
|
body.textBody ?? '',
|
||||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
if (header?.messageId != null) _buildNotesSection(ctx, header!),
|
||||||
if (body.attachments.isNotEmpty) ...[
|
if (body.attachments.isNotEmpty) ...[
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -340,6 +352,114 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNotesSection(BuildContext ctx, Email header) {
|
||||||
|
final messageId = header.messageId!;
|
||||||
|
final notes = ref.watch(notesProvider((header.accountId, messageId)));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
'Notes',
|
||||||
|
style: Theme.of(ctx).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add'),
|
||||||
|
onPressed: () => unawaited(_addNoteDialog(ctx, header)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
notes.when(
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (e, _) => Text('Error loading notes: $e'),
|
||||||
|
data: (list) {
|
||||||
|
if (list.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
'No notes yet.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final note in list) _buildNoteRow(ctx, note),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNoteRow(BuildContext ctx, EmailNote note) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(note.noteText),
|
||||||
|
subtitle: Text(
|
||||||
|
DateFormat('MMM d, HH:mm').format(note.createdAt),
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20),
|
||||||
|
tooltip: 'Delete note',
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteDialog(BuildContext context, Email header) async {
|
||||||
|
final messageId = header.messageId;
|
||||||
|
if (messageId == null) return;
|
||||||
|
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Add note'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(hintText: 'Type a note…'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = ctrl.text.trim();
|
||||||
|
ctrl.dispose();
|
||||||
|
if (confirmed != true || text.isEmpty) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
await ref.read(noteRepositoryProvider).addNote(
|
||||||
|
header.accountId,
|
||||||
|
messageId,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext ctx, Email email) {
|
Widget _buildHeader(BuildContext ctx, Email email) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const _noCode = {
|
|||||||
'lib/core/repositories/user_preferences_repository.dart',
|
'lib/core/repositories/user_preferences_repository.dart',
|
||||||
'lib/core/models/undo_action.dart',
|
'lib/core/models/undo_action.dart',
|
||||||
'lib/core/models/user_preferences.dart',
|
'lib/core/models/user_preferences.dart',
|
||||||
|
'lib/core/models/note.dart',
|
||||||
|
'lib/core/repositories/note_repository.dart',
|
||||||
'lib/core/storage/secure_storage.dart',
|
'lib/core/storage/secure_storage.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ const _excluded = {
|
|||||||
'lib/core/services/update_service.dart',
|
'lib/core/services/update_service.dart',
|
||||||
'lib/ui/widgets/email_thread_tile.dart',
|
'lib/ui/widgets/email_thread_tile.dart',
|
||||||
'lib/ui/screens/trusted_image_senders_screen.dart',
|
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||||
|
'lib/data/repositories/note_repository_impl.dart',
|
||||||
'lib/ui/widgets/thread_tile.dart',
|
'lib/ui/widgets/thread_tile.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ void main() {
|
|||||||
group('Migration', () {
|
group('Migration', () {
|
||||||
test('schemaVersion matches expected value', () async {
|
test('schemaVersion matches expected value', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
expect(db.schemaVersion, 38);
|
expect(db.schemaVersion, 39);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,12 +424,15 @@ void main() {
|
|||||||
expect(userPrefsColumns, contains('prefetch_mode'));
|
expect(userPrefsColumns, contains('prefetch_mode'));
|
||||||
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
||||||
|
|
||||||
|
// v39: email_notes table.
|
||||||
|
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('fresh install creates all tables at schemaVersion 38', () async {
|
test('fresh install creates all tables at schemaVersion 39', () async {
|
||||||
final db = AppDatabase(NativeDatabase.memory());
|
final db = AppDatabase(NativeDatabase.memory());
|
||||||
await db.select(db.accounts).get();
|
await db.select(db.accounts).get();
|
||||||
|
|
||||||
@@ -458,6 +461,7 @@ void main() {
|
|||||||
'local_sieve_applied', // v32
|
'local_sieve_applied', // v32
|
||||||
'user_preferences', // v34
|
'user_preferences', // v34
|
||||||
'image_trusted_senders', // v37
|
'image_trusted_senders', // v37
|
||||||
|
'email_notes', // v39
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -493,6 +497,9 @@ void main() {
|
|||||||
expect(userPrefsColumns, contains('prefetch_mode'));
|
expect(userPrefsColumns, contains('prefetch_mode'));
|
||||||
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
expect(userPrefsColumns, contains('body_cache_limit_mb'));
|
||||||
|
|
||||||
|
// v39: email_notes table.
|
||||||
|
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||||
|
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user