[CI] pgitBot
·
2026-02-26
pgitBot.go
Go
1package main
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/aes"
7 "crypto/cipher"
8 "crypto/rand"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "flag"
13 "fmt"
14 "io"
15 "log"
16 "mime"
17 "mime/multipart"
18 "net/mail"
19 "net/smtp"
20 "os"
21 "path/filepath"
22 "regexp"
23 "strconv"
24 "strings"
25 "sync"
26 "time"
27 "unicode"
28
29 "github.com/emersion/go-imap/v2"
30 "github.com/emersion/go-imap/v2/imapclient"
31 "github.com/goccy/go-yaml"
32)
33
34var (
35 debug bool
36 errConfigLoad = errors.New("config load error")
37 errConfigCreate = errors.New("config create error")
38 errDBEncrypt = errors.New("database encryption error")
39 errDBDecrypt = errors.New("database decryption error")
40)
41
42type RepoConfig struct {
43 DBFile string `yaml:"db_file"`
44 ExcludeFromGlobal bool `yaml:"exclude_from_global"`
45 SubjectTag *string `yaml:"subject_tag"`
46 IMAPMailbox *string `yaml:"imap_mailbox"`
47 Reactions map[string]string `yaml:"reactions"`
48 MarkAs map[string]string `yaml:"mark_as"`
49 Permissions map[string][]string `yaml:"permissions"`
50}
51
52type GlobalConfig struct {
53 IMAPServer string `yaml:"imap_server"`
54 IMAPUsername string `yaml:"imap_username"`
55 IMAPPassword string `yaml:"imap_password"`
56 SMTPServer string `yaml:"smtp_server"`
57 SMTPUsername string `yaml:"smtp_username"`
58 SMTPPassword string `yaml:"smtp_password"`
59 SMTPFromAddr string `yaml:"smtp_from_addr"`
60 SMTPFromAlias *string `yaml:"smtp_from_alias,omitempty"`
61 DBEncryptionKey string `yaml:"db_encryption_key"`
62 DisableEncryption bool `yaml:"disable_encryption"`
63 SubjectTag string `yaml:"subject_tag"`
64 IMAPMailbox string `yaml:"imap_mailbox"`
65 Reactions map[string]string `yaml:"reactions"`
66 MarkAs map[string]string `yaml:"mark_as"`
67 GlobalDBFile string `yaml:"global_db_file"`
68 Permissions map[string][]string `yaml:"permissions"`
69}
70
71type Config struct {
72 Global GlobalConfig `yaml:"global"`
73 Repos map[string]RepoConfig `yaml:"repos"`
74}
75
76type ResolvedRepoConfig struct {
77 Name string
78 DBFile string
79 SubjectTag string
80 IMAPMailbox string
81 Reactions map[string]string
82 MarkAs map[string]string
83 Permissions map[string][]string
84}
85
86type EditRecord struct {
87 Timestamp time.Time `json:"timestamp"`
88 Body string `json:"body"`
89}
90
91type Reaction struct {
92 Emoji string `json:"emoji"`
93 Author string `json:"author"`
94}
95
96type Comment struct {
97 ID int `json:"id"`
98 Author string `json:"author"`
99 Body string `json:"body"`
100 CreatedAt time.Time `json:"createdAt"`
101 Reactions []Reaction `json:"reactions"`
102 History []EditRecord `json:"history,omitempty"`
103}
104
105type Issue struct {
106 ID int `json:"id"`
107 Title string `json:"title"`
108 Author string `json:"author"`
109 Body string `json:"body"`
110 CreatedAt time.Time `json:"createdAt"`
111 Comments []Comment `json:"comments"`
112 Reactions []Reaction `json:"reactions"`
113 History []EditRecord `json:"history,omitempty"`
114 IsClosed bool `json:"isClosed"`
115 Status string `json:"status"`
116 StatusClass string `json:"statusClass"`
117}
118
119type RepoDatabase struct {
120 Issues map[int]*Issue `json:"issues"`
121 NextIssueID int `json:"nextIssueId"`
122 NextCommentID int `json:"nextCommentId"`
123 ProcessedUIDs map[imap.UID]bool `json:"processedUids"`
124 RejectedUIDs map[imap.UID]string `json:"rejectedUids,omitempty"`
125 RepoName string `json:"repoName"`
126 Reactions map[string]string `json:"reactions"`
127 MarkAs map[string]string `json:"mark_as"`
128 IssuesEmail string `json:"issuesEmail"`
129 SubjectTag string `json:"subjectTag"`
130 AliasToEmail map[string]string `json:"aliasToEmail,omitempty"`
131 EmailToAlias map[string]string `json:"emailToAlias,omitempty"`
132 mu sync.Mutex `json:"-"`
133}
134
135type GlobalDatabase struct {
136 Repos map[string]*RepoDatabase `json:"repos"`
137 AliasToEmail map[string]string `json:"aliasToEmail"`
138 EmailToAlias map[string]string `json:"emailToAlias"`
139 mu sync.Mutex `json:"-"`
140}
141
142// RejectionMail is an item in the queue for sending rejection emails.
143type RejectionMail struct {
144 To string
145 Reason string
146 Cfg *GlobalConfig
147}
148
149// RejectionResult is used to pass rejection data back from goroutines.
150type RejectionResult struct {
151 UID imap.UID
152 Reason string
153}
154
155func main() {
156 var configPath string
157 flag.BoolVar(&debug, "debug", false, "Enable verbose IMAP debug output to stderr")
158 flag.StringVar(&configPath, "config", "pgitBot.yml", "Path to config.yml file")
159 flag.Parse()
160
161 log.Println("Starting pgitBot...")
162
163 cfg, err := loadConfig(configPath)
164 if err != nil {
165 log.Fatalf("Failed to load configuration: %v", err)
166 }
167
168 rejectionQueue := make(chan RejectionMail, 100)
169 var appWg sync.WaitGroup
170 appWg.Add(1)
171 go rejectionSender(&appWg, rejectionQueue)
172
173 var globalDB *GlobalDatabase
174 if cfg.Global.GlobalDBFile != "" {
175 globalDB, err = loadGlobalDatabase(cfg.Global.GlobalDBFile, &cfg.Global)
176 if err != nil {
177 log.Fatalf("Failed to load global database '%s': %v", cfg.Global.GlobalDBFile, err)
178 }
179 log.Printf("Global database enabled at '%s'", cfg.Global.GlobalDBFile)
180 }
181
182 c, err := connectIMAP(&cfg.Global)
183 if err != nil {
184 log.Fatalf("IMAP connection failed: %v", err)
185 }
186 defer func() {
187 if err := c.Logout().Wait(); err != nil {
188 log.Printf("Logout error: %v", err)
189 }
190 }()
191 log.Println("Successfully connected to IMAP server.")
192
193 for repoName, repoCfg := range cfg.Repos {
194 log.Printf("--- Processing repository: %s ---", repoName)
195
196 resolvedCfg := resolveRepoConfig(repoName, &cfg.Global, &repoCfg)
197
198 var repoDB *RepoDatabase
199 if resolvedCfg.DBFile != "" {
200 repoDB, err = loadRepoDatabase(resolvedCfg.DBFile, &cfg.Global)
201 if err != nil {
202 log.Printf("ERROR: Could not load repo database for '%s', skipping: %v", repoName, err)
203 continue
204 }
205 } else {
206 log.Printf("Repo '%s' has no db_file, will process in-memory.", repoName)
207 repoDB = newRepoDatabase()
208 }
209
210 repoDB.RepoName = resolvedCfg.Name
211 repoDB.Reactions = resolvedCfg.Reactions
212 repoDB.MarkAs = resolvedCfg.MarkAs
213 repoDB.IssuesEmail = cfg.Global.SMTPFromAddr
214 repoDB.SubjectTag = resolvedCfg.SubjectTag
215
216 if err := processEmails(c, repoDB, &cfg.Global, &resolvedCfg, globalDB, rejectionQueue); err != nil {
217 log.Printf("An error occurred during email processing for repo %s: %v", repoName, err)
218 }
219
220 if resolvedCfg.DBFile != "" {
221 if err := saveRepoDatabase(resolvedCfg.DBFile, repoDB, &cfg.Global); err != nil {
222 log.Printf("Failed to save database for repo %s: %v", repoName, err)
223 }
224 }
225
226 if globalDB != nil && !repoCfg.ExcludeFromGlobal {
227 log.Printf("Merging repo '%s' into global database.", repoName)
228 globalDB.mu.Lock()
229 dbCopy := *repoDB
230 dbCopy.ProcessedUIDs = nil
231 // Don't merge rejection history into the global DB.
232 dbCopy.RejectedUIDs = nil
233 globalDB.Repos[repoName] = &dbCopy
234 globalDB.mu.Unlock()
235 }
236
237 if err := cleanupOldRejections(c, &resolvedCfg); err != nil {
238 log.Printf("WARNING: Failed to clean up old rejection emails for repo %s: %v", repoName, err)
239 }
240
241 log.Printf("--- Finished processing repository: %s ---", repoName)
242 }
243
244 if globalDB != nil {
245 if err := saveGlobalDatabase(cfg.Global.GlobalDBFile, globalDB, &cfg.Global); err != nil {
246 log.Printf("FATAL: Failed to save global database: %v", err)
247 }
248 }
249
250 log.Println("All repositories processed. Closing rejection queue...")
251 close(rejectionQueue)
252 appWg.Wait()
253
254 log.Println("pgitBot finished.")
255}
256
257// processEnvVars finds placeholders like {{ env.VAR_NAME }} and replaces them
258// with the corresponding environment variable's value.
259func processEnvVars(data []byte) ([]byte, error) {
260 re := regexp.MustCompile(`\{\{\s*env\.(\w+)\s*\}\}`)
261 var firstError error
262
263 processed := re.ReplaceAllStringFunc(string(data), func(match string) string {
264 if firstError != nil {
265 return "" // Stop processing if an error has occurred
266 }
267
268 varName := re.FindStringSubmatch(match)[1]
269 value := os.Getenv(varName)
270
271 if value == "" {
272 firstError = fmt.Errorf("environment variable '%s' not set or is empty", varName)
273 return ""
274 }
275 return value
276 })
277
278 if firstError != nil {
279 return nil, firstError
280 }
281
282 return []byte(processed), nil
283}
284
285func loadConfig(configPath string) (*Config, error) {
286 if _, err := os.Stat(configPath); os.IsNotExist(err) {
287 log.Printf("Config file not found at '%s'. Creating a default one.", configPath)
288 if err := createDefaultConfigAt(configPath); err != nil {
289 return nil, fmt.Errorf("%w: %v", errConfigCreate, err)
290 }
291 log.Println("Please edit the default pgitBot.yml and restart the application.")
292 os.Exit(1)
293 }
294
295 yamlFile, err := os.ReadFile(configPath)
296 if err != nil {
297 return nil, fmt.Errorf("%w: could not read yaml file: %v", errConfigLoad, err)
298 }
299
300 // Pre-process the YAML to substitute environment variables
301 processedYaml, err := processEnvVars(yamlFile)
302 if err != nil {
303 return nil, fmt.Errorf("%w: could not process environment variables in config: %v", errConfigLoad, err)
304 }
305
306 var cfg Config
307 if err := yaml.Unmarshal(processedYaml, &cfg); err != nil {
308 return nil, fmt.Errorf("%w: could not unmarshal yaml: %v", errConfigLoad, err)
309 }
310
311 applyGlobalDefaults(&cfg.Global)
312 return &cfg, nil
313}
314
315func applyGlobalDefaults(g *GlobalConfig) {
316 if g.IMAPMailbox == "" {
317 g.IMAPMailbox = "INBOX"
318 }
319 if g.SubjectTag == "" {
320 g.SubjectTag = "[pgit]"
321 }
322 if g.Reactions == nil {
323 g.Reactions = map[string]string{
324 "thumbs-up": "👍", "thumbs-down": "👎", "laugh": "😄", "hooray": "🎉",
325 "confused": "😕", "heart": "❤️", "rocket": "🚀", "eyes": "👀",
326 }
327 }
328 if g.MarkAs == nil {
329 g.MarkAs = map[string]string{
330 "Open": "open", "In Progress": "in-progress", "Resolved": "resolved",
331 "Not Planned": "not-planned", "Duplicate": "duplicate",
332 }
333 }
334}
335
336func resolveRepoConfig(name string, g *GlobalConfig, r *RepoConfig) ResolvedRepoConfig {
337 resolved := ResolvedRepoConfig{
338 Name: name,
339 DBFile: r.DBFile,
340 }
341
342 if r.SubjectTag != nil {
343 resolved.SubjectTag = *r.SubjectTag
344 } else {
345 // Example: global [pgit], repo gbc -> [pgit-gbc]
346 baseTag := strings.TrimSuffix(strings.TrimPrefix(g.SubjectTag, "["), "]")
347 resolved.SubjectTag = fmt.Sprintf("[%s-%s]", baseTag, name)
348 }
349
350 if r.IMAPMailbox != nil {
351 resolved.IMAPMailbox = *r.IMAPMailbox
352 } else {
353 resolved.IMAPMailbox = g.IMAPMailbox
354 }
355
356 if r.Reactions != nil {
357 resolved.Reactions = r.Reactions
358 } else {
359 resolved.Reactions = g.Reactions
360 }
361
362 if r.MarkAs != nil {
363 resolved.MarkAs = r.MarkAs
364 } else {
365 resolved.MarkAs = g.MarkAs
366 }
367
368 // Merge permissions. Repo-specific permissions override global ones.
369 resolved.Permissions = make(map[string][]string)
370 if g.Permissions != nil {
371 for k, v := range g.Permissions {
372 resolved.Permissions[k] = v
373 }
374 }
375 if r.Permissions != nil {
376 for k, v := range r.Permissions {
377 resolved.Permissions[k] = v
378 }
379 }
380
381 return resolved
382}
383
384func createDefaultConfigAt(path string) error {
385 defaultConfig := `
386global:
387 imap_server: "imap.gmail.com:993"
388 imap_username: "[email protected]"
389 # Use environment variables for sensitive data.
390 # Example: export IMAP_PASSWORD="your-app-password"
391 imap_password: "{{ env.IMAP_PASSWORD }}"
392 smtp_server: "smtp.gmail.com:587"
393 smtp_username: "[email protected]"
394 smtp_password: "{{ env.SMTP_PASSWORD }}"
395 smtp_from_addr: "[email protected]"
396 # smtp_from_alias: "PGIT Bot <[email protected]>" # Optional
397 # Generate a key with: openssl rand -hex 32
398 db_encryption_key: "{{ env.DB_ENCRYPTION_KEY }}"
399 disable_encryption: false
400 subject_tag: "[pgit]"
401 imap_mailbox: "INBOX"
402 global_db_file: "pgit-global.json"
403 reactions:
404 lovebear: "ʕ♥ᴥ♥ʔ"
405 smiley: "☺︎"
406 mark_as:
407 Open: "open"
408 Closed: "closed"
409 # Permissions block.
410 # 'all': Grants all permissions, including moderating content from others.
411 # 'moderate': Allows editing and deleting issues/comments from other users.
412 # Users not matching a specific rule receive default 'user' permissions.
413 # This allows creating/editing/deleting/reacting to their own content.
414 # To explicitly deny a user, add their email with an empty list.
415 permissions:
416 "[email protected]": ["all"]
417 "[email protected]": ["mark-as", "moderate"] # Can mark status and moderate content
418 "[email protected]": ["create-issue", "add-comment", "react", "unreact", "edit", "delete-issue", "delete-comment"]
419 "*@trusted-company.com": ["create-issue", "add-comment"]
420 "[email protected]": [] # Explicitly deny
421 "*": ["react", "unreact"]
422
423repos:
424 # Tag will be [pgit-my-first-repo]
425 my-first-repo:
426 db_file: "repo1-issues.json"
427 my-second-repo:
428 db_file: "repo2-issues.json"
429 subject_tag: "[repo2-custom]"
430 my-secret-repo:
431 db_file: "secret-issues.json"
432 subject_tag: "[secret]"
433 exclude_from_global: true
434 permissions:
435 "[email protected]": ["all"]
436 "*": [] # Only the project lead can act on this repo.
437`
438 return os.WriteFile(path, []byte(defaultConfig), 0600)
439}
440
441func connectIMAP(cfg *GlobalConfig) (*imapclient.Client, error) {
442 options := &imapclient.Options{}
443 if debug {
444 log.Println("DEBUG mode enabled. Raw IMAP commands will be logged.")
445 options.DebugWriter = os.Stderr
446 }
447
448 c, err := imapclient.DialTLS(cfg.IMAPServer, options)
449 if err != nil {
450 return nil, fmt.Errorf("failed to dial IMAP server: %w", err)
451 }
452
453 if err := c.Login(cfg.IMAPUsername, cfg.IMAPPassword).Wait(); err != nil {
454 c.Logout().Wait()
455 return nil, fmt.Errorf("failed to login: %w", err)
456 }
457 return c, nil
458}
459
460// processEmails fetches emails, processes their content in parallel, and then
461// marks them as seen on the server.
462func processEmails(c *imapclient.Client, db *RepoDatabase, globalCfg *GlobalConfig, repoCfg *ResolvedRepoConfig, globalDB *GlobalDatabase, rejectionQueue chan<- RejectionMail) error {
463 selectCmd := c.Select(repoCfg.IMAPMailbox, nil)
464 _, err := selectCmd.Wait()
465 if err != nil {
466 return fmt.Errorf("failed to select '%s' mailbox: %w", repoCfg.IMAPMailbox, err)
467 }
468
469 // Search for UNSEEN emails with the subject tag. This uses the IMAP server
470 // as the source of truth, preventing reprocessing if the local DB is lost.
471 searchCriteria := &imap.SearchCriteria{
472 Header: []imap.SearchCriteriaHeaderField{
473 {Key: "Subject", Value: repoCfg.SubjectTag},
474 },
475 NotFlag: []imap.Flag{imap.FlagSeen},
476 }
477
478 searchCmd := c.UIDSearch(searchCriteria, nil)
479 searchData, err := searchCmd.Wait()
480 if err != nil {
481 return fmt.Errorf("email search failed: %w", err)
482 }
483 uids := searchData.AllUIDs()
484
485 if len(uids) == 0 {
486 log.Printf("No new (unseen) emails with subject tag '%s' found in '%s' mailbox.", repoCfg.SubjectTag, repoCfg.IMAPMailbox)
487 return nil
488 }
489
490 var processingWg sync.WaitGroup
491 processedUIDsChan := make(chan imap.UID, len(uids))
492 rejectedUIDsChan := make(chan RejectionResult, len(uids))
493
494 fetchOptions := &imap.FetchOptions{
495 BodySection: []*imap.FetchItemBodySection{{}},
496 }
497
498 log.Printf("Found %d new emails to process for repo '%s'.", len(uids), repoCfg.Name)
499
500 // Sequentially fetch emails to avoid overwhelming the IMAP server.
501 for _, uid := range uids {
502 // This local check is redundant due to the UNSEEN search, but kept as a safeguard.
503 db.mu.Lock()
504 _, isProcessed := db.ProcessedUIDs[uid]
505 _, isRejected := db.RejectedUIDs[uid]
506 db.mu.Unlock()
507
508 if isProcessed || isRejected {
509 continue
510 }
511
512 uidSet := imap.UIDSetNum(uid)
513 fetchCmd := c.Fetch(uidSet, fetchOptions)
514 msg := fetchCmd.Next()
515 if msg == nil {
516 fetchCmd.Close()
517 log.Printf("UID %v: Fetch command returned no message data.", uid)
518 continue
519 }
520
521 msgBuffer, err := msg.Collect()
522 if err != nil {
523 fetchCmd.Close()
524 log.Printf("UID %v: Failed to collect message data: %v", uid, err)
525 continue
526 }
527 fetchCmd.Close()
528
529 bodyBytes := msgBuffer.FindBodySection(&imap.FetchItemBodySection{})
530 if bodyBytes == nil {
531 log.Printf("Could not find message body for UID %v, skipping.", uid)
532 continue
533 }
534
535 processingWg.Add(1)
536 go func(body []byte, currentUID imap.UID) {
537 defer processingWg.Done()
538 err := processSingleEmail(bytes.NewReader(body), db, globalCfg, repoCfg, globalDB, rejectionQueue)
539 if err != nil {
540 log.Printf("UID %v: Rejected. Reason: %v", currentUID, err)
541 rejectedUIDsChan <- RejectionResult{UID: currentUID, Reason: err.Error()}
542 } else {
543 log.Printf("UID %v: Successfully processed for repo '%s' with tag '%s'.", currentUID, repoCfg.Name, repoCfg.SubjectTag)
544 processedUIDsChan <- currentUID
545 }
546 }(bodyBytes, uid)
547 }
548
549 go func() {
550 processingWg.Wait()
551 close(processedUIDsChan)
552 close(rejectedUIDsChan)
553 }()
554
555 var processedUIDs []imap.UID
556 var rejectedResults []RejectionResult
557 var uidsToMarkSeen imap.UIDSet
558
559 for uid := range processedUIDsChan {
560 processedUIDs = append(processedUIDs, uid)
561 }
562 for rejection := range rejectedUIDsChan {
563 rejectedResults = append(rejectedResults, rejection)
564 }
565
566 db.mu.Lock()
567 for _, uid := range processedUIDs {
568 db.ProcessedUIDs[uid] = true
569 uidsToMarkSeen.AddNum(uid)
570 }
571 for _, rejection := range rejectedResults {
572 db.RejectedUIDs[rejection.UID] = rejection.Reason
573 uidsToMarkSeen.AddNum(rejection.UID)
574 }
575 db.mu.Unlock()
576
577 if len(uidsToMarkSeen) > 0 {
578 log.Printf("Marking %d emails as seen on server.", len(uidsToMarkSeen))
579 storeCmd := c.Store(uidsToMarkSeen, &imap.StoreFlags{
580 Op: imap.StoreFlagsAdd,
581 Silent: true,
582 Flags: []imap.Flag{imap.FlagSeen},
583 }, nil)
584 if err := storeCmd.Close(); err != nil {
585 log.Printf("Failed to mark emails as seen on server: %v", err)
586 }
587 }
588
589 return nil
590}
591
592func extractTextFromMIMEMessage(msg *mail.Message) (string, error) {
593 mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
594 if err != nil {
595 return "", fmt.Errorf("cannot parse Content-Type: %w", err)
596 }
597
598 if strings.HasPrefix(mediaType, "multipart/") {
599 mr := multipart.NewReader(msg.Body, params["boundary"])
600 for {
601 p, err := mr.NextPart()
602 if err == io.EOF {
603 break
604 }
605 if err != nil {
606 return "", fmt.Errorf("error reading multipart body: %w", err)
607 }
608 partMediaType, _, err := mime.ParseMediaType(p.Header.Get("Content-Type"))
609 if err != nil {
610 continue
611 }
612 if strings.HasPrefix(partMediaType, "text/plain") {
613 bodyBytes, err := io.ReadAll(p)
614 if err != nil {
615 return "", fmt.Errorf("error reading part body: %w", err)
616 }
617 return string(bodyBytes), nil
618 }
619 }
620 return "", errors.New("no text/plain part found in multipart message")
621 } else if strings.HasPrefix(mediaType, "text/plain") {
622 bodyBytes, err := io.ReadAll(msg.Body)
623 if err != nil {
624 return "", fmt.Errorf("error reading simple text body: %w", err)
625 }
626 return string(bodyBytes), nil
627 }
628
629 return "", fmt.Errorf("unsupported Content-Type: %s", mediaType)
630}
631
632func processSingleEmail(body io.Reader, db *RepoDatabase, globalCfg *GlobalConfig, repoCfg *ResolvedRepoConfig, globalDB *GlobalDatabase, rejectionQueue chan<- RejectionMail) error {
633 msg, err := mail.ReadMessage(body)
634 if err != nil {
635 return fmt.Errorf("failed to parse email: %w", err)
636 }
637
638 fromAddr, err := mail.ParseAddress(msg.Header.Get("From"))
639 if err != nil {
640 fromAddr = &mail.Address{Address: "[email protected]"}
641 }
642
643 subject := msg.Header.Get("Subject")
644 forwardPrefixes := []string{"Fwd:", "FW:", "fwd:", "fw:"}
645 trimmedSubject := strings.TrimSpace(subject)
646 for _, prefix := range forwardPrefixes {
647 if strings.HasPrefix(trimmedSubject, prefix) {
648 rejectionReason := "Forwarded emails are not processed."
649 queueRejectionEmail(rejectionQueue, fromAddr.Address, rejectionReason, globalCfg)
650 return errors.New(rejectionReason)
651 }
652 }
653
654 plainTextBody, err := extractTextFromMIMEMessage(msg)
655 if err != nil {
656 rejectionReason := fmt.Sprintf("Failed to extract plain text content: %v. pgitBot only supports plain text emails.", err)
657 queueRejectionEmail(rejectionQueue, fromAddr.Address, rejectionReason, globalCfg)
658 return errors.New(rejectionReason)
659 }
660
661 mainBody, commandBlocks, err := parseCommandBlocks(plainTextBody)
662 if err != nil {
663 rejectionReason := fmt.Sprintf("Invalid command block: %v", err)
664 queueRejectionEmail(rejectionQueue, fromAddr.Address, rejectionReason, globalCfg)
665 return errors.New(rejectionReason)
666 }
667
668 if len(commandBlocks) == 0 {
669 log.Println("No command blocks found in email, ignoring.")
670 return nil
671 }
672
673 if len(commandBlocks) > 1 {
674 rejectionReason := "Multiple command blocks found. Only one action per email is permitted."
675 queueRejectionEmail(rejectionQueue, fromAddr.Address, rejectionReason, globalCfg)
676 return errors.New(rejectionReason)
677 }
678
679 block := commandBlocks[0]
680 for key, value := range block {
681 if !isPrintable(key) || !isPrintable(value) {
682 rejectionReason := "Commands contain non-visible or non-printable characters."
683 queueRejectionEmail(rejectionQueue, fromAddr.Address, rejectionReason, globalCfg)
684 return errors.New(rejectionReason)
685 }
686 }
687
688 if err := executeCommand(block, mainBody, fromAddr.Address, db, repoCfg, globalDB); err != nil {
689 rejectionReason := fmt.Sprintf("Command execution failed: %v", err)
690 queueRejectionEmail(rejectionQueue, fromAddr.Address, rejectionReason, globalCfg)
691 return err
692 }
693
694 return nil
695}
696
697func isPrintable(s string) bool {
698 for _, r := range s {
699 if !unicode.IsPrint(r) {
700 return false
701 }
702 }
703 return true
704}
705
706func parseCommandBlocks(body string) (string, []map[string]string, error) {
707 var blocks []map[string]string
708 var mainBody strings.Builder
709 scanner := bufio.NewScanner(strings.NewReader(body))
710 var currentBlock map[string]string
711 inBlock := false
712 foundFirstBlock := false
713
714 for scanner.Scan() {
715 line := scanner.Text()
716 trimmedLine := strings.TrimSpace(line)
717
718 if trimmedLine == "---pgitBot---" {
719 if !foundFirstBlock {
720 foundFirstBlock = true
721 }
722 if inBlock {
723 if currentBlock != nil {
724 blocks = append(blocks, currentBlock)
725 }
726 inBlock = false
727 currentBlock = nil
728 } else {
729 inBlock = true
730 currentBlock = make(map[string]string)
731 }
732 continue
733 }
734
735 if inBlock && currentBlock != nil {
736 parts := strings.SplitN(trimmedLine, ":", 2)
737 if len(parts) == 2 {
738 key := strings.TrimSpace(parts[0])
739 value := strings.TrimSpace(parts[1])
740 currentBlock[key] = value
741 }
742 } else if !foundFirstBlock {
743 mainBody.WriteString(line)
744 mainBody.WriteString("\n")
745 }
746 }
747
748 return strings.TrimSpace(mainBody.String()), blocks, scanner.Err()
749}
750
751// hasPermission checks if an author is allowed to execute a specific command.
752// If a user is not explicitly defined in the permissions map, they receive a default "user" role.
753func hasPermission(author, command string, perms map[string][]string) bool {
754 // Default permissions for any user not explicitly defined in the map.
755 // Ownership is checked within the command execution logic itself.
756 defaultUserPermissions := map[string]bool{
757 "create-issue": true,
758 "add-comment": true,
759 "edit": true,
760 "react": true,
761 "unreact": true,
762 "delete-issue": true,
763 "delete-comment": true,
764 "alias": true,
765 "unalias": true,
766 "mark-as": true,
767 "moderate": false,
768 }
769
770 // Check for an exact email match first.
771 if allowedCmds, ok := perms[author]; ok {
772 // An empty list for a user means they are explicitly denied all actions.
773 if len(allowedCmds) == 0 {
774 return false
775 }
776 if len(allowedCmds) == 1 && allowedCmds[0] == "all" {
777 return true
778 }
779 for _, cmd := range allowedCmds {
780 if cmd == command {
781 return true
782 }
783 }
784 // A rule was found, but the command is not in the list. Deny.
785 return false
786 }
787
788 // Check for a wildcard domain match.
789 parts := strings.Split(author, "@")
790 if len(parts) == 2 {
791 wildcardDomain := "*@" + parts[1]
792 if allowedCmds, ok := perms[wildcardDomain]; ok {
793 if len(allowedCmds) == 0 {
794 return false
795 }
796 if len(allowedCmds) == 1 && allowedCmds[0] == "all" {
797 return true
798 }
799 for _, cmd := range allowedCmds {
800 if cmd == command {
801 return true
802 }
803 }
804 return false
805 }
806 }
807
808 // Check for a global wildcard match.
809 if allowedCmds, ok := perms["*"]; ok {
810 if len(allowedCmds) == 0 {
811 return false
812 }
813 if len(allowedCmds) == 1 && allowedCmds[0] == "all" {
814 return true
815 }
816 for _, cmd := range allowedCmds {
817 if cmd == command {
818 return true
819 }
820 }
821 return false
822 }
823
824 // No specific rule found, fall back to the default user permissions.
825 return defaultUserPermissions[command]
826}
827
828func executeCommand(cmd map[string]string, body, author string, db *RepoDatabase, repoCfg *ResolvedRepoConfig, globalDB *GlobalDatabase) error {
829 command, ok := cmd["command"]
830 if !ok {
831 return errors.New("command block is missing 'command' key")
832 }
833
834 if !hasPermission(author, command, repoCfg.Permissions) {
835 return fmt.Errorf("permission denied for user '%s' to execute command '%s'", author, command)
836 }
837
838 if command != "alias" && command != "unalias" {
839 db.mu.Lock()
840 defer db.mu.Unlock()
841 }
842
843 switch command {
844 case "create-issue":
845 title, ok := cmd["title"]
846 if !ok || title == "" {
847 return errors.New("create-issue requires a 'title'")
848 }
849
850 for _, issue := range db.Issues {
851 if strings.EqualFold(issue.Title, title) {
852 return fmt.Errorf("an issue with the title '%s' already exists (ID #%d)", title, issue.ID)
853 }
854 }
855
856 newID := db.NextIssueID
857 db.NextIssueID++
858 db.Issues[newID] = &Issue{
859 ID: newID,
860 Title: title,
861 Author: author,
862 Body: body,
863 CreatedAt: time.Now(),
864 Comments: []Comment{},
865 Reactions: []Reaction{},
866 IsClosed: false,
867 Status: "Open",
868 StatusClass: "open",
869 }
870 log.Printf("Created new issue #%d with title: %s", newID, title)
871
872 case "add-comment":
873 issueIDStr, ok := cmd["issue-id"]
874 if !ok {
875 return errors.New("add-comment requires 'issue-id'")
876 }
877 issueID, err := strconv.Atoi(issueIDStr)
878 if err != nil {
879 return fmt.Errorf("invalid 'issue-id' for add-comment: %s", issueIDStr)
880 }
881 issue, ok := db.Issues[issueID]
882 if !ok {
883 return fmt.Errorf("issue with ID %d not found", issueID)
884 }
885 newCommentID := db.NextCommentID
886 db.NextCommentID++
887 issue.Comments = append(issue.Comments, Comment{
888 ID: newCommentID,
889 Author: author,
890 Body: body,
891 CreatedAt: time.Now(),
892 Reactions: []Reaction{},
893 })
894 log.Printf("Added new comment #%d to issue #%d", newCommentID, issueID)
895
896 case "edit":
897 itemType, ok := cmd["type"]
898 if !ok {
899 return errors.New("edit command requires 'type'")
900 }
901 issueIDStr, ok := cmd["issue-id"]
902 if !ok {
903 return errors.New("edit command requires 'issue-id'")
904 }
905 issueID, err := strconv.Atoi(issueIDStr)
906 if err != nil {
907 return fmt.Errorf("invalid 'issue-id' for edit: %s", issueIDStr)
908 }
909 issue, ok := db.Issues[issueID]
910 if !ok {
911 return fmt.Errorf("issue with ID %d not found", issueID)
912 }
913
914 switch itemType {
915 case "issue":
916 if issue.Author != author && !hasPermission(author, "moderate", repoCfg.Permissions) {
917 return fmt.Errorf("permission denied: user %s cannot edit issue by %s", author, issue.Author)
918 }
919 issue.History = append(issue.History, EditRecord{Timestamp: time.Now(), Body: issue.Body})
920 issue.Body = body
921 log.Printf("Edited issue #%d", issueID)
922 case "comment":
923 commentIDStr, ok := cmd["comment-id"]
924 if !ok {
925 return errors.New("editing a comment requires 'comment-id'")
926 }
927 commentID, err := strconv.Atoi(commentIDStr)
928 if err != nil {
929 return fmt.Errorf("invalid 'comment-id' for edit: %s", commentIDStr)
930 }
931 var targetComment *Comment
932 for i := range issue.Comments {
933 if issue.Comments[i].ID == commentID {
934 targetComment = &issue.Comments[i]
935 break
936 }
937 }
938 if targetComment == nil {
939 return fmt.Errorf("comment with ID %d not found in issue #%d", commentID, issueID)
940 }
941 if targetComment.Author != author && !hasPermission(author, "moderate", repoCfg.Permissions) {
942 return fmt.Errorf("permission denied: user %s cannot edit comment by %s", author, targetComment.Author)
943 }
944 targetComment.History = append(targetComment.History, EditRecord{Timestamp: time.Now(), Body: targetComment.Body})
945 targetComment.Body = body
946 log.Printf("Edited comment #%d on issue #%d", commentID, issueID)
947 default:
948 return fmt.Errorf("unknown item type for edit: '%s'", itemType)
949 }
950
951 case "delete-issue":
952 issueIDStr, ok := cmd["issue-id"]
953 if !ok {
954 return errors.New("delete-issue requires 'issue-id'")
955 }
956 issueID, err := strconv.Atoi(issueIDStr)
957 if err != nil {
958 return fmt.Errorf("invalid 'issue-id' for delete-issue: %s", issueIDStr)
959 }
960 issue, ok := db.Issues[issueID]
961 if !ok {
962 return fmt.Errorf("issue with ID %d not found", issueID)
963 }
964 if issue.Author != author && !hasPermission(author, "moderate", repoCfg.Permissions) {
965 return fmt.Errorf("permission denied: user %s cannot delete issue owned by %s", author, issue.Author)
966 }
967 delete(db.Issues, issueID)
968 log.Printf("Deleted issue #%d", issueID)
969
970 case "delete-comment":
971 issueIDStr, ok := cmd["issue-id"]
972 if !ok {
973 return errors.New("delete-comment requires 'issue-id'")
974 }
975 issueID, err := strconv.Atoi(issueIDStr)
976 if err != nil {
977 return fmt.Errorf("invalid 'issue-id' for delete-comment: %s", issueIDStr)
978 }
979 issue, ok := db.Issues[issueID]
980 if !ok {
981 return fmt.Errorf("issue with ID %d not found", issueID)
982 }
983
984 commentIDStr, ok := cmd["comment-id"]
985 if !ok {
986 return errors.New("delete-comment requires 'comment-id'")
987 }
988 commentID, err := strconv.Atoi(commentIDStr)
989 if err != nil {
990 return fmt.Errorf("invalid 'comment-id' for delete-comment: %s", commentIDStr)
991 }
992
993 commentIndex := -1
994 var targetComment Comment
995 for i, c := range issue.Comments {
996 if c.ID == commentID {
997 targetComment = c
998 commentIndex = i
999 break
1000 }
1001 }
1002
1003 if commentIndex == -1 {
1004 return fmt.Errorf("comment with ID %d not found in issue #%d", commentID, issueID)
1005 }
1006
1007 if targetComment.Author != author && !hasPermission(author, "moderate", repoCfg.Permissions) {
1008 return fmt.Errorf("permission denied: user %s cannot delete comment owned by %s", author, targetComment.Author)
1009 }
1010
1011 issue.Comments = append(issue.Comments[:commentIndex], issue.Comments[commentIndex+1:]...)
1012 log.Printf("Deleted comment #%d on issue #%d", commentID, issueID)
1013
1014 case "mark-as":
1015 issueIDStr, ok := cmd["issue-id"]
1016 if !ok {
1017 return errors.New("mark-as requires 'issue-id'")
1018 }
1019 statusName, ok := cmd["status"]
1020 if !ok {
1021 return errors.New("mark-as requires 'status'")
1022 }
1023 issueID, err := strconv.Atoi(issueIDStr)
1024 if err != nil {
1025 return fmt.Errorf("invalid 'issue-id' for mark-as: %s", issueIDStr)
1026 }
1027 className, ok := repoCfg.MarkAs[statusName]
1028 if !ok {
1029 return fmt.Errorf("unknown status: '%s'. check config.yml", statusName)
1030 }
1031 issue, ok := db.Issues[issueID]
1032 if !ok {
1033 return fmt.Errorf("issue with ID %d not found", issueID)
1034 }
1035
1036 // A user can mark their own issue. A user with 'moderate' or 'all' permission can mark any issue.
1037 if issue.Author != author && !hasPermission(author, "moderate", repoCfg.Permissions) {
1038 return fmt.Errorf("permission denied: user %s cannot mark-as an issue owned by %s", author, issue.Author)
1039 }
1040
1041 issue.Status = statusName
1042 issue.StatusClass = className
1043 if className == "resolved" || className == "not-planned" || className == "closed" {
1044 issue.IsClosed = true
1045 } else {
1046 issue.IsClosed = false
1047 }
1048 log.Printf("Marked issue #%d as %s", issueID, statusName)
1049
1050 case "react":
1051 itemType, ok := cmd["type"]
1052 if !ok {
1053 return errors.New("react command requires 'type' (issue or comment)")
1054 }
1055 reactionName, ok := cmd["reaction"]
1056 if !ok {
1057 return errors.New("react command requires 'reaction'")
1058 }
1059 emoji, ok := repoCfg.Reactions[reactionName]
1060 if !ok {
1061 return fmt.Errorf("unknown reaction: '%s'", reactionName)
1062 }
1063 issueIDStr, ok := cmd["issue-id"]
1064 if !ok {
1065 return errors.New("react command requires 'issue-id'")
1066 }
1067 issueID, err := strconv.Atoi(issueIDStr)
1068 if err != nil {
1069 return fmt.Errorf("invalid 'issue-id' for react: %s", issueIDStr)
1070 }
1071 issue, ok := db.Issues[issueID]
1072 if !ok {
1073 return fmt.Errorf("issue with ID %d not found", issueID)
1074 }
1075
1076 switch itemType {
1077 case "issue":
1078 issue.Reactions = append(issue.Reactions, Reaction{Emoji: emoji, Author: author})
1079 log.Printf("Added reaction '%s' to issue #%d", emoji, issueID)
1080 case "comment":
1081 commentIDStr, ok := cmd["comment-id"]
1082 if !ok {
1083 return errors.New("reacting to a comment requires 'comment-id'")
1084 }
1085 commentID, err := strconv.Atoi(commentIDStr)
1086 if err != nil {
1087 return fmt.Errorf("invalid 'comment-id' for react: %s", commentIDStr)
1088 }
1089 var targetComment *Comment
1090 for i := range issue.Comments {
1091 if issue.Comments[i].ID == commentID {
1092 targetComment = &issue.Comments[i]
1093 break
1094 }
1095 }
1096 if targetComment == nil {
1097 return fmt.Errorf("comment with ID %d not found in issue #%d", commentID, issueID)
1098 }
1099 targetComment.Reactions = append(targetComment.Reactions, Reaction{Emoji: emoji, Author: author})
1100 log.Printf("Added reaction '%s' to comment #%d on issue #%d", emoji, commentID, issueID)
1101 default:
1102 return fmt.Errorf("unknown item type for reaction: '%s'", itemType)
1103 }
1104
1105 case "unreact":
1106 itemType, ok := cmd["type"]
1107 if !ok {
1108 return errors.New("unreact command requires 'type'")
1109 }
1110 reactionName, ok := cmd["reaction"]
1111 if !ok {
1112 return errors.New("unreact command requires 'reaction'")
1113 }
1114 emoji, ok := repoCfg.Reactions[reactionName]
1115 if !ok {
1116 return fmt.Errorf("unknown reaction: '%s'", reactionName)
1117 }
1118 issueIDStr, ok := cmd["issue-id"]
1119 if !ok {
1120 return errors.New("unreact command requires 'issue-id'")
1121 }
1122 issueID, err := strconv.Atoi(issueIDStr)
1123 if err != nil {
1124 return fmt.Errorf("invalid 'issue-id' for unreact: %s", issueIDStr)
1125 }
1126 issue, ok := db.Issues[issueID]
1127 if !ok {
1128 return fmt.Errorf("issue with ID %d not found", issueID)
1129 }
1130
1131 var reactions *[]Reaction
1132 var itemName string
1133
1134 switch itemType {
1135 case "issue":
1136 reactions = &issue.Reactions
1137 itemName = fmt.Sprintf("issue #%d", issueID)
1138 case "comment":
1139 commentIDStr, ok := cmd["comment-id"]
1140 if !ok {
1141 return errors.New("unreacting to a comment requires 'comment-id'")
1142 }
1143 commentID, err := strconv.Atoi(commentIDStr)
1144 if err != nil {
1145 return fmt.Errorf("invalid 'comment-id' for unreact: %s", commentIDStr)
1146 }
1147 var targetComment *Comment
1148 for i := range issue.Comments {
1149 if issue.Comments[i].ID == commentID {
1150 targetComment = &issue.Comments[i]
1151 break
1152 }
1153 }
1154 if targetComment == nil {
1155 return fmt.Errorf("comment with ID %d not found in issue #%d", commentID, issueID)
1156 }
1157 reactions = &targetComment.Reactions
1158 itemName = fmt.Sprintf("comment #%d on issue #%d", commentID, issueID)
1159 default:
1160 return fmt.Errorf("unknown item type for unreaction: '%s'", itemType)
1161 }
1162
1163 found := false
1164 var updatedReactions []Reaction
1165 for _, r := range *reactions {
1166 if r.Emoji == emoji && r.Author == author && !found {
1167 found = true
1168 continue
1169 }
1170 updatedReactions = append(updatedReactions, r)
1171 }
1172
1173 if !found {
1174 return fmt.Errorf("reaction '%s' by '%s' not found on %s", emoji, author, itemName)
1175 }
1176
1177 *reactions = updatedReactions
1178 log.Printf("Removed reaction '%s' by '%s' from %s", emoji, author, itemName)
1179
1180 case "alias":
1181 alias, ok := cmd["alias"]
1182 if !ok || alias == "" {
1183 return errors.New("alias command requires a non-empty 'alias'")
1184 }
1185
1186 db.mu.Lock()
1187 defer db.mu.Unlock()
1188
1189 if ownerEmail, exists := db.AliasToEmail[alias]; exists && ownerEmail != author {
1190 return fmt.Errorf("alias '%s' is already in use within this repository", alias)
1191 }
1192 if oldAlias, exists := db.EmailToAlias[author]; exists {
1193 delete(db.AliasToEmail, oldAlias)
1194 }
1195 db.AliasToEmail[alias] = author
1196 db.EmailToAlias[author] = alias
1197 log.Printf("Set repo-specific alias for '%s' to '%s' in repo '%s'", author, alias, repoCfg.Name)
1198
1199 case "unalias":
1200 db.mu.Lock()
1201 defer db.mu.Unlock()
1202
1203 if oldAlias, exists := db.EmailToAlias[author]; exists {
1204 delete(db.AliasToEmail, oldAlias)
1205 delete(db.EmailToAlias, author)
1206 log.Printf("Removed repo-specific alias '%s' for '%s' in repo '%s'", oldAlias, author, repoCfg.Name)
1207 } else {
1208 log.Printf("User '%s' had no repo-specific alias to remove in repo '%s'", author, repoCfg.Name)
1209 }
1210
1211 default:
1212 return fmt.Errorf("unknown command: %s", command)
1213 }
1214
1215 return nil
1216}
1217
1218// queueRejectionEmail sends a rejection mail request to a channel for sequential processing.
1219func queueRejectionEmail(queue chan<- RejectionMail, to, reason string, cfg *GlobalConfig) {
1220 select {
1221 case queue <- RejectionMail{To: to, Reason: reason, Cfg: cfg}:
1222 log.Printf("Queued rejection email for %s", to)
1223 default:
1224 // This case is hit if the channel buffer is full.
1225 log.Printf("WARNING: Rejection email queue is full. Dropping rejection for %s", to)
1226 }
1227}
1228
1229// rejectionSender runs in its own goroutine, processing the rejection queue sequentially.
1230func rejectionSender(wg *sync.WaitGroup, queue <-chan RejectionMail) {
1231 defer wg.Done()
1232 log.Println("Rejection email sender goroutine started.")
1233
1234 for mail := range queue {
1235 cfg := mail.Cfg
1236
1237 from := cfg.SMTPFromAddr
1238 if cfg.SMTPFromAlias != nil && *cfg.SMTPFromAlias != "" {
1239 from = *cfg.SMTPFromAlias
1240 }
1241
1242 // Add a specific tag to the subject for easier filtering and cleanup later.
1243 subject := "Subject: [pgitBot-rejection] Your submission was rejected\r\n"
1244 headers := "From: " + from + "\r\n" +
1245 "To: " + mail.To + "\r\n" +
1246 subject
1247
1248 body := "Your email to pgitBot was rejected for the following reason:\r\n\r\n" +
1249 mail.Reason + "\r\n\r\n" +
1250 "Please ensure your emails are plain text and that commands only contain visible characters.\r\n"
1251
1252 message := []byte(headers + "\r\n" + body)
1253
1254 // The 'from' argument to smtp.SendMail must be the authentication user.
1255 auth := smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, strings.Split(cfg.SMTPServer, ":")[0])
1256 err := smtp.SendMail(cfg.SMTPServer, auth, cfg.SMTPFromAddr, []string{mail.To}, message)
1257
1258 if err != nil {
1259 log.Printf("Failed to send rejection email to %s: %v", mail.To, err)
1260 } else {
1261 log.Printf("Successfully sent rejection email to %s", mail.To)
1262 }
1263 // Add a small delay between sends to avoid being rate-limited or marked as spam.
1264 time.Sleep(2 * time.Second)
1265 }
1266
1267 log.Println("Rejection email sender goroutine finished.")
1268}
1269
1270func cleanupOldRejections(c *imapclient.Client, repoCfg *ResolvedRepoConfig) error {
1271 log.Println("Checking for old rejection emails to clean up...")
1272
1273 selectCmd := c.Select(repoCfg.IMAPMailbox, nil)
1274 if _, err := selectCmd.Wait(); err != nil {
1275 return fmt.Errorf("failed to select mailbox for cleanup: %w", err)
1276 }
1277
1278 thirtyDaysAgo := time.Now().AddDate(0, 0, -30)
1279 searchCriteria := &imap.SearchCriteria{
1280 Flag: []imap.Flag{imap.FlagSeen},
1281 Header: []imap.SearchCriteriaHeaderField{
1282 {Key: "Subject", Value: "[pgitBot-rejection]"},
1283 },
1284 SentBefore: thirtyDaysAgo,
1285 }
1286
1287 searchCmd := c.UIDSearch(searchCriteria, nil)
1288 searchData, err := searchCmd.Wait()
1289 if err != nil {
1290 return fmt.Errorf("failed to search for old rejections: %w", err)
1291 }
1292 uidsToDelete := searchData.AllUIDs()
1293
1294 if len(uidsToDelete) == 0 {
1295 log.Println("No old rejection emails to delete.")
1296 return nil
1297 }
1298
1299 log.Printf("Found %d old rejection emails to delete.", len(uidsToDelete))
1300
1301 var uidSet imap.UIDSet
1302 for _, uid := range uidsToDelete {
1303 uidSet.AddNum(uid)
1304 }
1305
1306 storeCmd := c.Store(uidSet, &imap.StoreFlags{
1307 Op: imap.StoreFlagsAdd,
1308 Silent: true,
1309 Flags: []imap.Flag{imap.FlagDeleted},
1310 }, nil)
1311 if err := storeCmd.Close(); err != nil {
1312 return fmt.Errorf("failed to mark old rejections for deletion: %w", err)
1313 }
1314
1315 if expungeCmd := c.Expunge(); expungeCmd != nil {
1316 if err := expungeCmd.Close(); err != nil {
1317 // Not a fatal error, as some servers might auto-expunge.
1318 log.Printf("Warning: failed to expunge old rejections: %v", err)
1319 }
1320 }
1321
1322 log.Printf("Successfully marked %d old rejection emails for deletion.", len(uidsToDelete))
1323 return nil
1324}
1325
1326func newRepoDatabase() *RepoDatabase {
1327 return &RepoDatabase{
1328 Issues: make(map[int]*Issue),
1329 NextIssueID: 1,
1330 NextCommentID: 1,
1331 ProcessedUIDs: make(map[imap.UID]bool),
1332 RejectedUIDs: make(map[imap.UID]string),
1333 Reactions: make(map[string]string),
1334 MarkAs: make(map[string]string),
1335 }
1336}
1337
1338func loadRepoDatabase(filename string, globalCfg *GlobalConfig) (*RepoDatabase, error) {
1339 db := newRepoDatabase()
1340
1341 if _, err := os.Stat(filename); os.IsNotExist(err) {
1342 log.Printf("Database file '%s' not found, creating a new one.", filename)
1343 return db, nil
1344 }
1345
1346 jsonData, err := readAndDecryptFile(filename, globalCfg)
1347 if err != nil {
1348 return nil, err
1349 }
1350
1351 if len(jsonData) == 0 {
1352 log.Printf("Database file '%s' is empty, starting fresh.", filename)
1353 return db, nil
1354 }
1355
1356 db.mu.Lock()
1357 defer db.mu.Unlock()
1358 if err := json.Unmarshal(jsonData, db); err != nil {
1359 return nil, fmt.Errorf("could not unmarshal repo db json: %w. Check for corruption or encryption mismatch", err)
1360 }
1361 if db.ProcessedUIDs == nil {
1362 db.ProcessedUIDs = make(map[imap.UID]bool)
1363 }
1364 if db.RejectedUIDs == nil {
1365 db.RejectedUIDs = make(map[imap.UID]string)
1366 }
1367 if db.AliasToEmail == nil {
1368 db.AliasToEmail = make(map[string]string)
1369 }
1370 if db.EmailToAlias == nil {
1371 db.EmailToAlias = make(map[string]string)
1372 }
1373 log.Printf("Loaded repo database '%s' with %d issues, %d processed UIDs, and %d rejected UIDs.", filename, len(db.Issues), len(db.ProcessedUIDs), len(db.RejectedUIDs))
1374 return db, nil
1375}
1376
1377func saveRepoDatabase(filename string, db *RepoDatabase, globalCfg *GlobalConfig) error {
1378 db.mu.Lock()
1379 defer db.mu.Unlock()
1380
1381 data, err := json.MarshalIndent(db, "", " ")
1382 if err != nil {
1383 return fmt.Errorf("could not marshal repo db to json: %w", err)
1384 }
1385
1386 if err := encryptAndWriteFile(filename, data, globalCfg); err != nil {
1387 return err
1388 }
1389 log.Printf("Successfully saved repo database to '%s'.", filename)
1390 return nil
1391}
1392
1393func loadGlobalDatabase(filename string, globalCfg *GlobalConfig) (*GlobalDatabase, error) {
1394 db := &GlobalDatabase{
1395 Repos: make(map[string]*RepoDatabase),
1396 AliasToEmail: make(map[string]string),
1397 EmailToAlias: make(map[string]string),
1398 }
1399
1400 if _, err := os.Stat(filename); os.IsNotExist(err) {
1401 log.Printf("Global database file '%s' not found, creating a new one.", filename)
1402 return db, nil
1403 }
1404
1405 jsonData, err := readAndDecryptFile(filename, globalCfg)
1406 if err != nil {
1407 return nil, err
1408 }
1409
1410 if len(jsonData) == 0 {
1411 log.Printf("Global database file '%s' is empty, starting fresh.", filename)
1412 return db, nil
1413 }
1414
1415 db.mu.Lock()
1416 defer db.mu.Unlock()
1417 if err := json.Unmarshal(jsonData, db); err != nil {
1418 return nil, fmt.Errorf("could not unmarshal global db json: %w. Check for corruption or encryption mismatch", err)
1419 }
1420
1421 if db.Repos == nil {
1422 db.Repos = make(map[string]*RepoDatabase)
1423 }
1424 if db.AliasToEmail == nil {
1425 db.AliasToEmail = make(map[string]string)
1426 }
1427 if db.EmailToAlias == nil {
1428 db.EmailToAlias = make(map[string]string)
1429 }
1430
1431 for _, repoDB := range db.Repos {
1432 if repoDB.ProcessedUIDs == nil {
1433 repoDB.ProcessedUIDs = make(map[imap.UID]bool)
1434 }
1435 if repoDB.RejectedUIDs == nil {
1436 repoDB.RejectedUIDs = make(map[imap.UID]string)
1437 }
1438 }
1439
1440 log.Printf("Loaded global database '%s' with %d repositories and %d aliases.", filename, len(db.Repos), len(db.EmailToAlias))
1441 return db, nil
1442}
1443
1444func saveGlobalDatabase(filename string, db *GlobalDatabase, globalCfg *GlobalConfig) error {
1445 db.mu.Lock()
1446 defer db.mu.Unlock()
1447
1448 data, err := json.MarshalIndent(db, "", " ")
1449 if err != nil {
1450 return fmt.Errorf("could not marshal global db to json: %w", err)
1451 }
1452
1453 if err := encryptAndWriteFile(filename, data, globalCfg); err != nil {
1454 return err
1455 }
1456 log.Printf("Successfully saved global database to '%s'.", filename)
1457 return nil
1458}
1459
1460func readAndDecryptFile(filename string, globalCfg *GlobalConfig) ([]byte, error) {
1461 fileData, err := os.ReadFile(filename)
1462 if err != nil {
1463 return nil, fmt.Errorf("could not read file '%s': %w", filename, err)
1464 }
1465 if len(fileData) == 0 {
1466 return nil, nil // Empty file is valid
1467 }
1468
1469 shouldDecrypt := !globalCfg.DisableEncryption && globalCfg.DBEncryptionKey != ""
1470 if !shouldDecrypt {
1471 return fileData, nil
1472 }
1473
1474 decryptedData, err := decrypt(fileData, globalCfg.DBEncryptionKey)
1475 if err != nil {
1476 return nil, fmt.Errorf("failed to decrypt '%s': %w. If not encrypted, ensure 'db_encryption_key' is empty or 'disable_encryption: true'", filename, err)
1477 }
1478 return decryptedData, nil
1479}
1480
1481func encryptAndWriteFile(filename string, data []byte, globalCfg *GlobalConfig) error {
1482 shouldEncrypt := !globalCfg.DisableEncryption && globalCfg.DBEncryptionKey != ""
1483 var outputData []byte
1484
1485 if shouldEncrypt {
1486 encryptedData, err := encrypt(data, globalCfg.DBEncryptionKey)
1487 if err != nil {
1488 return fmt.Errorf("failed to encrypt data for '%s': %w", filename, err)
1489 }
1490 outputData = encryptedData
1491 } else {
1492 outputData = data
1493 }
1494
1495 dir := filepath.Dir(filename)
1496 if dir != "" && dir != "." {
1497 if err := os.MkdirAll(dir, 0755); err != nil {
1498 return fmt.Errorf("could not create directory for '%s': %w", filename, err)
1499 }
1500 }
1501
1502 if err := os.WriteFile(filename, outputData, 0600); err != nil {
1503 return fmt.Errorf("could not write file '%s': %w", filename, err)
1504 }
1505 return nil
1506}
1507
1508func encrypt(plaintext []byte, hexKey string) ([]byte, error) {
1509 key, err := hex.DecodeString(hexKey)
1510 if err != nil {
1511 return nil, fmt.Errorf("%w: failed to decode hex key: %v", errDBEncrypt, err)
1512 }
1513 if len(key) != 32 {
1514 return nil, fmt.Errorf("%w: key must be 32 bytes for AES-256", errDBEncrypt)
1515 }
1516
1517 block, err := aes.NewCipher(key)
1518 if err != nil {
1519 return nil, err
1520 }
1521
1522 gcm, err := cipher.NewGCM(block)
1523 if err != nil {
1524 return nil, err
1525 }
1526
1527 nonce := make([]byte, gcm.NonceSize())
1528 if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
1529 return nil, err
1530 }
1531
1532 return gcm.Seal(nonce, nonce, plaintext, nil), nil
1533}
1534
1535func decrypt(ciphertext []byte, hexKey string) ([]byte, error) {
1536 key, err := hex.DecodeString(hexKey)
1537 if err != nil {
1538 return nil, fmt.Errorf("%w: failed to decode hex key: %v", errDBDecrypt, err)
1539 }
1540 if len(key) != 32 {
1541 return nil, fmt.Errorf("%w: key must be 32 bytes for AES-256", errDBDecrypt)
1542 }
1543
1544 block, err := aes.NewCipher(key)
1545 if err != nil {
1546 return nil, err
1547 }
1548
1549 gcm, err := cipher.NewGCM(block)
1550 if err != nil {
1551 return nil, err
1552 }
1553
1554 if len(ciphertext) < gcm.NonceSize() {
1555 return nil, fmt.Errorf("%w: ciphertext too short", errDBDecrypt)
1556 }
1557
1558 nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
1559 return gcm.Open(nil, nonce, ciphertext, nil)
1560}