package main import ( "bytes" "encoding/json" "flag" "fmt" "log" "os" "sync" "text/template" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) type Config struct { FromEmail string `json:"from-email"` FromName string `json:"from-name"` IMAPServer string `json:"imap-server"` IMAPEmail string `json:"imap-email"` IMAPPass string `json:"imap-pass"` SMTPServer string `json:"smtp-server"` SMTPEmail string `json:"smtp-email"` SMTPPass string `json:"smtp-pass"` Cooldown string `json:"cooldown"` IgnoreAlias bool `json:"ignore-alias"` } type Email struct { ToEmail string FromName string FromEmail string Date string } type worker struct { config Config sender sasl.Client client *client.Client idler *idler mbox *imap.MailboxStatus seen seen emlPath string } type seen struct { sync.Mutex emails map[string]time.Time cooldown time.Duration } type idler struct { sync.Mutex client *client.Client stop chan struct{} done chan error waiting bool idleing bool } var ( errIdleTimeout = fmt.Errorf("idle timeout") errIdleModeHangs = fmt.Errorf("idle mode hangs; waiting to reconnect") ) func newIdler(c *client.Client) *idler { return &idler{client: c, done: make(chan error)} } func (i *idler) setWaiting(wait bool) { i.Lock() i.waiting = wait i.Unlock() } func (i *idler) isWaiting() bool { i.Lock() defer i.Unlock() return i.waiting } func (i *idler) isReady() bool { i.Lock() defer i.Unlock() return (!i.waiting && i.client != nil && i.client.State() == imap.SelectedState) } func (i *idler) setIdleing(v bool) { i.Lock() defer i.Unlock() i.idleing = v } func (i *idler) Start() { switch { case i.isReady(): i.stop = make(chan struct{}) go func() { select { case <-i.stop: log.Println("Idle debounced...") i.done <- nil case <-time.After(10 * time.Millisecond): i.setIdleing(true) log.Println("Entered idle mode...") now := time.Now() err := i.client.Idle(i.stop, &client.IdleOptions{ LogoutTimeout: 0, PollInterval: 0, }) i.setIdleing(false) i.done <- err log.Printf("Elapsed idle time: %v", time.Since(now)) } }() case i.isWaiting(): log.Println("Not started: wait for idle to exit...") default: log.Println("Not started: client not ready...") } } func (i *idler) Stop() error { var reterr error switch { case i.isReady(): close(i.stop) select { case err := <-i.done: if err == nil { log.Println("Idle done...") } else { log.Printf("Idle done with err: %v", err) } reterr = nil case <-time.After(10 * time.Second): log.Println("Idle err (timeout); waiting in background...") i.waitOnIdle() reterr = errIdleTimeout } case i.isWaiting(): log.Println("Not stopped: still idleing/hanging...") reterr = errIdleModeHangs default: log.Println("Not stopped: client not ready...") reterr = nil } return reterr } func (i *idler) waitOnIdle() { i.setWaiting(true) log.Println("Waiting for idle in background...") go func() { err := <-i.done if err == nil { log.Println("Idle waited...") } else { log.Printf("Idle waited; with err: %v", err) } i.setWaiting(false) i.stop = make(chan struct{}) fmt.Println("restart") i.Start() }() } func (w *worker) handleUpdate(update *client.MailboxUpdate) { log.Println("New update, stopping idle...") defer func() { w.idler.Start() }() if err := w.idler.Stop(); err != nil { log.Fatal(err) } log.Println("Searching for messages...") criteria := imap.NewSearchCriteria() criteria.WithoutFlags = []string{imap.SeenFlag} ids, err := w.client.Search(criteria) if err != nil { log.Fatal(err) } if len(ids) == 0 { log.Println("No IDs Found") return } log.Println("IDs found:", ids) seqset := new(imap.SeqSet) seqset.AddNum(ids...) messages := make(chan *imap.Message, 10) done := make(chan error, 1) go func() { done <- w.client.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages) }() tmpl, err := template.ParseFiles(w.emlPath) if err != nil { log.Fatal(err) } for msg := range messages { log.Println("* ", msg.Envelope.From[0].Address(), msg.Envelope.Subject) if w.config.IgnoreAlias { foundAddress := false for _, toEmail := range msg.Envelope.To { if toEmail.Address() == w.config.FromEmail { foundAddress = true } } if !foundAddress { log.Println("Our address absent from 'to' set, skipping...") continue } } eml := Email{ ToEmail: msg.Envelope.From[0].Address(), FromEmail: w.config.FromEmail, FromName: w.config.FromName, Date: time.Now().Format(time.RFC1123Z), } if prevSend, exists := w.seen.emails[eml.ToEmail]; exists { log.Println("Address exists, checking time...") diff := time.Now().Sub(prevSend) if diff < w.seen.cooldown { log.Println("Address seen too recently, skipping...") continue } } log.Println("Sending email...") to := []string{msg.Envelope.From[0].Address()} buf := new(bytes.Buffer) err := tmpl.Execute(buf, &eml) if err != nil { log.Fatal(err) } err = smtp.SendMail(w.config.SMTPServer, w.sender, w.config.SMTPEmail, to, buf) if err != nil { log.Fatal(err) } log.Println("Adding address to seen map...") w.seen.Lock() w.seen.emails[eml.ToEmail] = time.Now() w.seen.Unlock() } log.Println("Marking messages as seen...") item := imap.FormatFlagsOp(imap.AddFlags, true) flags := []interface{}{imap.SeenFlag} err = w.client.Store(seqset, item, flags, nil) if err != nil { log.Fatal(err) } if err := <-done; err != nil { log.Fatal(err) } } func main() { var configPath string var w worker var err error flag.StringVar(&configPath, "conf", "./pigeon.json", "path to JSON configuration") flag.StringVar(&w.emlPath, "eml", "./message.eml", "path to EML message") flag.Parse() conf, err := os.ReadFile(configPath) if err != nil { log.Fatal(err) } err = json.Unmarshal(conf, &w.config) if err != nil { log.Fatal(err) } if cd, err := time.ParseDuration(w.config.Cooldown); err != nil { log.Fatal(err) } else { w.seen.cooldown = cd } w.seen.emails = make(map[string]time.Time) log.Println("Connecting to server...") w.client, err = client.DialTLS(w.config.IMAPServer, nil) if err != nil { log.Fatal(err) } // w.client.SetDebug(os.Stdout) defer w.client.Logout() if err := w.client.Login(w.config.IMAPEmail, w.config.IMAPPass); err != nil { log.Fatal(err) } log.Println("Logged in") w.idler = newIdler(w.client) w.mbox, err = w.client.Select("INBOX", false) if err != nil { log.Fatal(err) } w.sender = sasl.NewPlainClient("", w.config.SMTPEmail, w.config.SMTPPass) updates := make(chan client.Update, 1) w.client.Updates = updates w.idler.Start() for { update := <-updates switch update := update.(type) { case *client.MailboxUpdate: log.Println("Mailbox update received, processing...") w.handleUpdate(update) } } }