The codebase that powers boop.cat
boop.cat
1// Copyright 2025 boop.cat
2// Licensed under the Apache License, Version 2.0
3// See LICENSE file for details.
4
5package lib
6
7import (
8 "bytes"
9 "encoding/json"
10 "fmt"
11 "log"
12 "net/http"
13 "os"
14 "time"
15
16 "github.com/emersion/go-imap"
17 "github.com/emersion/go-imap/client"
18 "github.com/emersion/go-message/mail"
19)
20
21type DMCAMonitor struct {
22 IMAPHost string
23 IMAPPort string
24 IMAPUser string
25 IMAPPass string
26 IMAPTLS bool
27 WebhookURL string
28 pollTicker *time.Ticker
29 stopChan chan struct{}
30}
31
32func NewDMCAMonitor() *DMCAMonitor {
33 return &DMCAMonitor{
34 IMAPHost: os.Getenv("IMAP_HOST"),
35 IMAPPort: os.Getenv("IMAP_PORT"),
36 IMAPUser: os.Getenv("IMAP_USER"),
37 IMAPPass: os.Getenv("IMAP_PASSWORD"),
38 IMAPTLS: os.Getenv("IMAP_TLS") == "true",
39 WebhookURL: os.Getenv("DISCORD_DMCA_WEBHOOK_URL"),
40 stopChan: make(chan struct{}),
41 }
42}
43
44func (m *DMCAMonitor) Start() {
45 if m.IMAPHost == "" || m.IMAPUser == "" || m.IMAPPass == "" {
46 log.Println("DMCA Monitor: Missing IMAP credentials, skipping.")
47 return
48 }
49
50 m.CheckEmails()
51
52 m.pollTicker = time.NewTicker(5 * time.Minute)
53 go func() {
54 for {
55 select {
56 case <-m.pollTicker.C:
57 m.CheckEmails()
58 case <-m.stopChan:
59 m.pollTicker.Stop()
60 return
61 }
62 }
63 }()
64 log.Println("DMCA Monitor started.")
65}
66
67func (m *DMCAMonitor) Stop() {
68 close(m.stopChan)
69}
70
71func (m *DMCAMonitor) CheckEmails() {
72 if m.IMAPHost == "" || m.IMAPUser == "" || m.IMAPPass == "" {
73 log.Println("DMCA Monitor: Missing IMAP credentials, skipping.")
74 return
75 }
76
77 port := m.IMAPPort
78 if port == "" {
79 port = "993"
80 }
81
82 addr := fmt.Sprintf("%s:%s", m.IMAPHost, port)
83
84 var c *client.Client
85 var err error
86
87 if m.IMAPTLS {
88 c, err = client.DialTLS(addr, nil)
89 } else {
90 c, err = client.Dial(addr)
91 if err == nil {
92
93 if ok, _ := c.SupportStartTLS(); ok {
94 if err := c.StartTLS(nil); err != nil {
95 log.Printf("DMCA Monitor: STARTTLS failed: %v", err)
96 return
97 }
98 }
99 }
100 }
101 if err != nil {
102 log.Printf("DMCA Monitor: Failed to connect: %v", err)
103 return
104 }
105 defer c.Logout()
106
107 if err := c.Login(m.IMAPUser, m.IMAPPass); err != nil {
108 log.Printf("DMCA Monitor: Login failed: %v", err)
109 return
110 }
111
112 mbox, err := c.Select("INBOX", false)
113 if err != nil {
114 log.Printf("DMCA Monitor: Failed to select INBOX: %v", err)
115 return
116 }
117
118 if mbox.Messages == 0 {
119 return
120 }
121
122 criteria := imap.NewSearchCriteria()
123 criteria.WithoutFlags = []string{imap.SeenFlag}
124
125 uids, err := c.Search(criteria)
126 if err != nil {
127 log.Printf("DMCA Monitor: Search failed: %v", err)
128 return
129 }
130
131 if len(uids) == 0 {
132 return
133 }
134
135 seqSet := new(imap.SeqSet)
136 seqSet.AddNum(uids...)
137
138 section := &imap.BodySectionName{}
139 items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope}
140
141 messages := make(chan *imap.Message, len(uids))
142 done := make(chan error, 1)
143 go func() {
144 done <- c.Fetch(seqSet, items, messages)
145 }()
146
147 for msg := range messages {
148 if msg == nil {
149 continue
150 }
151
152 r := msg.GetBody(section)
153 if r == nil {
154 continue
155 }
156
157 mr, err := mail.CreateReader(r)
158 if err != nil {
159 log.Printf("DMCA Monitor: Failed to create mail reader: %v", err)
160 continue
161 }
162
163 var from, subject, body string
164
165 header := mr.Header
166 if addrs, err := header.AddressList("From"); err == nil && len(addrs) > 0 {
167 from = addrs[0].String()
168 }
169 if s, err := header.Subject(); err == nil {
170 subject = s
171 }
172
173 for {
174 p, err := mr.NextPart()
175 if err != nil {
176 break
177 }
178
179 switch h := p.Header.(type) {
180 case *mail.InlineHeader:
181
182 contentType, _, _ := h.ContentType()
183
184 buf := new(bytes.Buffer)
185 buf.ReadFrom(p.Body)
186 partBody := buf.String()
187
188 if body == "" {
189 body = partBody
190 } else if contentType == "text/plain" {
191
192 body += "\n\n" + partBody
193 }
194 }
195 }
196
197 log.Printf("DMCA Monitor: Forwarding email from %s: %s", from, subject)
198
199 if m.WebhookURL != "" {
200 m.sendToDiscord(from, subject, body)
201 } else {
202 log.Println("DMCA Monitor: No Webhook URL configured.")
203 }
204 }
205
206 if err := <-done; err != nil {
207 log.Printf("DMCA Monitor: Fetch error: %v", err)
208 }
209
210 item := imap.FormatFlagsOp(imap.AddFlags, true)
211 flags := []interface{}{imap.SeenFlag}
212 if err := c.Store(seqSet, item, flags, nil); err != nil {
213 log.Printf("DMCA Monitor: Store failed: %v", err)
214 }
215}
216
217func (m *DMCAMonitor) sendToDiscord(from, subject, body string) {
218 if m.WebhookURL == "" {
219 return
220 }
221
222 header := fmt.Sprintf("**New DMCA/Legal-related Email Received**\n**From:** %s\n**Subject:** %s\n\n", from, subject)
223
224 if body == "" {
225 body = "(No content)"
226 }
227
228 const chunkSize = 1900
229 chunks := splitString(body, chunkSize)
230
231 firstChunk := header + ">>> " + chunks[0]
232 m.postWebhook(firstChunk)
233
234 for i := 1; i < len(chunks); i++ {
235 time.Sleep(500 * time.Millisecond)
236 m.postWebhook(">>> " + chunks[i])
237 }
238}
239
240func (m *DMCAMonitor) postWebhook(content string) {
241 payload := map[string]string{"content": content}
242 data, _ := json.Marshal(payload)
243
244 resp, err := http.Post(m.WebhookURL, "application/json", bytes.NewReader(data))
245 if err != nil {
246 log.Printf("DMCA Monitor: Discord webhook failed: %v", err)
247 return
248 }
249 resp.Body.Close()
250}
251
252func splitString(s string, chunkSize int) []string {
253 if len(s) <= chunkSize {
254 return []string{s}
255 }
256
257 var chunks []string
258 for i := 0; i < len(s); i += chunkSize {
259 end := i + chunkSize
260 if end > len(s) {
261 end = len(s)
262 }
263 chunks = append(chunks, s[i:end])
264 }
265 return chunks
266}
267
268func StartDMCAMonitor() *DMCAMonitor {
269 monitor := NewDMCAMonitor()
270 monitor.Start()
271 return monitor
272}