feat: JMAP send via EmailSubmission/set; role column on Mailboxes
- sendEmail dispatches on account type: IMAP keeps SMTP+APPEND path, JMAP chains Email/set create + EmailSubmission/set in one API call - Sent mailbox looked up by role='sent' from local DB so sent mail lands in the right folder - JmapClient gains uploadUrl/eventSourceUrl/capabilities from session, supportsSubmission getter, withSubmission flag on call(), and uploadBlob() for attachment upload before send - Mailboxes table gains nullable role column (schema v8); _upsertJmapMailboxes persists role from JMAP Mailbox/get response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7e34ca45de
commit
8d8dbc33db
@@ -39,6 +39,8 @@ class Mailboxes extends Table {
|
||||
TextColumn get name => text()();
|
||||
IntColumn get unreadCount => integer().withDefault(const Constant(0))();
|
||||
IntColumn get totalCount => integer().withDefault(const Constant(0))();
|
||||
// Added in schema v8: JMAP role (e.g. "inbox", "sent", "trash").
|
||||
TextColumn get role => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
@@ -150,7 +152,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 7;
|
||||
int get schemaVersion => 8;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -174,6 +176,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 7) {
|
||||
await m.createTable(syncLogs);
|
||||
}
|
||||
if (from < 8) {
|
||||
await m.addColumn(mailboxes, mailboxes.role);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const _using = [
|
||||
const _coreUsing = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
];
|
||||
|
||||
const _submissionCapability = 'urn:ietf:params:jmap:submission';
|
||||
|
||||
/// A connected JMAP session. Fetch via [JmapClient.connect].
|
||||
///
|
||||
/// Parses the JMAP Session object (RFC 8620 §2), stores the resolved
|
||||
@@ -17,18 +20,33 @@ class JmapClient {
|
||||
required String credentials,
|
||||
required Uri apiUrl,
|
||||
required String accountId,
|
||||
required Set<String> capabilities,
|
||||
String? uploadUrl,
|
||||
String? eventSourceUrl,
|
||||
}) : _httpClient = httpClient,
|
||||
_credentials = credentials,
|
||||
_apiUrl = apiUrl,
|
||||
_accountId = accountId;
|
||||
_accountId = accountId,
|
||||
_capabilities = capabilities,
|
||||
_uploadUrl = uploadUrl,
|
||||
_eventSourceUrl = eventSourceUrl;
|
||||
|
||||
final http.Client _httpClient;
|
||||
final String _credentials;
|
||||
final Uri _apiUrl;
|
||||
final String _accountId;
|
||||
final Set<String> _capabilities;
|
||||
final String? _uploadUrl;
|
||||
final String? _eventSourceUrl;
|
||||
|
||||
String get accountId => _accountId;
|
||||
|
||||
/// Whether the server supports `EmailSubmission/set` (RFC 8621 §7).
|
||||
bool get supportsSubmission => _capabilities.contains(_submissionCapability);
|
||||
|
||||
/// SSE push URL advertised by the server, or null if push is unsupported.
|
||||
String? get eventSourceUrl => _eventSourceUrl;
|
||||
|
||||
/// Fetches the JMAP Session object from [jmapUrl] and returns a connected
|
||||
/// client. Throws [JmapException] on HTTP errors or missing capabilities.
|
||||
static Future<JmapClient> connect({
|
||||
@@ -54,11 +72,18 @@ class JmapClient {
|
||||
final apiUrl = _extractApiUrl(session, jmapUrl);
|
||||
final accountId = _extractAccountId(session);
|
||||
|
||||
final capabilities = _extractCapabilities(session);
|
||||
final uploadUrl = session['uploadUrl'] as String?;
|
||||
final eventSourceUrl = session['eventSourceUrl'] as String?;
|
||||
|
||||
return JmapClient._(
|
||||
httpClient: httpClient,
|
||||
credentials: credentials,
|
||||
apiUrl: apiUrl,
|
||||
accountId: accountId,
|
||||
capabilities: capabilities,
|
||||
uploadUrl: uploadUrl,
|
||||
eventSourceUrl: eventSourceUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,10 +92,19 @@ class JmapClient {
|
||||
/// Each call is a triple `[methodName, arguments, callId]`.
|
||||
/// Returns the raw `methodResponses` list from the server.
|
||||
///
|
||||
/// Pass [withSubmission] to include `urn:ietf:params:jmap:submission` in
|
||||
/// the `using` declaration (required for `EmailSubmission/set` calls).
|
||||
///
|
||||
/// Throws [JmapException] on HTTP errors or a top-level JMAP error response.
|
||||
Future<List<dynamic>> call(List<List<dynamic>> methodCalls) async {
|
||||
Future<List<dynamic>> call(
|
||||
List<List<dynamic>> methodCalls, {
|
||||
bool withSubmission = false,
|
||||
}) async {
|
||||
final using = withSubmission
|
||||
? [..._coreUsing, _submissionCapability]
|
||||
: _coreUsing;
|
||||
final body = jsonEncode({
|
||||
'using': _using,
|
||||
'using': using,
|
||||
'methodCalls': methodCalls,
|
||||
});
|
||||
|
||||
@@ -100,6 +134,34 @@ class JmapClient {
|
||||
return decoded['methodResponses'] as List<dynamic>;
|
||||
}
|
||||
|
||||
/// Uploads [data] as a blob and returns the server-assigned `blobId`.
|
||||
///
|
||||
/// Used to attach files to outgoing emails before calling `Email/set`.
|
||||
Future<String> uploadBlob(Uint8List data, String contentType) async {
|
||||
if (_uploadUrl == null) {
|
||||
throw JmapException('Server does not advertise an uploadUrl');
|
||||
}
|
||||
final url = Uri.parse(
|
||||
_uploadUrl.replaceAll('{accountId}', Uri.encodeComponent(_accountId)));
|
||||
final resp = await _httpClient
|
||||
.post(
|
||||
url,
|
||||
headers: {
|
||||
'Authorization': 'Basic $_credentials',
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
body: data,
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (resp.statusCode != 200 && resp.statusCode != 201) {
|
||||
throw JmapException('Blob upload failed (HTTP ${resp.statusCode})');
|
||||
}
|
||||
final decoded = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final blobId = decoded['blobId'] as String?;
|
||||
if (blobId == null) throw JmapException('Blob upload: missing blobId');
|
||||
return blobId;
|
||||
}
|
||||
|
||||
static Uri _extractApiUrl(Map<String, dynamic> session, Uri sessionUri) {
|
||||
final raw = session['apiUrl'] as String?;
|
||||
if (raw == null || raw.isEmpty) {
|
||||
@@ -109,6 +171,11 @@ class JmapClient {
|
||||
return sessionUri.resolve(raw);
|
||||
}
|
||||
|
||||
static Set<String> _extractCapabilities(Map<String, dynamic> session) {
|
||||
final caps = session['capabilities'] as Map<String, dynamic>?;
|
||||
return caps?.keys.toSet() ?? {};
|
||||
}
|
||||
|
||||
static String _extractAccountId(Map<String, dynamic> session) {
|
||||
final primaryAccounts =
|
||||
session['primaryAccounts'] as Map<String, dynamic>?;
|
||||
|
||||
@@ -953,6 +953,16 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
Future<void> sendEmail(String accountId, model.EmailDraft draft) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
switch (account.type) {
|
||||
case account_model.AccountType.imap:
|
||||
await _sendEmailImap(account, password, draft);
|
||||
case account_model.AccountType.jmap:
|
||||
await _sendEmailJmap(account, password, draft);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendEmailImap(account_model.Account account, String password,
|
||||
model.EmailDraft draft) async {
|
||||
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()
|
||||
@@ -965,7 +975,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
await builder.addFile(file, mediaType);
|
||||
}
|
||||
final mimeMessage = builder.buildMimeMessage();
|
||||
final smtpClient = await _smtpConnect(account, _effectiveUsername(account), password);
|
||||
final smtpClient =
|
||||
await _smtpConnect(account, _effectiveUsername(account), password);
|
||||
try {
|
||||
await smtpClient.sendMessage(mimeMessage);
|
||||
} finally {
|
||||
@@ -973,7 +984,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
// Save a copy to the Sent folder via IMAP APPEND.
|
||||
// Create the folder first — many servers don't pre-create it.
|
||||
final imapClient = await _imapConnect(account, _effectiveUsername(account), password);
|
||||
final imapClient =
|
||||
await _imapConnect(account, _effectiveUsername(account), password);
|
||||
try {
|
||||
try {
|
||||
await imapClient.createMailbox('Sent');
|
||||
@@ -990,6 +1002,124 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendEmailJmap(account_model.Account account, String password,
|
||||
model.EmailDraft draft) 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,
|
||||
);
|
||||
|
||||
// Upload any file attachments and collect their blobIds.
|
||||
final attachments = <Map<String, dynamic>>[];
|
||||
for (final filePath in draft.attachmentFilePaths) {
|
||||
final file = File(filePath);
|
||||
final bytes = await file.readAsBytes();
|
||||
final contentType = imap.MediaType.guessFromFileName(filePath).text;
|
||||
final blobId = await jmap.uploadBlob(bytes, contentType);
|
||||
attachments.add({
|
||||
'blobId': blobId,
|
||||
'type': contentType,
|
||||
'name': p.basename(filePath),
|
||||
'size': bytes.length,
|
||||
'disposition': 'attachment',
|
||||
});
|
||||
}
|
||||
|
||||
// Look up the Sent mailbox JMAP ID from the local DB.
|
||||
final sentMailbox = await (_db.select(_db.mailboxes)
|
||||
..where((t) =>
|
||||
t.accountId.equals(account.id) & t.role.equals('sent'))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final sentJmapId = sentMailbox?.path;
|
||||
|
||||
// Build the email body.
|
||||
const bodyPartId = '1';
|
||||
final emailCreate = {
|
||||
'from': [{'name': draft.from.name, 'email': draft.from.email}],
|
||||
'to': draft.to.map((a) => {'name': a.name, 'email': a.email}).toList(),
|
||||
if (draft.cc.isNotEmpty)
|
||||
'cc': draft.cc.map((a) => {'name': a.name, 'email': a.email}).toList(),
|
||||
'subject': draft.subject,
|
||||
'bodyValues': {
|
||||
bodyPartId: {
|
||||
'value': draft.body,
|
||||
'isEncodingProblem': false,
|
||||
'isTruncated': false,
|
||||
},
|
||||
},
|
||||
'textBody': [{'partId': bodyPartId, 'type': 'text/plain'}],
|
||||
if (attachments.isNotEmpty) 'attachments': attachments,
|
||||
'keywords': {r'$seen': true},
|
||||
if (sentJmapId != null) 'mailboxIds': {sentJmapId: true},
|
||||
};
|
||||
|
||||
// Build the recipient envelope for EmailSubmission.
|
||||
final allRecipients = [
|
||||
...draft.to.map((a) => {'email': a.email}),
|
||||
...draft.cc.map((a) => {'email': a.email}),
|
||||
];
|
||||
|
||||
// Chain Email/set (create) + EmailSubmission/set (create) in one request.
|
||||
final responses = await jmap.call(
|
||||
[
|
||||
[
|
||||
'Email/set',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'create': {'em1': emailCreate},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/set',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'create': {
|
||||
'sub1': {
|
||||
'#emailId': {
|
||||
'resultOf': '0',
|
||||
'name': 'Email/set',
|
||||
'path': '/created/em1/id',
|
||||
},
|
||||
'envelope': {
|
||||
'mailFrom': {'email': draft.from.email},
|
||||
'rcptTo': allRecipients,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
withSubmission: true,
|
||||
);
|
||||
|
||||
// Check Email/set for creation errors.
|
||||
final setResult = _responseArgs(responses, 0, 'Email/set');
|
||||
final notCreated =
|
||||
setResult['notCreated'] as Map<String, dynamic>?;
|
||||
if (notCreated != null && notCreated.containsKey('em1')) {
|
||||
final err = notCreated['em1'] as Map<String, dynamic>;
|
||||
throw JmapException('Email/set create failed: ${err['type']}');
|
||||
}
|
||||
|
||||
// Check EmailSubmission/set for submission errors.
|
||||
final subResult = _responseArgs(responses, 1, 'EmailSubmission/set');
|
||||
final notSubmitted =
|
||||
subResult['notCreated'] as Map<String, dynamic>?;
|
||||
if (notSubmitted != null && notSubmitted.containsKey('sub1')) {
|
||||
final err = notSubmitted['sub1'] as Map<String, dynamic>;
|
||||
throw JmapException('EmailSubmission/set failed: ${err['type']}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
|
||||
@@ -195,6 +195,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
name: m['name'] as String? ?? jmapId,
|
||||
unreadCount: Value((m['unreadEmails'] as int?) ?? 0),
|
||||
totalCount: Value((m['totalEmails'] as int?) ?? 0),
|
||||
role: Value(m['role'] as String?),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user