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