Sending Emails
Recipients carry their transport
send takes typed recipients, not raw strings. The transport is decided before
the send starts, so a NIP-05 lookup that fails halfway can never misroute a
Nostr recipient through a bridge.
// Native Nostr, from a pubkey. The display address becomes <npub>@nostr.
NostrRecipient.fromPubkey(bobPubkey);
// Native Nostr, keeping the address the user typed.
NostrRecipient(pubkey: bobPubkey, mailAddress: MailAddress(null, 'bob@example.com'));
// Legacy, relayed through your SMTP bridge.
SmtpRecipient('bob@gmail.com');
To classify an address you only have as text, use resolveRecipient:
final recipient = await resolveRecipient(to: 'bob@example.com', ndk: ndk);
Basic send
await client.send(
to: [NostrRecipient.fromPubkey(bobPubkey)],
cc: [SmtpRecipient('carol@example.com')],
subject: 'Hello!',
body: 'This is the email body.',
);
Sending a prepared MIME message
When you build the message yourself, attachments included:
await client.sendMime(
message,
to: [SmtpRecipient('bob@example.com')],
mailFrom: 'npub1alice...@bridge.com',
);
beforePublish is called for every outgoing event once its relays are resolved
and just before it is queued, which is where a client hooks progress reporting.
Signed and public emails
signRumor: true turns the rumor into a complete Nostr event. The recipient can
show it to a third party and that third party can verify it, without trusting
anyone. Deniability is the default precisely because this is not always wanted.
isPublic: true publishes the signed event to the relays instead of wrapping
it. Bcc recipients still get a wrap, carrying a public-ref tag that points at
the public event and the relays where it can be fetched. This is the shape for
writing to a public entity where transparency is the point.
Scheduling
A Scheduler DVM holds the prepared email and publishes it at the requested time.
final scheduled = await client.scheduleEmail(
to: [NostrRecipient.fromPubkey(bobPubkey)],
subject: 'Monday reminder',
body: 'See you at 10.',
at: DateTime.now().add(const Duration(days: 3)),
);
await client.cancelScheduledEmail(scheduled.packageId);
Scheduling, listing and cancelling are local-first and work offline. Call
startScheduling() to also receive live DVM feedback and multi-device updates,
and watch the list with watchScheduledEmails(). The email lands in Sent
through the normal sync once the DVM actually publishes it.
Set the DVM once in NostrMailClient.create(schedulerDvm: ...), or per call
with dvmPubkey.
What happens under the hood
-
Build the RFC 2822 message.
-
Create the kind 1301 rumor, with
email-idand, for a bridged recipient,mail-fromandrcpt-to. -
Move the MIME to Blossom, encrypted, when it exceeds the inline threshold (32 KB).
-
Gift wrap once per recipient.
-
Hand each wrap to the offline broadcast queue, which resolves the destination relays and keeps retrying across restarts.
Error handling
try {
await client.send(to: [recipient], subject: 'Test', body: 'Test');
} on NetworkRequiredException catch (e) {
print('Offline during ${e.operation}, ask the user to reconnect');
} on RecipientResolutionException catch (e) {
print('No such recipient: ${e.message}');
} on NostrMailException catch (e) {
print('Error: ${e.message}');
}
NetworkRequiredException means the lookup never got an answer.
RecipientResolutionException means it got one, and the answer was no.