diff options
Diffstat (limited to 'pigeon.go')
-rw-r--r-- | pigeon.go | 325 |
1 files changed, 325 insertions, 0 deletions
diff --git a/pigeon.go b/pigeon.go new file mode 100644 index 0000000..c169129 --- /dev/null +++ b/pigeon.go @@ -0,0 +1,325 @@ +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"` + Subject string `json:"subject"` + 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"` + Message string `json:"message"` + Cooldown string `json:"cooldown"` + IgnoreAlias bool `json:"ignore-alias"` +} + +type Email struct { + ToEmail string + FromName string + FromEmail string + Subject string + Date string + Message 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, + Subject: w.config.Subject, + Date: time.Now().Format(time.RFC1123Z), + Message: w.config.Message, + } + 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) + } + } +} |