bridge

package
v0.65.60 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: AGPL-3.0 Imports: 33 Imported by: 0

Documentation

Overview

Package bridge implements a bidirectional Nostr-Email bridge using the Marmot protocol (MLS-based E2E encrypted messaging) for all Nostr-side communication. It receives inbound email via SMTP and delivers it as Marmot DMs, and receives outbound email as Marmot DMs to send via SMTP.

Index

Constants

View Source
const FreeInterval = 5 * time.Minute

FreeInterval is the minimum time between sends for unsubscribed users.

Variables

This section is empty.

Functions

func ComposeHandler

func ComposeHandler() http.HandlerFunc

ComposeHandler returns an HTTP handler that serves the compose form.

func DecryptAttachment

func DecryptAttachment(ciphertext []byte, keyHex string) ([]byte, error)

DecryptAttachment decrypts data that was encrypted by EncryptAttachment. keyHex is the hex-encoded key from the fragment URL.

func DecryptHandler

func DecryptHandler() http.HandlerFunc

DecryptHandler returns an HTTP handler that serves the decrypt page.

func EncryptAttachment

func EncryptAttachment(plaintext []byte) (ciphertext []byte, keyHex string, err error)

EncryptAttachment encrypts data with a random ChaCha20-Poly1305 key. Returns (ciphertext, keyHex). The key is hex-encoded for use in fragment URLs.

func GenerateReplyLink(baseURL, replyTo, subject string) string

GenerateReplyLink creates a compose form URL pre-populated with reply fields.

func IsOutboundEmail

func IsOutboundEmail(content string) bool

IsOutboundEmail returns true if the DM content looks like an outbound email (starts with recognized headers like To:, Subject:, etc.)

func ZipParts

func ZipParts(htmlBody string, attachments []bridgesmtp.Attachment) ([]byte, error)

ZipParts bundles non-plaintext MIME parts (HTML + attachments) into a flat zip archive. The text/plain body is excluded — it goes directly into the DM content.

Types

type BlossomUploader

type BlossomUploader interface {
	Upload(data []byte, contentType string) (url string, err error)
}

BlossomUploader uploads encrypted data to a Blossom server and returns the URL.

type Bridge

type Bridge struct {
	// contains filtered or unexported fields
}

Bridge is the Nostr-Email bridge. It manages identity, relay connection, Marmot DM handling, and SMTP transport.

func New

func New(cfg *Config, dbGetter func() ([]byte, error)) *Bridge

New creates a new Bridge. The dbGetter is optional - pass nil for standalone mode (the bridge will fall back to file-based identity).

func (*Bridge) IdentitySource

func (b *Bridge) IdentitySource() IdentitySource

IdentitySource returns how the identity was resolved.

func (*Bridge) Signer

func (b *Bridge) Signer() signer.I

Signer returns the bridge's identity signer.

func (*Bridge) Start

func (b *Bridge) Start(ctx context.Context) error

Start initializes the bridge: resolves identity, connects to relay, and begins listening for events.

func (*Bridge) Stop

func (b *Bridge) Stop()

Stop gracefully shuts down the bridge.

type ClassifyDMResult

type ClassifyDMResult struct {
	Command DMCommand
	Alias   string // Optional alias from "subscribe <alias>"
}

ClassifyDMResult holds the classified command and any extracted alias.

func ClassifyDMFull

func ClassifyDMFull(content string) ClassifyDMResult

ClassifyDMFull returns the classified command along with any extracted alias.

type Config

