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:
Thomas Güttler
2026-04-19 17:41:21 +02:00
co-authored by Claude Sonnet 4.6
parent 7e34ca45de
commit 8d8dbc33db
6 changed files with 384 additions and 14 deletions
+6 -1
View File
@@ -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);
}
},
);
}
+71 -4
View File
@@ -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?),
),
);
}