1 package bridge
2 3 import (
4 "strings"
5 )
6 7 // ParsedDM represents a parsed outbound email DM from a user.
8 type ParsedDM struct {
9 // To is the list of recipient email addresses.
10 To []string
11 // Cc is the list of CC email addresses.
12 Cc []string
13 // Bcc is the list of BCC email addresses.
14 Bcc []string
15 // Subject is the email subject line.
16 Subject string
17 // Attachments is the list of Blossom fragment-key URLs.
18 Attachments []string
19 // Body is the email body text (everything after the blank line separator).
20 Body string
21 }
22 23 // DMCommand represents a recognized command in a DM.
24 type DMCommand int
25 26 const (
27 DMCommandNone DMCommand = iota // Not a command — treat as email or contact
28 DMCommandSubscribe // "subscribe" or "subscribe <alias>" command
29 DMCommandStatus // "status" command
30 )
31 32 // ClassifyDMResult holds the classified command and any extracted alias.
33 type ClassifyDMResult struct {
34 Command DMCommand
35 Alias string // Optional alias from "subscribe <alias>"
36 }
37 38 // ClassifyDM determines what kind of DM this is:
39 // - A subscribe command (optionally with alias)
40 // - A status command
41 // - An outbound email (has To: header)
42 // - A contact message (everything else → blind proxy)
43 func ClassifyDM(content string) DMCommand {
44 r := ClassifyDMFull(content)
45 return r.Command
46 }
47 48 // ClassifyDMFull returns the classified command along with any extracted alias.
49 func ClassifyDMFull(content string) ClassifyDMResult {
50 trimmed := strings.TrimSpace(strings.ToLower(content))
51 if trimmed == "subscribe" {
52 return ClassifyDMResult{Command: DMCommandSubscribe}
53 }
54 if strings.HasPrefix(trimmed, "subscribe ") {
55 alias := strings.TrimSpace(trimmed[len("subscribe "):])
56 if alias != "" {
57 return ClassifyDMResult{Command: DMCommandSubscribe, Alias: alias}
58 }
59 return ClassifyDMResult{Command: DMCommandSubscribe}
60 }
61 if trimmed == "status" {
62 return ClassifyDMResult{Command: DMCommandStatus}
63 }
64 return ClassifyDMResult{Command: DMCommandNone}
65 }
66 67 // IsOutboundEmail returns true if the DM content looks like an outbound email
68 // (starts with recognized headers like To:, Subject:, etc.)
69 func IsOutboundEmail(content string) bool {
70 // Check first non-empty line for a header-like pattern
71 lines := strings.SplitN(content, "\n", 2)
72 if len(lines) == 0 {
73 return false
74 }
75 first := strings.TrimSpace(lines[0])
76 firstLower := strings.ToLower(first)
77 return strings.HasPrefix(firstLower, "to:") ||
78 strings.HasPrefix(firstLower, "subject:") ||
79 strings.HasPrefix(firstLower, "cc:") ||
80 strings.HasPrefix(firstLower, "bcc:") ||
81 strings.HasPrefix(firstLower, "attachment:")
82 }
83 84 // ParseDMContent parses a DM message in RFC 822-style format into structured
85 // email fields. The format is:
86 //
87 // To: alice@example.com, bob@example.com
88 // Cc: carol@example.com
89 // Subject: Hello from Nostr
90 // Attachment: https://blossom.example/abc123#key
91 //
92 // Message body starts here.
93 //
94 // Headers are terminated by the first blank line. Everything after is the body.
95 // No From: header — the bridge derives the sender from the Nostr event pubkey.
96 func ParseDMContent(content string) (*ParsedDM, error) {
97 dm := &ParsedDM{}
98 99 lines := strings.Split(content, "\n")
100 bodyStart := 0
101 102 headerLoop:
103 for i, line := range lines {
104 trimmed := strings.TrimSpace(line)
105 106 // Blank line terminates headers
107 if trimmed == "" {
108 bodyStart = i + 1
109 break
110 }
111 112 // Parse header
113 colonIdx := strings.Index(trimmed, ":")
114 if colonIdx < 0 {
115 // Not a header — treat everything from here as body
116 bodyStart = i
117 break
118 }
119 120 key := strings.TrimSpace(trimmed[:colonIdx])
121 value := strings.TrimSpace(trimmed[colonIdx+1:])
122 123 switch strings.ToLower(key) {
124 case "to":
125 dm.To = parseAddressList(value)
126 case "cc":
127 dm.Cc = parseAddressList(value)
128 case "bcc":
129 dm.Bcc = parseAddressList(value)
130 case "subject":
131 dm.Subject = value
132 case "attachment":
133 if value != "" {
134 dm.Attachments = append(dm.Attachments, value)
135 }
136 default:
137 // Unknown header — treat everything from here as body
138 bodyStart = i
139 break headerLoop
140 }
141 }
142 143 // Extract body
144 if bodyStart < len(lines) {
145 dm.Body = strings.Join(lines[bodyStart:], "\n")
146 dm.Body = strings.TrimSpace(dm.Body)
147 }
148 149 return dm, nil
150 }
151 152 // parseAddressList splits a comma-or-space-separated list of email addresses.
153 // Per the spec, spaces are the real delimiter; commas are decorative.
154 func parseAddressList(s string) []string {
155 // Replace commas with spaces, then split on whitespace
156 s = strings.ReplaceAll(s, ",", " ")
157 parts := strings.Fields(s)
158 var addrs []string
159 for _, p := range parts {
160 p = strings.TrimSpace(p)
161 if p != "" && strings.Contains(p, "@") {
162 addrs = append(addrs, p)
163 }
164 }
165 return addrs
166 }
167