Skip to content

Mail over Nostr

Email-shaped messaging on top of NIP-59 gift wrap: structured To/Cc/Bcc, threading, and encrypted attachments.

Experimental

There is no ratified NIP for email-style messaging on Nostr, so MAIL_KIND is provisional and can be overridden per call. The transport is entirely standard - a kind 1059 gift wrap around a kind 13 seal - so only the inner rumor schema is mail-specific. Expect the schema to move if and when a NIP lands.

Why not NIP-17?

NIP-17 models a conversation between equal participants. Email is defined by asymmetric recipient roles: To, Cc and Bcc are not the same thing, and Bcc must be invisible to the other recipients. NIP-17 has no way to express that, which is why this is a separate module rather than an extension.

Import

ts
import { mail } from 'nostr-core'
// or, as a subpath
import * as mail from 'nostr-core/mail'
// or import individual functions
import {
  createMailTemplate,
  createMailMessage,
  parseMailMessage,
  parseMailRumor,
  createReply,
  getThreadId,
  encryptAttachment,
  encryptAttachmentWithKey,
  decryptAttachment,
  uploadMailAttachment,
  downloadMailAttachment,
  getMailFilter,
  getDeliveryRelayFilter,
  parseDeliveryRelays,
  MAIL_KIND,
  DM_RELAY_LIST_KIND,
} from 'nostr-core'

Constants

ConstantValueDescription
MAIL_KIND1314Inner rumor kind (provisional)
DM_RELAY_LIST_KIND10050NIP-17 DM relay list, used for delivery

TIP

1314 deliberately avoids 1301, which is already taken by NIP-101e "Workout Record". Because mail rumors are always 1059-wrapped, relays never filter on this number - but a future NIP should still claim a registry-checked kind.

The Envelope

kind 1059  gift wrap   <- the only thing a relay ever sees
  kind 13  seal        <- signed by the real author, NIP-44 encrypted
    kind 1314 rumor    <- the mail message

MailMessage Type

ts
type MailMessage = {
  subject: string
  body: string
  format?: 'text' | 'html'
  to: string[]
  cc?: string[]
  bcc?: string[]
  attachments?: MailAttachment[]
  thread?: string     // root rumor id; omit on the first message
  replyTo?: string    // rumor id being replied to
  extraTags?: string[][]
  kind?: number       // override MAIL_KIND
}

All pubkeys are 32-byte hex, per NIP-01 - not npub.

The rumor carries p tags for the visible recipients plus subject, thread and e tags; the content is a JSON document:

json
{
  "body": "...",
  "format": "text",
  "to": ["<hex>"],
  "cc": ["<hex>"],
  "bcc": ["<hex>"],
  "attachments": [{ "server": "...", "hash": "...", "mime": "...", "size": 0, "key": "<hex>", "name": "..." }]
}

createMailMessage

ts
function createMailMessage(
  mail: MailMessage,
  senderSecretKey: Uint8Array,
  opts?: { selfCopy?: boolean },
): MailCopy[]

type MailCopy = {
  recipient: string
  role: 'to' | 'cc' | 'bcc' | 'sender'
  rumor: Rumor
  wrap: NostrEvent
}

Returns one gift-wrapped copy per recipient. Publish each wrap to that recipient's DM relays.

ts
const copies = mail.createMailMessage({
  subject: 'Q3 planning',
  body: 'Agenda attached.',
  to: [bobPubkey],
  cc: [carolPubkey],
  bcc: [davePubkey],
}, aliceSecretKey)

// -> 4 copies: bob (to), carol (cc), dave (bcc), alice (sender)

How Bcc privacy works

Gift wrap already encrypts a separate copy per recipient, and this module exploits that:

Copyp tagsbcc array
To / Cc recipientsTo + Ccempty
Each Bcc recipientTo + Cconly themselves
Sender's own copyTo + Ccthe full list

Blind recipients are never p-tagged and each gets their own rumor with a distinct id. Neither another recipient nor a relay can reconstruct the blind list - it is privacy by construction, not by convention.

parseMailMessage

ts
function parseMailMessage(
  wrap: NostrEvent,
  recipientSecretKey: Uint8Array,
  opts?: { kind?: number },
): ParsedMail

function parseMailRumor(rumor: Rumor): ParsedMail
ts
type ParsedMail = {
  subject: string
  body: string
  format: 'text' | 'html'
  to: string[]
  cc: string[]
  bcc: string[]        // what THIS copy reveals - see the table above
  attachments: MailAttachment[]
  thread?: string
  replyTo?: string
  sender: string
  id: string           // rumor id: the stable message id
  created_at: number
  extraTags: string[][]
  rumor: Rumor
}

A malformed content body does not lose the envelope - subject, threading and p tags still parse, and the recipient arrays fall back to the p tags.

Threading

