From c031bef006751089a3d02ff53abe7a25469be3c2 Mon Sep 17 00:00:00 2001 From: Erik Winter Date: Thu, 13 May 2021 08:15:14 +0200 Subject: [PATCH] imap daemon --- cmd/daemon/service.go | 79 +++++++++++++++++++ cmd/generate-recurring/main.go | 59 +++++++------- cmd/process-inbox/main.go | 44 ++++------- go.mod | 2 +- go.sum | 12 ++- internal/process/inbox.go | 52 +++++++++++++ internal/process/inbox_test.go | 134 ++++++++++++++++++++++++++++++++ internal/process/recur.go | 56 ++++++++++++++ internal/process/recur_test.go | 69 +++++++++++++++++ internal/task/dispatch.go | 20 +++++ internal/task/repo.go | 16 +++- internal/task/task.go | 12 ++- pkg/msend/memory.go | 17 ++++ pkg/msend/memory_test.go | 21 +++++ pkg/msend/msend.go | 16 ++++ pkg/msend/smtp.go | 127 ++++++++++++++++++++++++++++++ pkg/mstore/imap.go | 137 ++++++++++++++++++++++----------- 17 files changed, 753 insertions(+), 120 deletions(-) create mode 100644 cmd/daemon/service.go create mode 100644 internal/process/inbox.go create mode 100644 internal/process/inbox_test.go create mode 100644 internal/process/recur.go create mode 100644 internal/process/recur_test.go create mode 100644 internal/task/dispatch.go create mode 100644 pkg/msend/memory.go create mode 100644 pkg/msend/memory_test.go create mode 100644 pkg/msend/msend.go create mode 100644 pkg/msend/smtp.go diff --git a/cmd/daemon/service.go b/cmd/daemon/service.go new file mode 100644 index 0000000..f2230df --- /dev/null +++ b/cmd/daemon/service.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "os/signal" + "time" + + "git.ewintr.nl/go-kit/log" + "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/msend" + "git.ewintr.nl/gte/pkg/mstore" +) + +func main() { + logger := log.New(os.Stdout) + logger.Info("started") + + msgStore := mstore.NewIMAP(&mstore.IMAPConfig{ + IMAPURL: os.Getenv("IMAP_URL"), + IMAPUsername: os.Getenv("IMAP_USERNAME"), + IMAPPassword: os.Getenv("IMAP_PASSWORD"), + }) + msgSender := msend.NewSSLSMTP(&msend.SSLSMTPConfig{ + URL: os.Getenv("SMTP_URL"), + Username: os.Getenv("SMTP_USERNAME"), + Password: os.Getenv("SMTP_PASSWORD"), + From: os.Getenv("SMTP_FROM"), + To: os.Getenv("SMTP_TO"), + }) + repo := task.NewRepository(msgStore) + disp := task.NewDispatcher(msgSender) + + inboxProc := process.NewInbox(repo) + recurProc := process.NewRecur(repo, disp, 6) + + go Run(inboxProc, recurProc, logger) + + done := make(chan os.Signal) + signal.Notify(done, os.Interrupt) + <-done + logger.Info("stopped") +} + +func Run(inboxProc *process.Inbox, recurProc *process.Recur, logger log.Logger) { + logger = logger.WithField("func", "run") + inboxTicker := time.NewTicker(10 * time.Second) + recurTicker := time.NewTicker(time.Hour) + oldToday := task.Today + + for { + select { + case <-inboxTicker.C: + result, err := inboxProc.Process() + if err != nil { + logger.WithErr(err).Error("failed processing inbox") + + continue + } + logger.WithField("count", result.Count).Info("finished processing inbox") + case <-recurTicker.C: + year, month, day := time.Now().Date() + newToday := task.NewDate(year, int(month), day) + if oldToday.Equal(newToday) { + + continue + } + + oldToday = newToday + result, err := recurProc.Process() + if err != nil { + logger.WithErr(err).Error("failed generating recurring tasks") + + continue + } + logger.WithField("count", result.Count).Info("finished generating recurring tasks") + } + } +} diff --git a/cmd/generate-recurring/main.go b/cmd/generate-recurring/main.go index 766ae7c..72a0b1b 100644 --- a/cmd/generate-recurring/main.go +++ b/cmd/generate-recurring/main.go @@ -1,51 +1,52 @@ package main import ( - "log" "os" "strconv" + "git.ewintr.nl/go-kit/log" + "git.ewintr.nl/gte/internal/process" "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/msend" "git.ewintr.nl/gte/pkg/mstore" ) func main() { - config := &mstore.ImapConfiguration{ - ImapUrl: os.Getenv("IMAP_URL"), - ImapUsername: os.Getenv("IMAP_USERNAME"), - ImapPassword: os.Getenv("IMAP_PASSWORD"), + logger := log.New(os.Stdout).WithField("cmd", "generate-recurring") + IMAPConfig := &mstore.IMAPConfig{ + IMAPURL: os.Getenv("IMAP_URL"), + IMAPUsername: os.Getenv("IMAP_USERNAME"), + IMAPPassword: os.Getenv("IMAP_PASSWORD"), } - if !config.Valid() { - log.Fatal("please set IMAP_USER, IMAP_PASSWORD, etc environment variables") + msgStore := mstore.NewIMAP(IMAPConfig) + + SMTPConfig := &msend.SSLSMTPConfig{ + URL: os.Getenv("SMTP_URL"), + Username: os.Getenv("SMTP_USERNAME"), + Password: os.Getenv("SMTP_PASSWORD"), + From: os.Getenv("SMTP_FROM"), + To: os.Getenv("SMTP_TO"), } + if !SMTPConfig.Valid() { + logger.Error("please set SMTP_URL, SMTP_USERNAME, etc environment variables") + os.Exit(1) + } + mailSend := msend.NewSSLSMTP(SMTPConfig) + daysAhead, err := strconv.Atoi(os.Getenv("GTE_DAYS_AHEAD")) if err != nil { daysAhead = 0 } - mailStore, err := mstore.ImapConnect(config) + taskRepo := task.NewRepository(msgStore) + taskDisp := task.NewDispatcher(mailSend) + + recur := process.NewRecur(taskRepo, taskDisp, daysAhead) + result, err := recur.Process() if err != nil { - log.Fatal(err) - } - defer mailStore.Disconnect() - - taskRepo := task.NewRepository(mailStore) - tasks, err := taskRepo.FindAll(task.FOLDER_RECURRING) - if err != nil { - log.Fatal(err) - } - rDate := task.Today.AddDays(daysAhead) - for _, t := range tasks { - if t.RecursOn(rDate) { - subject, body, err := t.CreateDueMessage(rDate) - if err != nil { - log.Fatal(err) - } - if err := mailStore.Add(task.FOLDER_PLANNED, subject, body); err != nil { - log.Fatal(err) - } - } - + logger.WithErr(err).Error("unable to process recurring") + os.Exit(1) } + logger.WithField("count", result.Count).Info("finished generating recurring tasks") } diff --git a/cmd/process-inbox/main.go b/cmd/process-inbox/main.go index 6cf2fac..ba19369 100644 --- a/cmd/process-inbox/main.go +++ b/cmd/process-inbox/main.go @@ -1,46 +1,28 @@ package main import ( - "log" "os" + "git.ewintr.nl/go-kit/log" + "git.ewintr.nl/gte/internal/process" "git.ewintr.nl/gte/internal/task" "git.ewintr.nl/gte/pkg/mstore" ) func main() { - config := &mstore.ImapConfiguration{ - ImapUrl: os.Getenv("IMAP_URL"), - ImapUsername: os.Getenv("IMAP_USERNAME"), - ImapPassword: os.Getenv("IMAP_PASSWORD"), - } - if !config.Valid() { - log.Fatal("please set IMAP_USER, IMAP_PASSWORD, etc environment variables") + logger := log.New(os.Stdout).WithField("cmd", "process-inbox") + config := &mstore.IMAPConfig{ + IMAPURL: os.Getenv("IMAP_URL"), + IMAPUsername: os.Getenv("IMAP_USERNAME"), + IMAPPassword: os.Getenv("IMAP_PASSWORD"), } + msgStore := mstore.NewIMAP(config) - mailStore, err := mstore.ImapConnect(config) + inboxProcessor := process.NewInbox(task.NewRepository(msgStore)) + result, err := inboxProcessor.Process() if err != nil { - log.Fatal(err) - } - defer mailStore.Disconnect() - - taskRepo := task.NewRepository(mailStore) - tasks, err := taskRepo.FindAll(task.FOLDER_INBOX) - if err != nil { - log.Fatal(err) - } - var cleanupNeeded bool - for _, t := range tasks { - if t.Dirty { - if err := taskRepo.Update(t); err != nil { - log.Fatal(err) - } - cleanupNeeded = true - } - } - if cleanupNeeded { - if err := taskRepo.CleanUp(); err != nil { - log.Fatal(err) - } + logger.WithErr(err).Error("unable to process inbox") + os.Exit(1) } + logger.WithField("count", result.Count).Info("finished processing inbox") } diff --git a/go.mod b/go.mod index 2cf8496..95df948 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.ewintr.nl/gte go 1.14 require ( - git.ewintr.nl/go-kit v0.0.0-20210509123609-19e474005502 + git.ewintr.nl/go-kit v0.0.0-20210513091124-da7006c2c242 github.com/emersion/go-imap v1.1.0 github.com/emersion/go-message v0.14.1 github.com/google/uuid v1.2.0 diff --git a/go.sum b/go.sum index 42f60a1..77914be 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -git.ewintr.nl/go-kit v0.0.0-20210509123609-19e474005502 h1:h3PYDz6uWAz2mBPOFShhkZv7SapfLWXcBDsmHqFCq5w= -git.ewintr.nl/go-kit v0.0.0-20210509123609-19e474005502/go.mod h1:eYANz1nepfc6lHxa9UcNZFvLaezBrihttH/Pdc5+0Vk= +git.ewintr.nl/go-kit v0.0.0-20210513084754-6c0524f3de86 h1:jVP4muIBqQ5poAuOlDgW/PhYscSnRHdqEqX1WfAK++A= +git.ewintr.nl/go-kit v0.0.0-20210513084754-6c0524f3de86/go.mod h1:eYANz1nepfc6lHxa9UcNZFvLaezBrihttH/Pdc5+0Vk= +git.ewintr.nl/go-kit v0.0.0-20210513091124-da7006c2c242 h1:9UIbgqTOIot2rAXqVWhd0Q9MyWru1kIzr52HPWpCCTM= +git.ewintr.nl/go-kit v0.0.0-20210513091124-da7006c2c242/go.mod h1:eYANz1nepfc6lHxa9UcNZFvLaezBrihttH/Pdc5+0Vk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= @@ -65,11 +67,14 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -224,6 +229,7 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -237,7 +243,6 @@ github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5J github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -310,6 +315,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= diff --git a/internal/process/inbox.go b/internal/process/inbox.go new file mode 100644 index 0000000..39ca635 --- /dev/null +++ b/internal/process/inbox.go @@ -0,0 +1,52 @@ +package process + +import ( + "errors" + "fmt" + + "git.ewintr.nl/gte/internal/task" +) + +var ( + ErrInboxProcess = errors.New("could not process inbox") +) + +type Inbox struct { + taskRepo *task.TaskRepo +} + +type InboxResult struct { + Count int +} + +func NewInbox(repo *task.TaskRepo) *Inbox { + return &Inbox{ + taskRepo: repo, + } +} + +func (inbox *Inbox) Process() (*InboxResult, error) { + tasks, err := inbox.taskRepo.FindAll(task.FOLDER_INBOX) + if err != nil { + return &InboxResult{}, fmt.Errorf("%w: %v", ErrInboxProcess, err) + } + + var cleanupNeeded bool + for _, t := range tasks { + if t.Dirty { + if err := inbox.taskRepo.Update(t); err != nil { + return &InboxResult{}, fmt.Errorf("%w: %v", ErrInboxProcess, err) + } + cleanupNeeded = true + } + } + if cleanupNeeded { + if err := inbox.taskRepo.CleanUp(); err != nil { + return &InboxResult{}, fmt.Errorf("%w: %v", ErrInboxProcess, err) + } + } + + return &InboxResult{ + Count: len(tasks), + }, nil +} diff --git a/internal/process/inbox_test.go b/internal/process/inbox_test.go new file mode 100644 index 0000000..9507c55 --- /dev/null +++ b/internal/process/inbox_test.go @@ -0,0 +1,134 @@ +package process_test + +import ( + "testing" + + "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/mstore" +) + +func TestInboxProcess(t *testing.T) { + for _, tc := range []struct { + name string + messages map[string][]*mstore.Message + expResult *process.InboxResult + expMsgs map[string][]*mstore.Message + }{ + { + name: "empty", + messages: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {}, + }, + expResult: &process.InboxResult{}, + expMsgs: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {}, + }, + }, + { + name: "all flavors", + messages: map[string][]*mstore.Message{ + task.FOLDER_INBOX: { + { + Subject: "to new", + }, + { + Subject: "to recurring", + Body: "recur: 2021-05-14, daily\nid: xxx-xxx\nversion: 1", + }, + { + Subject: "to planned", + Body: "due: 2021-05-14\nid: xxx-xxx\nversion: 1", + }, + { + Subject: "to unplanned", + Body: "id: xxx-xxx\nversion: 1", + }, + }, + }, + expResult: &process.InboxResult{ + Count: 4, + }, + expMsgs: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {}, + task.FOLDER_NEW: {{Subject: "to new"}}, + task.FOLDER_RECURRING: {{Subject: "to recurring"}}, + task.FOLDER_PLANNED: {{Subject: "2021-05-14 (friday) - to planned"}}, + task.FOLDER_UNPLANNED: {{Subject: "to unplanned"}}, + }, + }, + { + name: "cleanup", + messages: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {{ + Subject: "new version", + Body: "id: xxx-xxx\nversion: 3", + }}, + task.FOLDER_UNPLANNED: {{ + Subject: "old version", + Body: "id: xxx-xxx\nversion: 3", + }}, + }, + expResult: &process.InboxResult{ + Count: 1, + }, + expMsgs: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {}, + task.FOLDER_UNPLANNED: {{Subject: "new version"}}, + }, + }, + { + name: "cleanup version conflict", + messages: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {{ + Subject: "new version", + Body: "id: xxx-xxx\nversion: 3", + }}, + task.FOLDER_UNPLANNED: {{ + Subject: "not really old version", + Body: "id: xxx-xxx\nversion: 5", + }}, + }, + expResult: &process.InboxResult{ + Count: 1, + }, + expMsgs: map[string][]*mstore.Message{ + task.FOLDER_INBOX: {}, + task.FOLDER_UNPLANNED: {{Subject: "not really old version"}}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mstorer, err := mstore.NewMemory([]string{ + task.FOLDER_INBOX, + task.FOLDER_NEW, + task.FOLDER_RECURRING, + task.FOLDER_PLANNED, + task.FOLDER_UNPLANNED, + }) + test.OK(t, err) + for folder, messages := range tc.messages { + for _, m := range messages { + test.OK(t, mstorer.Add(folder, m.Subject, m.Body)) + } + } + + inboxProc := process.NewInbox(task.NewRepository(mstorer)) + actResult, err := inboxProc.Process() + + test.OK(t, err) + test.Equals(t, tc.expResult, actResult) + for folder, expMessages := range tc.expMsgs { + actMessages, err := mstorer.Messages(folder) + test.OK(t, err) + test.Equals(t, len(expMessages), len(actMessages)) + if len(expMessages) == 0 { + + continue + } + test.Equals(t, expMessages[0].Subject, actMessages[0].Subject) + } + }) + } +} diff --git a/internal/process/recur.go b/internal/process/recur.go new file mode 100644 index 0000000..7347b94 --- /dev/null +++ b/internal/process/recur.go @@ -0,0 +1,56 @@ +package process + +import ( + "errors" + "fmt" + + "git.ewintr.nl/gte/internal/task" +) + +var ( + ErrRecurProcess = errors.New("could not generate tasks from recurrer") +) + +type Recur struct { + taskRepo *task.TaskRepo + taskDispatcher *task.Dispatcher + daysAhead int +} + +type RecurResult struct { + Count int +} + +func NewRecur(repo *task.TaskRepo, disp *task.Dispatcher, daysAhead int) *Recur { + return &Recur{ + taskRepo: repo, + taskDispatcher: disp, + daysAhead: daysAhead, + } +} + +func (recur *Recur) Process() (*RecurResult, error) { + tasks, err := recur.taskRepo.FindAll(task.FOLDER_RECURRING) + if err != nil { + return &RecurResult{}, fmt.Errorf("%w: %v", ErrRecurProcess, err) + } + + rDate := task.Today.AddDays(recur.daysAhead) + var count int + for _, t := range tasks { + if t.RecursOn(rDate) { + newTask, err := t.GenerateFromRecurrer(rDate) + if err != nil { + return &RecurResult{}, fmt.Errorf("%w: %v", ErrRecurProcess, err) + } + if err := recur.taskDispatcher.Dispatch(newTask); err != nil { + return &RecurResult{}, fmt.Errorf("%w: %v", ErrRecurProcess, err) + } + count++ + } + } + + return &RecurResult{ + Count: count, + }, nil +} diff --git a/internal/process/recur_test.go b/internal/process/recur_test.go new file mode 100644 index 0000000..511a3b1 --- /dev/null +++ b/internal/process/recur_test.go @@ -0,0 +1,69 @@ +package process_test + +import ( + "testing" + + "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/internal/process" + "git.ewintr.nl/gte/internal/task" + "git.ewintr.nl/gte/pkg/msend" + "git.ewintr.nl/gte/pkg/mstore" +) + +func TestRecurProcess(t *testing.T) { + task.Today = task.NewDate(2021, 5, 14) + for _, tc := range []struct { + name string + recurMsgs []*mstore.Message + expResult *process.RecurResult + expMsgs []*msend.Message + }{ + { + name: "empty", + expResult: &process.RecurResult{}, + expMsgs: []*msend.Message{}, + }, + { + name: "one of two recurring", + recurMsgs: []*mstore.Message{ + { + Subject: "not recurring", + Body: "recur: 2021-05-20, daily\nid: xxx-xxx\nversion: 1", + }, + { + Subject: "recurring", + Body: "recur: 2021-05-10, daily\nid: xxx-xxx\nversion: 1", + }, + }, + expResult: &process.RecurResult{ + Count: 1, + }, + expMsgs: []*msend.Message{ + {Subject: "2021-05-15 (saturday) - recurring"}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mstorer, err := mstore.NewMemory([]string{ + task.FOLDER_INBOX, + task.FOLDER_NEW, + task.FOLDER_RECURRING, + task.FOLDER_PLANNED, + task.FOLDER_UNPLANNED, + }) + test.OK(t, err) + for _, m := range tc.recurMsgs { + test.OK(t, mstorer.Add(task.FOLDER_RECURRING, m.Subject, m.Body)) + } + msender := msend.NewMemory() + + recurProc := process.NewRecur(task.NewRepository(mstorer), task.NewDispatcher(msender), 1) + actResult, err := recurProc.Process() + test.OK(t, err) + test.Equals(t, tc.expResult, actResult) + for i, expMsg := range tc.expMsgs { + test.Equals(t, expMsg.Subject, msender.Messages[i].Subject) + } + }) + } +} diff --git a/internal/task/dispatch.go b/internal/task/dispatch.go new file mode 100644 index 0000000..0852347 --- /dev/null +++ b/internal/task/dispatch.go @@ -0,0 +1,20 @@ +package task + +import "git.ewintr.nl/gte/pkg/msend" + +type Dispatcher struct { + msender msend.MSender +} + +func NewDispatcher(msender msend.MSender) *Dispatcher { + return &Dispatcher{ + msender: msender, + } +} + +func (d *Dispatcher) Dispatch(t *Task) error { + return d.msender.Send(&msend.Message{ + Subject: t.FormatSubject(), + Body: t.FormatBody(), + }) +} diff --git a/internal/task/repo.go b/internal/task/repo.go index f61dcf8..5275921 100644 --- a/internal/task/repo.go +++ b/internal/task/repo.go @@ -52,8 +52,8 @@ func (tr *TaskRepo) Update(t *Task) error { } // add new - if err := tr.mstore.Add(t.Folder, t.FormatSubject(), t.FormatBody()); err != nil { - return fmt.Errorf("%w: %s", ErrMStoreError, err) + if err := tr.Add(t); err != nil { + return err } // remove old @@ -66,6 +66,18 @@ func (tr *TaskRepo) Update(t *Task) error { return nil } +func (tr *TaskRepo) Add(t *Task) error { + if t == nil { + return ErrInvalidTask + } + + if err := tr.mstore.Add(t.Folder, t.FormatSubject(), t.FormatBody()); err != nil { + return fmt.Errorf("%w: %v", ErrMStoreError, err) + } + + return nil +} + // Cleanup removes older versions of tasks func (tr *TaskRepo) CleanUp() error { // loop through folders, get all task version info diff --git a/internal/task/task.go b/internal/task/task.go index b4724f7..cac809c 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -261,20 +261,18 @@ func (t *Task) RecursOn(date Date) bool { return t.Recur.RecursOn(date) } -func (t *Task) CreateDueMessage(date Date) (string, string, error) { - if !t.IsRecurrer() { - return "", "", ErrTaskIsNotRecurring +func (t *Task) GenerateFromRecurrer(date Date) (*Task, error) { + if !t.IsRecurrer() || !t.RecursOn(date) { + return &Task{}, ErrTaskIsNotRecurring } - tempTask := &Task{ + return &Task{ Id: uuid.New().String(), Version: 1, Action: t.Action, Project: t.Project, Due: date, - } - - return tempTask.FormatSubject(), tempTask.FormatBody(), nil + }, nil } func FieldFromBody(field, body string) (string, bool) { diff --git a/pkg/msend/memory.go b/pkg/msend/memory.go new file mode 100644 index 0000000..6f15c0d --- /dev/null +++ b/pkg/msend/memory.go @@ -0,0 +1,17 @@ +package msend + +type Memory struct { + Messages []*Message +} + +func NewMemory() *Memory { + return &Memory{ + Messages: []*Message{}, + } +} + +func (mem *Memory) Send(msg *Message) error { + mem.Messages = append(mem.Messages, msg) + + return nil +} diff --git a/pkg/msend/memory_test.go b/pkg/msend/memory_test.go new file mode 100644 index 0000000..8583914 --- /dev/null +++ b/pkg/msend/memory_test.go @@ -0,0 +1,21 @@ +package msend_test + +import ( + "testing" + + "git.ewintr.nl/go-kit/test" + "git.ewintr.nl/gte/pkg/msend" +) + +func TestMemorySend(t *testing.T) { + mem := msend.NewMemory() + test.Equals(t, []*msend.Message{}, mem.Messages) + + msg1 := &msend.Message{Subject: "sub1", Body: "body1"} + test.OK(t, mem.Send(msg1)) + test.Equals(t, []*msend.Message{msg1}, mem.Messages) + + msg2 := &msend.Message{Subject: "sub2", Body: "body2"} + test.OK(t, mem.Send(msg2)) + test.Equals(t, []*msend.Message{msg1, msg2}, mem.Messages) +} diff --git a/pkg/msend/msend.go b/pkg/msend/msend.go new file mode 100644 index 0000000..5d61875 --- /dev/null +++ b/pkg/msend/msend.go @@ -0,0 +1,16 @@ +package msend + +import "errors" + +var ( + ErrSendFail = errors.New("could not send message") +) + +type Message struct { + Subject string + Body string +} + +type MSender interface { + Send(msg *Message) error +} diff --git a/pkg/msend/smtp.go b/pkg/msend/smtp.go new file mode 100644 index 0000000..186db68 --- /dev/null +++ b/pkg/msend/smtp.go @@ -0,0 +1,127 @@ +package msend + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/mail" + "net/smtp" +) + +var ( + ErrSMTPInvalidConfig = errors.New("invalid smtp configuration") + ErrSMTPConnectionFailed = errors.New("connection to smtp server failed") + ErrSendMessageFailed = errors.New("could not send message") +) + +type SSLSMTPConfig struct { + URL string + Username string + Password string + From string + To string +} + +func (ssc *SSLSMTPConfig) Valid() bool { + if _, _, err := net.SplitHostPort(ssc.URL); err != nil { + return false + } + + return ssc.Username != "" && ssc.Password != "" && ssc.To != "" && ssc.From != "" +} + +type SSLSMTP struct { + config *SSLSMTPConfig + client *smtp.Client + connected bool +} + +func NewSSLSMTP(config *SSLSMTPConfig) *SSLSMTP { + return &SSLSMTP{ + config: config, + } +} + +func (s *SSLSMTP) Connect() error { + if !s.config.Valid() { + return ErrSMTPInvalidConfig + } + + host, _, _ := net.SplitHostPort(s.config.URL) + auth := smtp.PlainAuth("", s.config.Username, s.config.Password, host) + conn, err := tls.Dial("tcp", s.config.URL, &tls.Config{ServerName: host}) + if err != nil { + return fmt.Errorf("%w: %v", ErrSMTPConnectionFailed, err) + } + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("%w: %v", ErrSMTPConnectionFailed, err) + } + if err := client.Auth(auth); err != nil { + return fmt.Errorf("%w: %v", ErrSMTPConnectionFailed, err) + } + s.client = client + s.connected = true + + return nil +} + +func (s *SSLSMTP) Close() error { + if !s.connected { + return nil + } + + if err := s.client.Quit(); err != nil { + return fmt.Errorf("%w: %v", ErrSMTPConnectionFailed, err) + } + s.connected = false + + return nil +} + +func (s *SSLSMTP) Send(msg *Message) error { + if err := s.Connect(); err != nil { + return err + } + defer s.Close() + + from := mail.Address{ + Name: "gte", + Address: s.config.From, + } + to := mail.Address{ + Name: "todo", + Address: s.config.To, + } + + headers := make(map[string]string) + headers["From"] = from.String() + headers["To"] = to.String() + headers["Subject"] = msg.Subject + + message := "" + for k, v := range headers { + message += fmt.Sprintf("%s: %s\r\n", k, v) + } + message += fmt.Sprintf("\r\n%s", msg.Body) + + if err := s.client.Mail(s.config.From); err != nil { + return fmt.Errorf("%w: %v", ErrSendMessageFailed, err) + } + if err := s.client.Rcpt(s.config.To); err != nil { + return fmt.Errorf("%w: %v", ErrSendMessageFailed, err) + } + wc, err := s.client.Data() + if err != nil { + return fmt.Errorf("%w: %v", ErrSendMessageFailed, err) + } + if _, err := wc.Write([]byte(message)); err != nil { + return fmt.Errorf("%w: %v", ErrSendMessageFailed, err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("%w: %v", ErrSendMessageFailed, err) + } + + return nil +} diff --git a/pkg/mstore/imap.go b/pkg/mstore/imap.go index 5535002..4a4a2ae 100644 --- a/pkg/mstore/imap.go +++ b/pkg/mstore/imap.go @@ -1,6 +1,7 @@ package mstore import ( + "errors" "fmt" "io" "io/ioutil" @@ -12,71 +13,97 @@ import ( "github.com/emersion/go-message/mail" ) -type Body struct { +var ( + ErrIMAPInvalidConfig = errors.New("invalid imap configuration") + ErrIMAPConnFailure = errors.New("could not connect with imap") + ErrIMAPNotConnected = errors.New("unable to perform, not connected to imap") + ErrIMAPServerProblem = errors.New("imap server was unable to perform operation") +) + +type IMAPBody struct { reader io.Reader length int } -func NewBody(msg string) *Body { - - return &Body{ +func NewIMAPBody(msg string) *IMAPBody { + return &IMAPBody{ reader: strings.NewReader(msg), length: len([]byte(msg)), } } -func (b *Body) Read(p []byte) (int, error) { +func (b *IMAPBody) Read(p []byte) (int, error) { return b.reader.Read(p) } -func (b *Body) Len() int { +func (b *IMAPBody) Len() int { return b.length } -type ImapConfiguration struct { - ImapUrl string - ImapUsername string - ImapPassword string +type IMAPConfig struct { + IMAPURL string + IMAPUsername string + IMAPPassword string } -func (esc *ImapConfiguration) Valid() bool { - if esc.ImapUrl == "" { +func (esc *IMAPConfig) Valid() bool { + if esc.IMAPURL == "" { return false } - if esc.ImapUsername == "" || esc.ImapPassword == "" { + if esc.IMAPUsername == "" || esc.IMAPPassword == "" { return false } return true } -type Imap struct { - imap *client.Client +type IMAP struct { + config *IMAPConfig + connected bool + client *client.Client mboxStatus *imap.MailboxStatus } -func ImapConnect(conf *ImapConfiguration) (*Imap, error) { - imap, err := client.DialTLS(conf.ImapUrl, nil) +func NewIMAP(config *IMAPConfig) *IMAP { + return &IMAP{ + config: config, + } +} + +func (im *IMAP) Connect() error { + if !im.config.Valid() { + return ErrIMAPInvalidConfig + } + if im.connected { + return nil + } + + cl, err := client.DialTLS(im.config.IMAPURL, nil) if err != nil { - return &Imap{}, err + return fmt.Errorf("%w: %v", ErrIMAPConnFailure, err) } - if err := imap.Login(conf.ImapUsername, conf.ImapPassword); err != nil { - return &Imap{}, err + if err := cl.Login(im.config.IMAPUsername, im.config.IMAPPassword); err != nil { + return fmt.Errorf("%w: %v", ErrIMAPConnFailure, err) } - return &Imap{ - imap: imap, - }, nil + im.client = cl + im.connected = true + + return nil } -func (es *Imap) Disconnect() { - es.imap.Logout() +func (im *IMAP) Close() { + im.client.Logout() + im.connected = false } -func (es *Imap) Folders() ([]string, error) { +func (im *IMAP) Folders() ([]string, error) { + im.Connect() + defer im.Close() + boxes, done := make(chan *imap.MailboxInfo), make(chan error) go func() { - done <- es.imap.List("", "*", boxes) + done <- im.client.List("", "*", boxes) }() folders := []string{} @@ -91,28 +118,35 @@ func (es *Imap) Folders() ([]string, error) { return folders, nil } -func (es *Imap) selectFolder(folder string) error { - status, err := es.imap.Select(folder, false) - if err != nil { - return err +func (im *IMAP) selectFolder(folder string) error { + if !im.connected { + return ErrIMAPNotConnected } - es.mboxStatus = status + status, err := im.client.Select(folder, false) + if err != nil { + return fmt.Errorf("%w, %v", ErrIMAPServerProblem, err) + } + + im.mboxStatus = status return nil } -func (es *Imap) Messages(folder string) ([]*Message, error) { - if err := es.selectFolder(folder); err != nil { +func (im *IMAP) Messages(folder string) ([]*Message, error) { + im.Connect() + defer im.Close() + + if err := im.selectFolder(folder); err != nil { return []*Message{}, err } - if es.mboxStatus.Messages == 0 { + if im.mboxStatus.Messages == 0 { return []*Message{}, nil } seqset := new(imap.SeqSet) - seqset.AddRange(uint32(1), es.mboxStatus.Messages) + seqset.AddRange(uint32(1), im.mboxStatus.Messages) // Get the whole message body section := &imap.BodySectionName{} @@ -120,7 +154,7 @@ func (es *Imap) Messages(folder string) ([]*Message, error) { imsg, done := make(chan *imap.Message), make(chan error) go func() { - done <- es.imap.Fetch(seqset, items, imsg) + done <- im.client.Fetch(seqset, items, imsg) }() messages := []*Message{} @@ -169,30 +203,39 @@ func (es *Imap) Messages(folder string) ([]*Message, error) { } if err := <-done; err != nil { - return []*Message{}, err + return []*Message{}, fmt.Errorf("%w: %v", ErrIMAPServerProblem, err) } return messages, nil } -func (es *Imap) Add(folder, subject, body string) error { +func (im *IMAP) Add(folder, subject, body string) error { + im.Connect() + defer im.Close() + msgStr := fmt.Sprintf(`From: todo Date: %s Subject: %s %s`, time.Now().Format(time.RFC822Z), subject, body) - msg := NewBody(msgStr) + msg := NewIMAPBody(msgStr) - return es.imap.Append(folder, nil, time.Time{}, imap.Literal(msg)) + if err := im.client.Append(folder, nil, time.Time{}, imap.Literal(msg)); err != nil { + return fmt.Errorf("%w: %v", ErrIMAPServerProblem, err) + } + + return nil } -func (es *Imap) Remove(msg *Message) error { +func (im *IMAP) Remove(msg *Message) error { if msg == nil || !msg.Valid() { return ErrInvalidMessage } + im.Connect() + defer im.Close() - if err := es.selectFolder(msg.Folder); err != nil { + if err := im.selectFolder(msg.Folder); err != nil { return err } @@ -200,14 +243,14 @@ func (es *Imap) Remove(msg *Message) error { seqset := new(imap.SeqSet) seqset.AddRange(msg.Uid, msg.Uid) storeItem := imap.FormatFlagsOp(imap.SetFlags, true) - err := es.imap.UidStore(seqset, storeItem, imap.FormatStringList([]string{imap.DeletedFlag}), nil) + err := im.client.UidStore(seqset, storeItem, imap.FormatStringList([]string{imap.DeletedFlag}), nil) if err != nil { - return err + return fmt.Errorf("%w: %v", ErrIMAPServerProblem, err) } // expunge box - if err := es.imap.Expunge(nil); err != nil { - return err + if err := im.client.Expunge(nil); err != nil { + return fmt.Errorf("%w: %v", ErrIMAPServerProblem, err) } return nil