The codebase that powers boop.cat boop.cat
at main 272 lines 5.5 kB view raw
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}