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