ts
function getThreadId(mail: Pick<ParsedMail, 'thread' | 'id'>): string

Returns the message's thread tag, or its own id when it is the root of a new thread.

ts
function createReply(
  original: ParsedMail,
  reply: { body: string; format?: MailFormat; attachments?: MailAttachment[]; replyAll?: boolean },
  replierPubkey: string,
): MailMessage

Swaps the recipients around, prefixes the subject with Re: (only once), and fills in the threading tags. replyAll keeps the original To and Cc minus the replier; otherwise only the original sender is addressed. Bcc is never carried over.

ts
const reply = mail.createReply(received, { body: 'Looks good', replyAll: true }, myPubkey)
const replyCopies = mail.createMailMessage(reply, mySecretKey)

Attachments

Attachments are symmetrically encrypted, uploaded to a Blossom server, and referenced by descriptor. The decryption key travels only inside the gift wrap, so the file server never sees plaintext.

ts
type MailAttachment = {
  server: string   // Blossom server base URL
  hash: string     // SHA-256 of the ENCRYPTED blob, hex
  mime: string
  size: number     // plaintext size
  key: string      // symmetric key, hex - never leaves the gift wrap
  name: string
}

encryptAttachment / decryptAttachment

ts
function encryptAttachment(data: Uint8Array): Promise<{ ciphertext: Uint8Array; key: string }>
function encryptAttachmentWithKey(data: Uint8Array, keyHex: string): Promise<Uint8Array>
function decryptAttachment(ciphertext: Uint8Array, keyHex: string): Promise<Uint8Array>

AES-256-GCM with a fresh key and a 12-byte IV prepended to the ciphertext, so the blob is self-contained.

uploadMailAttachment / downloadMailAttachment

ts
function uploadMailAttachment(
  file: { data: Uint8Array; name: string; mime: string },
  server: string,
  senderSecretKey: Uint8Array,
  opts?: { expirationSeconds?: number },
): Promise<MailAttachment>

function downloadMailAttachment(
  attachment: MailAttachment,
  opts?: { server?: string },
): Promise<Uint8Array>

Encrypts, uploads with a signed kind 24242 Blossom auth event, and returns the descriptor to drop into the message.

ts
const attachment = await mail.uploadMailAttachment(
  { data: pdfBytes, name: 'agenda.pdf', mime: 'application/pdf' },
  'https://blossom.example',
  aliceSecretKey,
)

const copies = mail.createMailMessage({
  subject: 'Q3 planning', body: 'Agenda attached.',
  to: [bobPubkey], attachments: [attachment],
}, aliceSecretKey)

Addressing & Delivery

ts
function getMailFilter(pubkey: string, since?: number): Filter
function getDeliveryRelayFilter(pubkeys: string[]): Filter
function parseDeliveryRelays(event: NostrEvent): string[]

Mail is fetched as gift wraps - the mail kind is never visible to the relay:

ts
mail.getMailFilter(myPubkey)
// { kinds: [1059], '#p': [myPubkey] }

Recipients are resolved with NIP-05, and each copy is published to that recipient's kind 10050 DM relay list.

Complete Example

ts
import { mail, nip05, RelayPool } from 'nostr-core'

const pool = new RelayPool()
const defaultRelays = ['wss://relay.damus.io']

// --- Send ---
const bob = await nip05.queryNip05('bob@example.com')

const copies = mail.createMailMessage({
  subject: 'Q3 planning',
  body: 'Agenda attached.',
  to: [bob.pubkey],
  bcc: [legalPubkey],
}, aliceSecretKey)

// Look up where each recipient wants their mail delivered.
const lists = await pool.querySync(
  defaultRelays,
  mail.getDeliveryRelayFilter(copies.map(c => c.recipient)),
)
const inboxes = new Map(lists.map(e => [e.pubkey, mail.parseDeliveryRelays(e)]))

for (const copy of copies) {
  await pool.publish(inboxes.get(copy.recipient) ?? defaultRelays, copy.wrap)
}

// --- Receive ---
pool.subscribe(myRelays, mail.getMailFilter(myPubkey), {
  onevent(wrap) {
    try {
      const message = mail.parseMailMessage(wrap, mySecretKey)
      console.log(message.subject, 'from', message.sender)
      console.log('thread:', mail.getThreadId(message))
    } catch {
      // Not addressed to us, or not a mail rumor.
    }
  },
})

Standardization

The schema above is a proposal, not a standard. Making Nostr mail actually interoperable needs a NIP: a registry-checked kind, and agreement with the other shipping implementations (notably nmail, which currently uses the colliding kind 1301). Until then, treat this module as one implementation of one schema.

See Also

  • NIP-59 - the gift wrap transport
  • NIP-17 - direct messages, for conversations rather than mail
  • NIP-78 - private folders, read state and drafts
  • Blossom - attachment storage

Released under the MIT License.