inbound.go raw

   1  package bridge
   2  
   3  import (
   4  	"fmt"
   5  	"net/url"
   6  	"strings"
   7  
   8  	bridgesmtp "next.orly.dev/pkg/bridge/smtp"
   9  
  10  	"next.orly.dev/pkg/lol/log"
  11  )
  12  
  13  // BlossomUploader uploads encrypted data to a Blossom server and returns the URL.
  14  type BlossomUploader interface {
  15  	Upload(data []byte, contentType string) (url string, err error)
  16  }
  17  
  18  // InboundProcessor handles converting inbound emails to DMs.
  19  type InboundProcessor struct {
  20  	blossom    BlossomUploader
  21  	composeURL string
  22  	sendDM     func(pubkeyHex string, content string) error
  23  }
  24  
  25  // NewInboundProcessor creates an inbound email processor.
  26  func NewInboundProcessor(
  27  	blossom BlossomUploader,
  28  	composeURL string,
  29  	sendDM func(pubkeyHex string, content string) error,
  30  ) *InboundProcessor {
  31  	return &InboundProcessor{
  32  		blossom:    blossom,
  33  		composeURL: composeURL,
  34  		sendDM:     sendDM,
  35  	}
  36  }
  37  
  38  // ProcessInbound converts an inbound email to a DM and sends it to the
  39  // Nostr recipient. The recipientPubkeyHex is resolved from the local part
  40  // of the recipient email address (npub or hex).
  41  func (ip *InboundProcessor) ProcessInbound(email *bridgesmtp.InboundEmail, recipientPubkeyHex string) error {
  42  	// Parse MIME
  43  	parsed, err := bridgesmtp.ParseMIME(email.RawMessage)
  44  	if err != nil {
  45  		return fmt.Errorf("parse MIME: %w", err)
  46  	}
  47  
  48  	// Build DM content
  49  	var dm strings.Builder
  50  
  51  	dm.WriteString(fmt.Sprintf("From: %s\n", parsed.From))
  52  	dm.WriteString(fmt.Sprintf("Subject: %s\n", parsed.Subject))
  53  
  54  	// Handle attachments: zip non-text parts, encrypt, upload to Blossom
  55  	if len(parsed.Attachments) > 0 || parsed.TextHTML != "" {
  56  		if ip.blossom != nil {
  57  			url, err := ip.processAttachments(parsed.TextHTML, parsed.Attachments)
  58  			if err != nil {
  59  				log.W.F("attachment processing failed: %v", err)
  60  				dm.WriteString("Attachment: [processing failed]\n")
  61  			} else if url != "" {
  62  				dm.WriteString(fmt.Sprintf("Attachment: %s\n", url))
  63  			}
  64  		} else {
  65  			log.W.F("attachments present but no Blossom uploader configured")
  66  		}
  67  	}
  68  
  69  	// Add reply link if compose URL is configured
  70  	if ip.composeURL != "" {
  71  		replyLink := GenerateReplyLink(ip.composeURL, parsed.From, parsed.Subject)
  72  		dm.WriteString(fmt.Sprintf("Reply: %s\n", replyLink))
  73  	}
  74  
  75  	dm.WriteString("\n")
  76  
  77  	// Body
  78  	body := parsed.TextPlain
  79  	if body == "" && parsed.TextHTML != "" {
  80  		body = "[HTML-only email — see attachment]"
  81  	}
  82  	dm.WriteString(body)
  83  
  84  	// Send DM to recipient
  85  	if err := ip.sendDM(recipientPubkeyHex, dm.String()); err != nil {
  86  		return fmt.Errorf("send DM: %w", err)
  87  	}
  88  
  89  	log.I.F("inbound email from %s to %s forwarded as DM", parsed.From, recipientPubkeyHex)
  90  	return nil
  91  }
  92  
  93  func (ip *InboundProcessor) processAttachments(htmlBody string, attachments []bridgesmtp.Attachment) (string, error) {
  94  	// Only process if there's something to zip
  95  	if htmlBody == "" && len(attachments) == 0 {
  96  		return "", nil
  97  	}
  98  
  99  	// Zip non-text parts
 100  	zipData, err := ZipParts(htmlBody, attachments)
 101  	if err != nil {
 102  		return "", fmt.Errorf("zip parts: %w", err)
 103  	}
 104  
 105  	// Encrypt
 106  	encrypted, keyHex, err := EncryptAttachment(zipData)
 107  	if err != nil {
 108  		return "", fmt.Errorf("encrypt: %w", err)
 109  	}
 110  
 111  	// Upload to Blossom
 112  	url, err := ip.blossom.Upload(encrypted, "application/octet-stream")
 113  	if err != nil {
 114  		return "", fmt.Errorf("blossom upload: %w", err)
 115  	}
 116  
 117  	// Append key as fragment (never sent to server)
 118  	return url + "#" + keyHex, nil
 119  }
 120  
 121  // GenerateReplyLink creates a compose form URL pre-populated with reply fields.
 122  func GenerateReplyLink(baseURL, replyTo, subject string) string {
 123  	// Fragment-only params so the server never sees the data
 124  	if !strings.HasPrefix(subject, "Re: ") {
 125  		subject = "Re: " + subject
 126  	}
 127  	return fmt.Sprintf("%s#to=%s&subject=%s", baseURL, url.QueryEscape(replyTo), url.QueryEscape(subject))
 128  }
 129