Receiving Emails


Nothing to call

The sync engine keeps the ndk cache filled from the relays and revisits it on its own. The client declares what the logged-in account needs and follows logins and account switches. A freshly created client is already syncing.

Pull to refresh maps to one call:

await client.fetchRecent();

Watching

client.onEmail.listen((email) {
  print('${email.from}: ${email.subject}');
});

watch() gives the full stream of MailEvents, which is what a mailbox UI listens to: EmailReceived, LabelAdded, LabelRemoved, EmailDeleted. The narrow streams onLabel, onTrash, onRead and onStarred are there for the common cases. stopWatching() ends them.

client.watchUnreadCount(folder: 'inbox').listen((count) => badge.value = count);

Listing a mailbox

getSummaries is what a message list should call. It reads only the indexed columns a row draws, so it never touches the stored MIME nor parses it.

final page = await client.getSummaries(folder: 'inbox', limit: 50, offset: 0);

for (final row in page.items) {
  print('${row.isRead ? ' ' : '*'} ${row.fromName ?? row.from}: ${row.subject}');
  print('  ${row.preview}');
}

if (page.hasMore) {
  // next page at offset: page.offset + page.items.length
}

A row carries to, cc, bcc, date, folder, labels, attachmentRefs, isPublic and isBridged. For a native Nostr sender, from is <npub>@nostr and the real name lives in the profile behind senderPubkey: resolve that first, and fall back to fromName ?? from.

Filters: folder, isRead, isStarred, hasAttachments, senderPubkey, fromAddress, search.

final fromSender = await client.getSummaries(
  senderPubkey: row.senderPubkey,
  fromAddress: row.isBridged ? row.from : null,
);

Opening one email

final email = await client.getEmail(row.id);
print(email!.body);

Email exposes subject, body, textBody, htmlBody, date, from, attachmentRefs, and the parsed mime underneath.

Attachment bytes are loaded on demand, from the Blossom cache when they are there and by rebuilding the original MIME when they are not:

final bytes = await client.getAttachmentBytes(email, email.attachmentRefs.first);
final eml = await client.getRawMimeText(email); // byte-exact original

Folders and labels

Folders, read state and stars are NIP-32 labels, each in its own gift wrap. The client keeps a local view and publishes the changes.

await client.markAsRead(email.id);
await client.star(email.id);
await client.moveToTrash(email.id);
await client.restoreFromTrash(email.id);
await client.moveToArchive(email.id);

await client.addLabel(email.id, 'folder:invoices');
await client.removeLabel(email.id, 'folder:invoices');

Convenience readers: getInboxEmails, getSentEmails, getTrashedEmails, getArchivedEmails, getStarredEmails, getTrashedEmailsOlderThan, getUnreadCount, plus the matching *EmailIds lists.


final results = await client.search('meeting', limit: 10);

Full-text search runs on the local store, across folders. For a scoped search, pass search: to getSummaries.


Deleting

await client.delete([email.id]);

One NIP-09 request covers the batch. It targets the gift wrap, which is the event a relay actually holds, and it is signed by the pubkey the wrap is addressed to. The labels attached to those emails go with them.


When a wrap does not make it

Decryption can fail, a remote signer can refuse, a Blossom blob can be unreachable. Those wraps are parked rather than dropped.

final failed = await client.getFailedGiftWraps();
for (final f in failed) {
  print('${f.event.id} stopped at ${f.progress.stage}: ${f.progress.failure}');
}

await client.retry(failed.first.event.id);
final count = await client.getFailedCount();

Introspection

The wrap, the seal and the rumor behind an email stay available, which is what lets a client show the real event or export it:

final wrap = await client.getGiftWrap(email.id);
final seal = await client.getSeal(email.id);
final rumor = await client.getRumor(email.id);