repos / pgit

Improved static site generator for git repos
git clone https://github.com/xplshn/pgit.git

pgit / pgitBot
[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}