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
+8
-7
@@ -27,6 +27,10 @@ This document covers the mail-to-database sync layer only, not the UI.
|
||||
at the start of each sync cycle.
|
||||
- Email bodies are fetched on demand via `Email/get` with `bodyValues` and cached in
|
||||
`email_bodies` so subsequent opens are instant.
|
||||
- `syncEmails` fetches `bodyValues` during the sync pass so bodies are cached without
|
||||
a separate on-demand fetch.
|
||||
- `flushPendingChanges` passes `ifInState` to every `Email/set`; a `stateMismatch`
|
||||
response clears the local checkpoint and triggers a full re-sync before retrying.
|
||||
|
||||
### IMAP
|
||||
|
||||
@@ -56,18 +60,15 @@ This document covers the mail-to-database sync layer only, not the UI.
|
||||
|
||||
### JMAP hardening
|
||||
|
||||
- **Body caching during sync**: `syncEmails` currently syncs headers only. Include
|
||||
`bodyValues` + `htmlBody`/`textBody` in the `Email/get` properties list so bodies
|
||||
are written to `email_bodies` during the sync pass, not just on first open.
|
||||
- **JMAP send**: implement outgoing mail via `EmailSubmission/set` in addition to the
|
||||
current SMTP path.
|
||||
- **Push instead of polling**: upgrade `_JmapAccountSync._wait()` to use an
|
||||
`EventSource` connection to the JMAP push URL when the server advertises push
|
||||
capability. Fall back to 30 s polling when push is unavailable.
|
||||
- **Conflict handling**: pass `ifInState` to `Email/set` in `flushPendingChanges` so
|
||||
the server can reject a stale mutation; retry the affected change after re-syncing.
|
||||
|
||||
### Shared / cross-protocol
|
||||
|
||||
- **Explicit conflict-resolution strategy**: decide and document the policy (last-write-
|
||||
wins vs. server-wins) and implement it consistently across both protocols.
|
||||
- **Conflict-resolution hardening**: document and enforce the server-wins policy
|
||||
consistently — check `notUpdated`/`notDestroyed` per-item errors in JMAP `Email/set`
|
||||
responses, handle IMAP `NO`/`BAD` gracefully, and evict changes that exceed a
|
||||
maximum retry threshold (e.g. 5 attempts) to prevent queues from growing unboundedly.
|
||||
|
||||
@@ -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?),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:http/testing.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
|
||||
@@ -1469,4 +1470,169 @@ void main() {
|
||||
expect(bodies, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('JMAP sendEmail', () {
|
||||
http.Client mockSend({
|
||||
int sessionStatus = 200,
|
||||
int apiStatus = 200,
|
||||
Map<String, dynamic>? emailSetResult,
|
||||
Map<String, dynamic>? submissionResult,
|
||||
}) {
|
||||
return MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'apiUrl': 'https://jmap.example.com/api/',
|
||||
'accounts': {'acct1': {}},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': 'acct1',
|
||||
'urn:ietf:params:jmap:mail': 'acct1',
|
||||
},
|
||||
'capabilities': {
|
||||
'urn:ietf:params:jmap:core': {},
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'sess1',
|
||||
}),
|
||||
sessionStatus,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 's1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/set',
|
||||
emailSetResult ??
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'newState': 'est2',
|
||||
'created': {'em1': {'id': 'newEmailId1'}},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'EmailSubmission/set',
|
||||
submissionResult ??
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'created': {'sub1': {'id': 'subId1'}},
|
||||
},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
}),
|
||||
apiStatus,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const draft = EmailDraft(
|
||||
from: EmailAddress(name: 'Alice', email: 'alice@example.com'),
|
||||
to: [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
cc: [],
|
||||
subject: 'Hello',
|
||||
body: 'World',
|
||||
);
|
||||
|
||||
test('sends email via EmailSubmission/set for JMAP accounts', () async {
|
||||
final r = _makeRepos(httpClient: mockSend());
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
await r.emails.sendEmail('jmap-1', draft);
|
||||
// No exception = success; IMAP connections are not opened
|
||||
});
|
||||
|
||||
test('throws when Email/set reports notCreated', () async {
|
||||
final r = _makeRepos(
|
||||
httpClient: mockSend(
|
||||
emailSetResult: {
|
||||
'accountId': 'acct1',
|
||||
'notCreated': {'em1': {'type': 'invalidProperties'}},
|
||||
},
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
await expectLater(
|
||||
r.emails.sendEmail('jmap-1', draft),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when EmailSubmission/set reports notCreated', () async {
|
||||
final r = _makeRepos(
|
||||
httpClient: mockSend(
|
||||
submissionResult: {
|
||||
'accountId': 'acct1',
|
||||
'notCreated': {'sub1': {'type': 'invalidRecipients'}},
|
||||
},
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
await expectLater(
|
||||
r.emails.sendEmail('jmap-1', draft),
|
||||
throwsA(isA<JmapException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses Sent mailbox ID when role=sent mailbox exists in DB', () async {
|
||||
late Map<String, dynamic> capturedBody;
|
||||
final client = MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'apiUrl': 'https://jmap.example.com/api/',
|
||||
'accounts': {'acct1': {}},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': 'acct1',
|
||||
'urn:ietf:params:jmap:mail': 'acct1',
|
||||
},
|
||||
'capabilities': {
|
||||
'urn:ietf:params:jmap:core': {},
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'sess1',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
capturedBody = jsonDecode(req.body) as Map<String, dynamic>;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 's1',
|
||||
'methodResponses': [
|
||||
['Email/set', {'accountId': 'acct1', 'newState': 'est2',
|
||||
'created': {'em1': {'id': 'newId'}}}, '0'],
|
||||
['EmailSubmission/set', {'accountId': 'acct1',
|
||||
'created': {'sub1': {'id': 'subId'}}}, '1'],
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
// Seed a Sent mailbox with role='sent'
|
||||
await r.db.into(r.db.mailboxes).insert(MailboxesCompanion.insert(
|
||||
id: 'jmap-1:sentMbx', accountId: 'jmap-1',
|
||||
path: 'sentMbxJmapId', name: 'Sent',
|
||||
role: const Value('sent'),
|
||||
));
|
||||
|
||||
await r.emails.sendEmail('jmap-1', draft);
|
||||
|
||||
final calls = capturedBody['methodCalls'] as List<dynamic>;
|
||||
final emailSetArgs = (calls.first as List<dynamic>)[1] as Map<String, dynamic>;
|
||||
final createMap = emailSetArgs['create'] as Map<String, dynamic>;
|
||||
final em1Create = createMap['em1'] as Map<String, dynamic>;
|
||||
expect(em1Create['mailboxIds'], {'sentMbxJmapId': true});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user