aboutsummaryrefslogtreecommitdiff
path: root/pigeon.go
diff options
context:
space:
mode:
Diffstat (limited to 'pigeon.go')
-rw-r--r--pigeon.go325
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)
+ }
+ }
+}