type Config struct {
	// Domain is the email domain (e.g., "relay.example.com").
	// Users get addresses like npub1abc...@relay.example.com.
	Domain string

	// NSEC is the bridge identity secret key in nsec or hex format.
	// If empty, the bridge attempts to read it from the database
	// (monolithic mode) or from a file (standalone mode).
	NSEC string

	// RelayURL is the WebSocket URL of the relay to connect to.
	// Used in standalone mode. In monolithic mode this can be empty
	// (events are routed in-process).
	RelayURL string

	// PublicRelayURL is the public wss:// URL advertised in kind 10002
	// and kind 10050 relay list events. Separate from RelayURL because
	// the bridge may connect via ws://localhost internally.
	PublicRelayURL string

	// SMTPPort is the port the SMTP server listens on.
	SMTPPort int

	// SMTPHost is the address the SMTP server binds to.
	SMTPHost string

	// DataDir is where the bridge persists its state (group state,
	// subscriptions, identity file for standalone mode).
	DataDir string

	// DKIMKeyPath is the path to the DKIM private key PEM file.
	DKIMKeyPath string

	// DKIMSelector is the DKIM selector for DNS TXT lookup.
	DKIMSelector string

	// NWCURI is the NWC connection string for subscription payments.
	NWCURI string

	// MonthlyPriceSats is the price in sats for one month subscription.
	MonthlyPriceSats int64

	// ComposeURL is the public URL of the compose form page.
	ComposeURL string

	// SMTPRelayHost is the smarthost for outbound email delivery
	// (e.g., "smtp.migadu.com"). If empty, direct MX delivery is used.
	SMTPRelayHost string

	// SMTPRelayPort is the smarthost port (typically 587 for STARTTLS).
	SMTPRelayPort int

	// SMTPRelayUsername is the SMTP AUTH username for the smarthost.
	SMTPRelayUsername string

	// SMTPRelayPassword is the SMTP AUTH password for the smarthost.
	SMTPRelayPassword string

	// ACLGRPCServer is the gRPC address of the ACL server.
	// When set, the bridge uses ACL-backed subscriptions instead of file store.
	ACLGRPCServer string

	// AliasPriceSats is the monthly price in sats for an alias email address.
	// Must be >= MonthlyPriceSats. If zero, defaults to 2x MonthlyPriceSats.
	AliasPriceSats int64

	// ProfilePath is the path to a profile template file (email-header format).
	// If the file exists, the bridge publishes a kind 0 metadata event on startup.
	// Default: $DataDir/profile.txt
	ProfilePath string

	// SMTPMXPort overrides the port for direct MX delivery.
	// 0 (default) tries 2525 then falls back to 25.
	SMTPMXPort int
}

Config holds the bridge configuration. Constructed by callers from environment values (see app/config/config.go GetBridgeConfigValues).

type DMCommand

type DMCommand int

DMCommand represents a recognized command in a DM.

const (
	DMCommandNone      DMCommand = iota // Not a command — treat as email or contact
	DMCommandSubscribe                  // "subscribe" or "subscribe <alias>" command
	DMCommandStatus                     // "status" command
	DMCommandHelp                       // "help" command
)

func ClassifyDM

func ClassifyDM(content string) DMCommand

ClassifyDM determines what kind of DM this is: - A subscribe command (optionally with alias) - A status command - An outbound email (has To: header) - A contact message (everything else → blind proxy)

type FileSubscriptionStore

type FileSubscriptionStore struct {
	// contains filtered or unexported fields
}

FileSubscriptionStore persists subscriptions as a JSON file.

func NewFileSubscriptionStore

func NewFileSubscriptionStore(dataDir string) (*FileSubscriptionStore, error)

NewFileSubscriptionStore creates a subscription store backed by a JSON file.

func (*FileSubscriptionStore) Delete

func (s *FileSubscriptionStore) Delete(pubkeyHex string) error

func (*FileSubscriptionStore) Get

func (s *FileSubscriptionStore) Get(pubkeyHex string) (*Subscription, error)

func (*FileSubscriptionStore) List

func (s *FileSubscriptionStore) List() ([]*Subscription, error)

func (*FileSubscriptionStore) Save

func (*FileSubscriptionStore) Stop

func (s *FileSubscriptionStore) Stop()

Stop shuts down the actor goroutine and waits for it to exit.

type IdentitySource

type IdentitySource int

IdentitySource describes where the bridge identity came from.

const (
	IdentityFromConfig IdentitySource = iota // NSEC provided via env/config
	IdentityFromDB                           // Read from relay database
	IdentityFromFile                         // Read from or generated to file
)

func ResolveIdentity

func ResolveIdentity(nsecConfig string, dbGetter func() ([]byte, error), dataDir string) (signer.I, IdentitySource, error)

ResolveIdentity resolves the bridge signer using a three-tier strategy:

  1. Config NSEC (env var ORLY_BRIDGE_NSEC) — highest priority
  2. Database getter (monolithic mode — reads relay identity)
  3. File fallback (standalone mode — reads or generates bridge.nsec)

The dbGetter parameter is a function that returns the relay identity secret key from the database. Pass nil in standalone mode.

type InboundProcessor

type InboundProcessor struct {
	// contains filtered or unexported fields
}

InboundProcessor handles converting inbound emails to DMs.

