parser.go raw

   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