outbound.go raw

   1  package bridge
   2  
   3  import (
   4  	"fmt"
   5  	"strings"
   6  
   7  	"next.orly.dev/pkg/lol/log"
   8  
   9  	aclgrpc "next.orly.dev/pkg/acl/grpc"
  10  	bridgesmtp "next.orly.dev/pkg/bridge/smtp"
  11  )
  12  
  13  // OutboundProcessor handles converting DMs to outbound emails.
  14  type OutboundProcessor struct {
  15  	smtpClient  *bridgesmtp.Client
  16  	rateLimiter *RateLimiter
  17  	subHandler  *SubscriptionHandler
  18  	domain      string
  19  	sendDM      func(pubkeyHex string, content string) error
  20  	aclClient   *aclgrpc.Client
  21  }
  22  
  23  // NewOutboundProcessor creates an outbound DM-to-email processor.
  24  func NewOutboundProcessor(
  25  	smtpClient *bridgesmtp.Client,
  26  	rateLimiter *RateLimiter,
  27  	subHandler *SubscriptionHandler,
  28  	domain string,
  29  	sendDM func(pubkeyHex string, content string) error,
  30  	aclClient *aclgrpc.Client,
  31  ) *OutboundProcessor {
  32  	return &OutboundProcessor{
  33  		smtpClient:  smtpClient,
  34  		rateLimiter: rateLimiter,
  35  		subHandler:  subHandler,
  36  		domain:      domain,
  37  		sendDM:      sendDM,
  38  		aclClient:   aclClient,
  39  	}
  40  }
  41  
  42  // ProcessOutbound parses a DM as an outbound email and sends it.
  43  // senderPubkeyHex is the Nostr pubkey of the DM author.
  44  func (op *OutboundProcessor) ProcessOutbound(senderPubkeyHex, content string) error {
  45  	// Check subscription
  46  	if op.subHandler != nil && !op.subHandler.IsSubscribed(senderPubkeyHex) {
  47  		op.reply(senderPubkeyHex, "You need an active subscription to send emails. Send \"subscribe\" to get started.")
  48  		return nil
  49  	}
  50  
  51  	// Check rate limit
  52  	if op.rateLimiter != nil {
  53  		if err := op.rateLimiter.Check(senderPubkeyHex); err != nil {
  54  			op.reply(senderPubkeyHex, fmt.Sprintf("Rate limit: %v", err))
  55  			return nil
  56  		}
  57  	}
  58  
  59  	// Parse DM content as email
  60  	parsed, err := ParseDMContent(content)
  61  	if err != nil {
  62  		op.reply(senderPubkeyHex, fmt.Sprintf("Could not parse your message: %v", err))
  63  		return nil
  64  	}
  65  
  66  	if len(parsed.To) == 0 {
  67  		op.reply(senderPubkeyHex, "No recipients found. Start your message with:\nTo: recipient@example.com")
  68  		return nil
  69  	}
  70  
  71  	// Build the from address: alias@domain or truncated-pubkey@domain
  72  	fromLocal := senderPubkeyHex
  73  	if op.aclClient != nil {
  74  		if alias, err := op.aclClient.GetAliasByPubkey(senderPubkeyHex); err == nil && alias != "" {
  75  			fromLocal = alias
  76  		}
  77  	}
  78  	if fromLocal == senderPubkeyHex && len(fromLocal) > 16 {
  79  		fromLocal = fromLocal[:16]
  80  	}
  81  	fromAddr := fromLocal + "@" + op.domain
  82  
  83  	// Send via SMTP
  84  	email := &bridgesmtp.OutboundEmail{
  85  		From:    fromAddr,
  86  		To:      parsed.To,
  87  		Cc:      parsed.Cc,
  88  		Bcc:     parsed.Bcc,
  89  		Subject: parsed.Subject,
  90  		Body:    parsed.Body,
  91  	}
  92  
  93  	if err := op.smtpClient.Send(email); err != nil {
  94  		log.E.F("outbound email failed for %s: %v", senderPubkeyHex, err)
  95  		op.reply(senderPubkeyHex, fmt.Sprintf("Email delivery failed: %v", err))
  96  		return err
  97  	}
  98  
  99  	// Record for rate limiting
 100  	if op.rateLimiter != nil {
 101  		op.rateLimiter.Record(senderPubkeyHex)
 102  	}
 103  
 104  	// Confirm delivery
 105  	var recips []string
 106  	recips = append(recips, parsed.To...)
 107  	recips = append(recips, parsed.Cc...)
 108  	op.reply(senderPubkeyHex, fmt.Sprintf("Email sent to %s", strings.Join(recips, ", ")))
 109  
 110  	log.I.F("outbound email from %s to %v sent", senderPubkeyHex, parsed.To)
 111  	return nil
 112  }
 113  
 114  func (op *OutboundProcessor) reply(pubkeyHex, content string) {
 115  	if op.sendDM == nil {
 116  		return
 117  	}
 118  	if err := op.sendDM(pubkeyHex, content); err != nil {
 119  		log.E.F("failed to send reply DM to %s: %v", pubkeyHex, err)
 120  	}
 121  }
 122