func NewInboundProcessor

func NewInboundProcessor(
	blossom BlossomUploader,
	composeURL string,
	sendDM func(pubkeyHex string, content string) error,
) *InboundProcessor

NewInboundProcessor creates an inbound email processor.

func (*InboundProcessor) ProcessInbound

func (ip *InboundProcessor) ProcessInbound(email *bridgesmtp.InboundEmail, recipientPubkeyHex string) error

ProcessInbound converts an inbound email to a DM and sends it to the Nostr recipient. The recipientPubkeyHex is resolved from the local part of the recipient email address (npub or hex).

type Invoice

type Invoice struct {
	Bolt11      string `json:"invoice"`
	PaymentHash string `json:"payment_hash"`
	Amount      int64  `json:"amount"` // millisatoshis
	Description string `json:"description"`
	CreatedAt   int64  `json:"created_at"`
	ExpiresAt   int64  `json:"expires_at"`
}

Invoice represents a Lightning invoice returned by the wallet.

type InvoiceStatus

type InvoiceStatus struct {
	Bolt11      string `json:"invoice"`
	PaymentHash string `json:"payment_hash"`
	Amount      int64  `json:"amount"`
	Preimage    string `json:"preimage,omitempty"`
	SettledAt   int64  `json:"settled_at,omitempty"`
	IsPaid      bool   `json:"-"`
}

InvoiceStatus represents the status of a looked-up invoice.

type MemorySubscriptionStore

type MemorySubscriptionStore struct {
	// contains filtered or unexported fields
}

MemorySubscriptionStore is an in-memory subscription store for testing.

func NewMemorySubscriptionStore

func NewMemorySubscriptionStore() *MemorySubscriptionStore

NewMemorySubscriptionStore creates a new in-memory subscription store.

func (*MemorySubscriptionStore) Delete

func (s *MemorySubscriptionStore) Delete(pubkeyHex string) error

func (*MemorySubscriptionStore) Get

func (s *MemorySubscriptionStore) Get(pubkeyHex string) (*Subscription, error)

func (*MemorySubscriptionStore) List

func (s *MemorySubscriptionStore) List() ([]*Subscription, error)

func (*MemorySubscriptionStore) Save

func (*MemorySubscriptionStore) Stop

func (s *MemorySubscriptionStore) Stop()

Stop shuts down the actor goroutine and waits for it to exit.

type NWCRequester

type NWCRequester interface {
	Request(ctx context.Context, method string, params, result any) error
}

NWCRequester abstracts the NWC client's Request method for testing.

type OutboundProcessor

type OutboundProcessor struct {
	// contains filtered or unexported fields
}

OutboundProcessor handles converting DMs to outbound emails.

func NewOutboundProcessor

func NewOutboundProcessor(
	smtpClient *bridgesmtp.Client,
	rateLimiter *RateLimiter,
	subHandler *SubscriptionHandler,
	domain string,
	sendDM func(pubkeyHex string, content string) error,
	aclClient *aclgrpc.Client,
) *OutboundProcessor

NewOutboundProcessor creates an outbound DM-to-email processor.

func (*OutboundProcessor) ProcessOutbound

func (op *OutboundProcessor) ProcessOutbound(senderPubkeyHex, content string) error

ProcessOutbound parses a DM as an outbound email and sends it. senderPubkeyHex is the Nostr pubkey of the DM author.

type ParsedDM

type ParsedDM struct {
	// To is the list of recipient email addresses.
	To []string
	// Cc is the list of CC email addresses.
	Cc []string
	// Bcc is the list of BCC email addresses.
	Bcc []string
	// Subject is the email subject line.
	Subject string
	// Attachments is the list of Blossom fragment-key URLs.
	Attachments []string
	// Body is the email body text (everything after the blank line separator).
	Body string
}

ParsedDM represents a parsed outbound email DM from a user.

func ParseDMContent

func ParseDMContent(content string) (*ParsedDM, error)

ParseDMContent parses a DM message in RFC 822-style format into structured email fields. The format is:

To: alice@example.com, bob@example.com
Cc: carol@example.com
Subject: Hello from Nostr
Attachment: https://blossom.example/abc123#key

Message body starts here.

Headers are terminated by the first blank line. Everything after is the body. No From: header — the bridge derives the sender from the Nostr event pubkey.

type PaymentProcessor

type PaymentProcessor struct {
	// contains filtered or unexported fields
}

PaymentProcessor wraps a NWC client for bridge subscription payments.

func NewPaymentProcessor

func NewPaymentProcessor(nwcURI string, monthlyPriceSats int64) (*PaymentProcessor, error)

NewPaymentProcessor creates a payment processor with the given NWC URI and monthly price in satoshis.

func NewPaymentProcessorWithClient

func NewPaymentProcessorWithClient(client NWCRequester, monthlyPriceSats int64) *PaymentProcessor

NewPaymentProcessorWithClient creates a payment processor with a provided NWCRequester. This is useful for testing or custom NWC implementations.

func (*PaymentProcessor) CreateInvoice

func (pp *PaymentProcessor) CreateInvoice(ctx context.Context, amountSats int64) (*Invoice, error)

CreateInvoice creates a Lightning invoice for the given amount in satoshis.

func (*PaymentProcessor) CreateSubscriptionInvoice

func (pp *PaymentProcessor) CreateSubscriptionInvoice(ctx context.Context) (*Invoice, error)

CreateSubscriptionInvoice creates a Lightning invoice for a one-month bridge subscription at the default price.

func (*PaymentProcessor) LookupInvoice

func (pp *PaymentProcessor) LookupInvoice(ctx context.Context, paymentHash string) (*InvoiceStatus, error)

LookupInvoice checks the payment status of an invoice by its payment hash.

func (*PaymentProcessor) WaitForPayment

func (pp *PaymentProcessor) WaitForPayment(ctx context.Context, paymentHash string, pollInterval time.Duration) (*InvoiceStatus, error)

WaitForPayment polls the invoice until paid or the context is cancelled. Returns the settled invoice status on success.

type RateLimitConfig

type RateLimitConfig struct {
	PerUserPerHour int           // Max emails per user per hour (default: 10)
	PerUserPerDay  int           // Max emails per user per day (default: 50)
	GlobalPerHour  int           // Max emails globally per hour (default: 100)
	GlobalPerDay   int           // Max emails globally per day (default: 500)
	MinInterval    time.Duration // Min time between sends per user (default: 30s)
}

RateLimitConfig holds rate limit configuration values.

func DefaultRateLimitConfig

func DefaultRateLimitConfig() RateLimitConfig

DefaultRateLimitConfig returns sensible defaults per the spec.

type RateLimiter

type RateLimiter struct {
	actor.Lifecycle
	// contains filtered or unexported fields
}

RateLimiter tracks outbound email sending rates using sliding windows. All mutable state is owned by the actor goroutine.

func NewRateLimiter

func NewRateLimiter(cfg RateLimitConfig) *RateLimiter

NewRateLimiter creates a rate limiter with the given config.

func (*RateLimiter) Check

func (rl *RateLimiter) Check(pubkeyHex string) error

Check returns nil if the user is allowed to send, or an error describing when they can retry.

func (*RateLimiter) CheckFree

func (rl *RateLimiter) CheckFree(pubkeyHex string) error

CheckFree applies the free-tier rate limit: 1 email per 5 minutes.

func (*RateLimiter) Record

func (rl *RateLimiter) Record(pubkeyHex string)

Record records a send event for rate limiting purposes. Call this after a successful send.

func (*RateLimiter) Shutdown added in v0.65.60

func (rl *RateLimiter) Shutdown()

Shutdown stops the actor goroutine and waits for it to exit.

type RelayConn

type RelayConn struct {
	// contains filtered or unexported fields
}

RelayConn wraps a WebSocket relay connection with auto-reconnect. It satisfies the RelayConnection interface used by the bridge's Marmot client for standalone mode (connecting to an external relay).

func NewRelayConn

func NewRelayConn(url string, sign signer.I) *RelayConn

NewRelayConn creates a new relay connection wrapper. The signer is used for NIP-42 authentication when the relay requires it.

func (*RelayConn) Close

func (rc *RelayConn) Close()

Close closes the relay connection.

func (*RelayConn) Connect

func (rc *RelayConn) Connect(ctx context.Context) error

Connect establishes the WebSocket connection to the relay and pre-authenticates via NIP-42 so that subscriptions have proper access. In monolithic mode, the relay may not be listening yet, so Connect retries with exponential backoff for up to 30 seconds.

func (*RelayConn) FetchKind0

func (rc *RelayConn) FetchKind0(ctx context.Context, pubkey []byte) *event.E

FetchKind0 fetches the latest kind 0 profile event for a pubkey. Returns nil if not found or on error.

func (*RelayConn) Publish

func (rc *RelayConn) Publish(ctx context.Context, ev *event.E) error

Publish sends an event to the relay. If the relay responds with auth-required, the bridge authenticates via NIP-42 and retries once.

func (*RelayConn) Reconnect

func (rc *RelayConn) Reconnect() error

Reconnect attempts to reconnect with exponential backoff.

func (*RelayConn) Subscribe

func (rc *RelayConn) Subscribe(ctx context.Context, ff *filter.S) (*WsEventStream, error)

Subscribe creates a subscription on the relay and returns a stream of events.

type Router

type Router struct {
	// contains filtered or unexported fields
}

Router dispatches incoming DMs to the appropriate handler.

func NewRouter

func NewRouter(
	subHandler *SubscriptionHandler,
	outbound *OutboundProcessor,
	sendDM func(pubkeyHex string, content string) error,
) *Router

NewRouter creates a DM router.

func (*Router) RouteDM

func (r *Router) RouteDM(ctx context.Context, senderPubkeyHex, content string)

RouteDM processes an incoming DM and routes it to the right handler.

func (*Router) SendWelcome

func (r *Router) SendWelcome(pubkeyHex string)

SendWelcome sends the help text to a new peer (triggered by group establishment).

type Subscription

type Subscription struct {
	// PubkeyHex is the subscriber's 32-byte pubkey in hex.
	PubkeyHex string `json:"pubkey"`
	// ExpiresAt is the subscription expiration time.
	ExpiresAt time.Time `json:"expires_at"`
	// CreatedAt is when the subscription was created.
	CreatedAt time.Time `json:"created_at"`
	// InvoiceHash is the payment hash of the last paid invoice.
	InvoiceHash string `json:"invoice_hash,omitempty"`
}

Subscription represents a user's bridge subscription.

func (*Subscription) IsActive

func (s *Subscription) IsActive() bool

IsActive returns true if the subscription has not expired.

type SubscriptionHandler

type SubscriptionHandler struct {
	// contains filtered or unexported fields
}

SubscriptionHandler manages the subscription flow: user sends "subscribe" → create invoice → poll for payment → activate → confirm.

func NewSubscriptionHandler

func NewSubscriptionHandler(
	store SubscriptionStore,
	payments *PaymentProcessor,
	sendDM func(pubkeyHex string, content string) error,
	priceSats int64,
	aclClient *aclgrpc.Client,
	aliasPriceSats int64,
	domain string,
) *SubscriptionHandler

NewSubscriptionHandler creates a handler for subscription DM commands. sendDM is a callback that sends a DM reply to the user.

func (*SubscriptionHandler) HandleStatus

func (sh *SubscriptionHandler) HandleStatus(pubkeyHex string)

HandleStatus replies with the user's subscription info.

func (*SubscriptionHandler) HandleSubscribe

func (sh *SubscriptionHandler) HandleSubscribe(ctx context.Context, pubkeyHex, alias string)

HandleSubscribe processes a "subscribe" or "subscribe <alias>" command. Plain subscribe is free and instant. Alias subscribe requires payment.

func (*SubscriptionHandler) IsSubscribed

func (sh *SubscriptionHandler) IsSubscribed(pubkeyHex string) bool

IsSubscribed checks whether a user has an active subscription.

type SubscriptionStore

type SubscriptionStore interface {
	Save(sub *Subscription) error
	Get(pubkeyHex string) (*Subscription, error)
	List() ([]*Subscription, error)
	Delete(pubkeyHex string) error
}

SubscriptionStore persists and queries subscriptions.

type WsEventStream

type WsEventStream struct {
	// contains filtered or unexported fields
}

WsEventStream wraps a ws.Subscription to deliver events.

func (*WsEventStream) Close

func (s *WsEventStream) Close()

Close unsubscribes from the relay.

func (*WsEventStream) Events

func (s *WsEventStream) Events() <-chan *event.E

Events returns the channel of events.

Source Files

  • attachments.go
  • bridge.go
  • config.go
  • identity.go
  • inbound.go
  • mls.go
  • outbound.go
  • parser.go
  • payment.go
  • profile.go
  • ratelimit.go
  • relay.go
  • router.go
  • serve.go
  • subscription.go
  • subscription_handler.go
  • zip.go

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL