From 137709adea8e813f8885c18942d127d4fbba2ed4 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 5 Oct 2020 14:16:25 +0200 Subject: [PATCH] Ajout d'une newsletter basique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La newsletter effectue une collecte des évènements sur une période de temps donné et envoi un récapitulatif à l'ensemble des utilisateurs de Daddy. Actuellement, sont collectés et présentés: - Les créations de groupes de travail - Les créations de dossiers d'aide à la décision - Les dossiers dont le statut à été modifié et prêt à voté --- cmd/server/container.go | 7 + cmd/server/main.go | 2 + cmd/server/scheduler.go | 85 ++++++++++++ go.mod | 5 + go.sum | 14 ++ internal/config/config.go | 87 ++++++++++++ internal/mail/mailer.go | 52 ++++++++ internal/mail/provider.go | 11 ++ internal/mail/send.go | 207 +++++++++++++++++++++++++++++ internal/mail/service.go | 33 +++++ internal/model/dsf.go | 7 + internal/model/user_repository.go | 11 ++ internal/task/newsletter.go | 212 ++++++++++++++++++++++++++++++ internal/task/task.go | 6 + 14 files changed, 739 insertions(+) create mode 100644 cmd/server/scheduler.go create mode 100644 internal/mail/mailer.go create mode 100644 internal/mail/provider.go create mode 100644 internal/mail/send.go create mode 100644 internal/mail/service.go create mode 100644 internal/task/newsletter.go create mode 100644 internal/task/task.go diff --git a/cmd/server/container.go b/cmd/server/container.go index d9017f8..925aa19 100644 --- a/cmd/server/container.go +++ b/cmd/server/container.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "forge.cadoles.com/Cadoles/daddy/internal/mail" "forge.cadoles.com/Cadoles/daddy/internal/model" "forge.cadoles.com/Cadoles/daddy/internal/voter" @@ -108,5 +109,11 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con model.NewWorkgroupVoter(), )) + ctn.Provide(mail.ServiceName, mail.ServiceProvider( + mail.WithServer(conf.SMTP.Host, conf.SMTP.Port), + mail.WithCredentials(conf.SMTP.User, conf.SMTP.Password), + mail.WithTLS(conf.SMTP.UseStartTLS, conf.SMTP.InsecureSkipVerify), + )) + return ctn, nil } diff --git a/cmd/server/main.go b/cmd/server/main.go index 0f9c0e1..118e328 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -153,6 +153,8 @@ func main() { os.Exit(0) } + go runTaskScheduler(ctx, conf) + r := chi.NewRouter() // Define base middlewares diff --git a/cmd/server/scheduler.go b/cmd/server/scheduler.go new file mode 100644 index 0000000..21eea65 --- /dev/null +++ b/cmd/server/scheduler.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + + "gitlab.com/wpetit/goweb/logger" + + "forge.cadoles.com/Cadoles/daddy/internal/config" + "forge.cadoles.com/Cadoles/daddy/internal/task" + "github.com/pkg/errors" + "github.com/robfig/cron/v3" +) + +type cronLogger struct { + ctx context.Context +} + +func (l *cronLogger) Info(msg string, keysAndValues ...interface{}) { + fields := l.createFields(keysAndValues) + logger.Info(l.ctx, msg, fields...) +} + +func (l *cronLogger) Error(err error, msg string, keysAndValues ...interface{}) { + fields := l.createFields(keysAndValues) + fields = append(fields, logger.E(err)) + logger.Error(l.ctx, msg, fields...) +} + +func (l *cronLogger) createFields(keysAndValues ...interface{}) []logger.Field { + fields := make([]logger.Field, 0) + + var key string + + for _, v := range keysAndValues { + children, ok := v.([]interface{}) + if !ok { + continue + } + + for i, vv := range children { + if i%2 == 0 { + key = fmt.Sprintf("%v", vv) + + continue + } + + fields = append(fields, logger.F(key, vv)) + } + } + + return fields +} + +func runTaskScheduler(ctx context.Context, conf *config.Config) { + c := cron.New( + cron.WithLogger(&cronLogger{ctx}), + ) + + tasks := map[string]task.Task{ + conf.Task.Newsletter.CronSpec: task.NewNewsletter( + ctx, + conf.Task.Newsletter.TimeRange, + conf.Task.Newsletter.BaseURL, + conf.Task.Newsletter.ContentTemplate, + conf.Task.Newsletter.SubjectTemplate, + conf.SMTP.SenderAddress, + ), + } + + for spec, task := range tasks { + if _, err := c.AddFunc(spec, task.Run); err != nil { + logger.Fatal( + ctx, + "could not schedule task", + logger.F("task", task.Name()), + logger.E(errors.WithStack(err)), + ) + + return + } + } + + c.Start() +} diff --git a/go.mod b/go.mod index 5782660..6d741a0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.14 require ( forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032 + forge.cadoles.com/wpetit/hydra-passwordless v0.0.0-20200908094025-38ac4422dddc // indirect github.com/99designs/gqlgen v0.11.3 github.com/antonmedv/expr v1.8.8 github.com/caarlos0/env/v6 v6.2.2 @@ -15,10 +16,14 @@ require ( github.com/jackc/pgx v3.6.2+incompatible github.com/jackc/pgx/v4 v4.7.1 github.com/jinzhu/gorm v1.9.14 + github.com/lithammer/dedent v1.1.0 github.com/pkg/errors v0.9.1 + github.com/robfig/cron v1.2.0 + github.com/robfig/cron/v3 v3.0.1 github.com/rs/cors v1.7.0 github.com/vektah/gqlparser/v2 v2.0.1 github.com/wader/gormstore v0.0.0-20200328121358-65a111a20c23 gitlab.com/wpetit/goweb v0.0.0-20200707070104-985ce3eba3c2 + gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.2.8 ) diff --git a/go.sum b/go.sum index 92db44d..330991c 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032 h1:qTYaLPsLDlvqDkatONsvrisvfvpHaGe3lQqIaX7FFQQ= forge.cadoles.com/wpetit/goweb-oidc v0.0.0-20200619080035-4bbf7b016032/go.mod h1:gkfqGyk7fCj2Z0ngEOCJ3K0FVmqft/8dFV/OnYT1vec= +forge.cadoles.com/wpetit/hydra-passwordless v0.0.0-20200908094025-38ac4422dddc h1:9gc/1qizPtK6/iMVlizknWUFii75ntl2xSUV/FSC92Y= +forge.cadoles.com/wpetit/hydra-passwordless v0.0.0-20200908094025-38ac4422dddc/go.mod h1:nANHORi270d5jDXjeJ7B3pMgK9R4J0/17p1IIc+rhOk= github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4= github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -48,6 +50,7 @@ github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9Pq github.com/antonmedv/expr v1.8.8 h1:uVwIkIBNO2yn4vY2u2DQUqXTmv9jEEMCEcHa19G5weY= github.com/antonmedv/expr v1.8.8/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/bmatcuk/doublestar v1.3.1 h1:rT8rxDPsavp9G+4ZULzqhhUSaI/OPsTZNG88Z3i0xvY= github.com/bmatcuk/doublestar v1.3.1/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= @@ -143,6 +146,8 @@ github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= +github.com/gorilla/csrf v1.6.2/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -235,6 +240,8 @@ github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= @@ -287,6 +294,10 @@ github.com/rivo/tview v0.0.0-20200219210816-cd38d7432498/go.mod h1:6lkG1x+13OShE github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1 h1:FLWDC+iIP9BWgYKvWKKtOUZux35LIQNAuIzp/63RQJU= github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= @@ -510,6 +521,7 @@ google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -519,6 +531,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/config/config.go b/internal/config/config.go index 3745b4a..c773539 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,8 @@ import ( "io/ioutil" "time" + "github.com/lithammer/dedent" + "github.com/pkg/errors" "gitlab.com/wpetit/goweb/logger" @@ -19,6 +21,8 @@ type Config struct { OIDC OIDCConfig `yaml:"oidc"` Database DatabaseConfig `yaml:"database"` Auth AuthConfig `yaml:"auth"` + SMTP SMTPConfig `yaml:"smtp"` + Task TaskConfig `yaml:"task"` } // NewFromFile retrieves the configuration from the given file @@ -74,6 +78,29 @@ type AuthConfig struct { Rules []string `yaml:"rules" env:"AUTH_RULES"` } +type SMTPConfig struct { + Host string `yaml:"host" env:"SMTP_HOST"` + Port int `yaml:"port" env:"SMTP_PORT"` + UseStartTLS bool `yaml:"useStartTLS" env:"SMTP_USE_START_TLS"` + User string `yaml:"user" env:"SMTP_USER"` + Password string `yaml:"password" env:"SMTP_PASSWORD"` + InsecureSkipVerify bool `yaml:"insecureSkipVerify" env:"SMTP_INSECURE_SKIP_VERIFY"` + SenderAddress string `yaml:"senderAddress" env:"SMTP_SENDER_ADDRESS"` + SenderName string `yaml:"senderName" env:"SMTP_SENDER_NAME"` +} + +type TaskConfig struct { + Newsletter NewsletterTaskConfig `yaml:"newsletter"` +} + +type NewsletterTaskConfig struct { + CronSpec string `yaml:"cronSpec" env:"TASK_NEWSLETTER_CRON_SPEC"` + TimeRange time.Duration `yaml:"timeRange" env:"TASK_NEWSLETTER_TIME_RANGE"` + BaseURL string `yaml:"baseUrl" env:"TASK_NEWSLETTER_BASE_URL"` + ContentTemplate string `yaml:"contentTemplate" env:"TASK_NEWSLETTER_CONTENT_TEMPLATE"` + SubjectTemplate string `yaml:"subjectTemplate" env:"TASK_NEWSLETTER_SUBJECT_TEMPLATE"` +} + func NewDumpDefault() *Config { config := NewDefault() return config @@ -112,6 +139,66 @@ func NewDefault() *Config { "user.Email endsWith 'cadoles.com'", }, }, + SMTP: SMTPConfig{ + Host: "localhost", + Port: 2525, + User: "", + Password: "", + SenderAddress: "noreply@localhost", + SenderName: "noreply", + }, + Task: TaskConfig{ + Newsletter: NewsletterTaskConfig{ + CronSpec: "0 9 * * 1", + TimeRange: 24 * 7 * time.Hour, + BaseURL: "http://localhost:8080", + ContentTemplate: dedent.Dedent(` + {{- $root := . -}} + Bonjour {{ .User.Name }}, + + {{ if not .HasEvents -}} + Aucun évènement notoire ces derniers jours. + {{ else -}} + Voici les évènements de ces derniers jours: + {{- end}} + + {{- with .ReadyToVote }} + + Dossiers récemment prêts à voter + -------------------------------- + + {{range . -}} + - "{{ .Title }}" - {{ $root.BaseURL }}/decisions/{{ .ID }} - créé le {{ .CreatedAt.Format "02/01/2006" }} + {{ end }} + {{- end}} + + {{- with .NewDecisionSupportFiles }} + + Nouveaux dossiers d'aide à la décision + -------------------------------------- + + {{range . -}} + - "{{ .Title }}" - {{ $root.BaseURL }}/decisions/{{ .ID }} - créé le {{ .CreatedAt.Format "02/01/2006" }} + {{ end }} + {{- end}} + + {{- with .NewWorkgroups}} + + Nouveaux groupes de travail + --------------------------- + + {{range . -}} + - "{{ .Name }}" - {{ $root.BaseURL }}/workgroups/{{ .ID }} - créé le {{ .CreatedAt.Format "02/01/2006" }} + {{ end }} + {{- end}} + + Bonne semaine, + + Daddy + `), + SubjectTemplate: `[Daddy] Évènements du {{ .From.Format "02/01/2006" }} au {{ .To.Format "02/01/2006" }}`, + }, + }, } } diff --git a/internal/mail/mailer.go b/internal/mail/mailer.go new file mode 100644 index 0000000..b1dbf3b --- /dev/null +++ b/internal/mail/mailer.go @@ -0,0 +1,52 @@ +package mail + +const ( + ContentTypeHTML = "text/html" + ContentTypeText = "text/plain" +) + +type Option struct { + Host string + Port int + User string + Password string + InsecureSkipVerify bool + UseStartTLS bool +} + +type OptionFunc func(*Option) + +type Mailer struct { + opt *Option +} + +func WithTLS(useStartTLS, insecureSkipVerify bool) OptionFunc { + return func(opt *Option) { + opt.UseStartTLS = useStartTLS + opt.InsecureSkipVerify = insecureSkipVerify + } +} + +func WithServer(host string, port int) OptionFunc { + return func(opt *Option) { + opt.Host = host + opt.Port = port + } +} + +func WithCredentials(user, password string) OptionFunc { + return func(opt *Option) { + opt.User = user + opt.Password = password + } +} + +func NewMailer(funcs ...OptionFunc) *Mailer { + opt := &Option{} + + for _, fn := range funcs { + fn(opt) + } + + return &Mailer{opt} +} diff --git a/internal/mail/provider.go b/internal/mail/provider.go new file mode 100644 index 0000000..576b7f4 --- /dev/null +++ b/internal/mail/provider.go @@ -0,0 +1,11 @@ +package mail + +import "gitlab.com/wpetit/goweb/service" + +func ServiceProvider(opts ...OptionFunc) service.Provider { + mailer := NewMailer(opts...) + + return func(ctn *service.Container) (interface{}, error) { + return mailer, nil + } +} diff --git a/internal/mail/send.go b/internal/mail/send.go new file mode 100644 index 0000000..38a00eb --- /dev/null +++ b/internal/mail/send.go @@ -0,0 +1,207 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "math/rand" + "net/mail" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + gomail "gopkg.in/mail.v2" +) + +var ( + ErrUnexpectedEmailAddressFormat = errors.New("unexpected email address format") +) + +type SendFunc func(*SendOption) + +type SendOption struct { + Charset string + AddressHeaders []AddressHeader + Headers []Header + Body Body + AlternativeBodies []Body +} + +type AddressHeader struct { + Field string + Address string + Name string +} + +type Header struct { + Field string + Values []string +} + +type Body struct { + Type string + Content string + PartSetting gomail.PartSetting +} + +func WithCharset(charset string) func(*SendOption) { + return func(opt *SendOption) { + opt.Charset = charset + } +} + +func WithSender(address string, name string) func(*SendOption) { + return WithAddressHeader("From", address, name) +} + +func WithSubject(subject string) func(*SendOption) { + return WithHeader("Subject", subject) +} + +func WithAddressHeader(field, address, name string) func(*SendOption) { + return func(opt *SendOption) { + opt.AddressHeaders = append(opt.AddressHeaders, AddressHeader{field, address, name}) + } +} + +func WithHeader(field string, values ...string) func(*SendOption) { + return func(opt *SendOption) { + opt.Headers = append(opt.Headers, Header{field, values}) + } +} + +func WithRecipients(addresses ...string) func(*SendOption) { + return WithHeader("To", addresses...) +} + +func WithCopies(addresses ...string) func(*SendOption) { + return WithHeader("Cc", addresses...) +} + +func WithInvisibleCopies(addresses ...string) func(*SendOption) { + return WithHeader("Cci", addresses...) +} + +func WithBody(contentType string, content string, setting gomail.PartSetting) func(*SendOption) { + return func(opt *SendOption) { + if setting == nil { + setting = gomail.SetPartEncoding(gomail.Unencoded) + } + opt.Body = Body{contentType, content, setting} + } +} + +func WithAlternativeBody(contentType string, content string, setting gomail.PartSetting) func(*SendOption) { + return func(opt *SendOption) { + if setting == nil { + setting = gomail.SetPartEncoding(gomail.Unencoded) + } + opt.AlternativeBodies = append(opt.AlternativeBodies, Body{contentType, content, setting}) + } +} + +func (m *Mailer) Send(funcs ...SendFunc) error { + opt := &SendOption{ + Charset: "UTF-8", + Body: Body{ + Type: "text/plain", + Content: "", + PartSetting: gomail.SetPartEncoding(gomail.Unencoded), + }, + AddressHeaders: make([]AddressHeader, 0), + Headers: make([]Header, 0), + AlternativeBodies: make([]Body, 0), + } + + for _, f := range funcs { + f(opt) + } + + conn, err := m.openConnection() + if err != nil { + return errors.Wrap(err, "could not open connection") + } + + defer conn.Close() + + message := gomail.NewMessage(gomail.SetCharset(opt.Charset)) + + for _, h := range opt.AddressHeaders { + message.SetAddressHeader(h.Field, h.Address, h.Name) + } + + for _, h := range opt.Headers { + message.SetHeader(h.Field, h.Values...) + } + + froms := message.GetHeader("From") + + var sendDomain string + + if len(froms) > 0 { + sendDomain, err = extractEmailDomain(froms[0]) + if err != nil { + return err + } + } + + messageID := generateMessageID(sendDomain) + message.SetHeader("Message-Id", messageID) + + message.SetBody(opt.Body.Type, opt.Body.Content, opt.Body.PartSetting) + + for _, b := range opt.AlternativeBodies { + message.AddAlternative(b.Type, b.Content, b.PartSetting) + } + + if err := gomail.Send(conn, message); err != nil { + return errors.Wrap(err, "could not send message") + } + + return nil +} + +func (m *Mailer) openConnection() (gomail.SendCloser, error) { + dialer := gomail.NewDialer( + m.opt.Host, + m.opt.Port, + m.opt.User, + m.opt.Password, + ) + + if m.opt.InsecureSkipVerify { + dialer.TLSConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + + conn, err := dialer.Dial() + if err != nil { + return nil, errors.Wrap(err, "could not dial smtp server") + } + + return conn, nil +} + +func extractEmailDomain(email string) (string, error) { + address, err := mail.ParseAddress(email) + if err != nil { + return "", errors.Wrapf(err, "could not parse email address '%s'", email) + } + + addressParts := strings.SplitN(address.Address, "@", 2) + if len(addressParts) != 2 { // nolint: gomnd + return "", errors.WithStack(ErrUnexpectedEmailAddressFormat) + } + + domain := addressParts[1] + + return domain, nil +} + +func generateMessageID(domain string) string { + // Based on https://www.jwz.org/doc/mid.html + timestamp := strconv.FormatInt(time.Now().UnixNano(), 36) + random := strconv.FormatInt(rand.Int63(), 36) + return fmt.Sprintf("<%s.%s@%s>", timestamp, random, domain) +} diff --git a/internal/mail/service.go b/internal/mail/service.go new file mode 100644 index 0000000..acde334 --- /dev/null +++ b/internal/mail/service.go @@ -0,0 +1,33 @@ +package mail + +import ( + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/service" +) + +const ServiceName service.Name = "mail" + +// From retrieves the mail service in the given container +func From(container *service.Container) (*Mailer, error) { + service, err := container.Service(ServiceName) + if err != nil { + return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName) + } + + srv, ok := service.(*Mailer) + if !ok { + return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName) + } + + return srv, nil +} + +// Must retrieves the mail service in the given container or panic otherwise +func Must(container *service.Container) *Mailer { + srv, err := From(container) + if err != nil { + panic(err) + } + + return srv +} diff --git a/internal/model/dsf.go b/internal/model/dsf.go index ee01bb5..89321fd 100644 --- a/internal/model/dsf.go +++ b/internal/model/dsf.go @@ -11,6 +11,13 @@ import ( const ObjectTypeDecisionSupportFile = "dsf" +const ( + StatusDraft = "draft" + StatusReady = "ready" + StatusVoted = "voted" + StatusClosed = "closed" +) + type DecisionSupportFile struct { gorm.Model Title string `json:"title"` diff --git a/internal/model/user_repository.go b/internal/model/user_repository.go index 378982b..7e652d3 100644 --- a/internal/model/user_repository.go +++ b/internal/model/user_repository.go @@ -80,6 +80,17 @@ func (r *UserRepository) Find(ctx context.Context, id string) (*User, error) { return user, nil } +func (r *UserRepository) All(ctx context.Context) ([]*User, error) { + users := make([]*User, 0) + query := r.db.Model(&User{}) + + if err := query.Find(&users).Error; err != nil { + return nil, errs.WithStack(err) + } + + return users, nil +} + func NewUserRepository(db *gorm.DB) *UserRepository { return &UserRepository{db} } diff --git a/internal/task/newsletter.go b/internal/task/newsletter.go new file mode 100644 index 0000000..5f2158b --- /dev/null +++ b/internal/task/newsletter.go @@ -0,0 +1,212 @@ +package task + +import ( + "bytes" + "context" + "fmt" + "text/template" + "time" + + "forge.cadoles.com/Cadoles/daddy/internal/mail" + "forge.cadoles.com/Cadoles/daddy/internal/model" + "github.com/pkg/errors" + + "forge.cadoles.com/Cadoles/daddy/internal/orm" + + "gitlab.com/wpetit/goweb/middleware/container" + + "gitlab.com/wpetit/goweb/logger" +) + +type Newsletter struct { + ctx context.Context + timeRange time.Duration + baseURL string + contentTemplate string + subjectTemplate string + from string +} + +func (t *Newsletter) Name() string { + return "newsletter" +} + +func (t *Newsletter) Run() { + ctx := t.ctx + + logger.Info(ctx, "preparing newsletter", logger.F("timeRange", t.timeRange)) + + contentTmpl, err := template.New("").Parse(t.contentTemplate) + if err != nil { + logger.Error(ctx, "could not parse newsletter content template", logger.E(errors.WithStack(err))) + + return + } + + subjectTmpl, err := template.New("").Parse(t.subjectTemplate) + if err != nil { + logger.Error(ctx, "could not parse newsletter subject template", logger.E(errors.WithStack(err))) + + return + } + + ctn := container.Must(ctx) + orm := orm.Must(ctn) + db := orm.DB() + mailSrv := mail.Must(ctn) + + eventRepo := model.NewEventRepository(db) + + to := time.Now() + from := to.Add(-t.timeRange) + + events, err := eventRepo.Search(ctx, &model.EventFilter{ + From: &from, + To: &to, + }) + if err != nil { + logger.Error(ctx, "could not search events", logger.E(errors.WithStack(err))) + + return + } + + newWorkgroups := make([]*model.Workgroup, 0) + newDecisionSupportFiles := make([]*model.DecisionSupportFile, 0) + readyToVote := make([]*model.DecisionSupportFile, 0) + + workgroupRepo := model.NewWorkgroupRepository(db) + dsfRepo := model.NewDSFRepository(db) + + for _, evt := range events { + switch { + case evt.Type == model.EventTypeCreated && evt.ObjectType == model.ObjectTypeWorkgroup: + workgroup, err := workgroupRepo.Find(ctx, fmt.Sprintf("%d", evt.ObjectID)) + if err != nil { + logger.Error( + ctx, "could not find workgroup", + logger.E(errors.WithStack(err)), + logger.F("id", evt.ObjectID), + ) + + return + } + + newWorkgroups = append(newWorkgroups, workgroup) + + case evt.Type == model.EventTypeCreated && evt.ObjectType == model.ObjectTypeDecisionSupportFile: + dsf, err := dsfRepo.Find(ctx, fmt.Sprintf("%d", evt.ObjectID)) + if err != nil { + logger.Error( + ctx, "could not find decision support file", + logger.E(errors.WithStack(err)), + logger.F("id", evt.ObjectID), + ) + + return + } + + newDecisionSupportFiles = append(newDecisionSupportFiles, dsf) + + case evt.Type == model.EventTypeStatusChanged && evt.ObjectType == model.ObjectTypeDecisionSupportFile: + dsf, err := dsfRepo.Find(ctx, fmt.Sprintf("%d", evt.ObjectID)) + if err != nil { + logger.Error( + ctx, "could not find decision support file", + logger.E(errors.WithStack(err)), + logger.F("id", evt.ObjectID), + ) + + return + } + + if dsf.Status == model.StatusReady { + readyToVote = append(readyToVote, dsf) + } + } + } + + hasEvents := len(newDecisionSupportFiles) > 0 || len(newWorkgroups) > 0 + + userRepo := model.NewUserRepository(db) + + users, err := userRepo.All(ctx) + if err != nil { + logger.Error(ctx, "could not find users", logger.E(errors.WithStack(err))) + + return + } + + var ( + contentBuff bytes.Buffer + subjectBuff bytes.Buffer + ) + + for _, u := range users { + tmplData := struct { + User *model.User + NewWorkgroups []*model.Workgroup + NewDecisionSupportFiles []*model.DecisionSupportFile + ReadyToVote []*model.DecisionSupportFile + BaseURL string + From time.Time + To time.Time + HasEvents bool + }{ + User: u, + BaseURL: t.baseURL, + NewWorkgroups: newWorkgroups, + NewDecisionSupportFiles: newDecisionSupportFiles, + ReadyToVote: readyToVote, + From: from.Local(), + To: to.Local(), + HasEvents: hasEvents, + } + + err = contentTmpl.Execute(&contentBuff, tmplData) + if err != nil { + logger.Error(ctx, "could not execute newsletter content template", logger.E(errors.WithStack(err))) + + return + } + + err = subjectTmpl.Execute(&subjectBuff, tmplData) + if err != nil { + logger.Error(ctx, "could not execute newsletter subject template", logger.E(errors.WithStack(err))) + + return + } + + newsletterContent := contentBuff.String() + newsletterSubject := subjectBuff.String() + + err := mailSrv.Send( + mail.WithRecipients(u.Email), + mail.WithSubject(newsletterSubject), + mail.WithSender(t.from, ""), + mail.WithBody(mail.ContentTypeText, newsletterContent, nil), + ) + if err != nil { + logger.Error( + ctx, "could not send newsletter", + logger.E(errors.WithStack(err)), + logger.F("email", u.Email), + ) + + return + } + + contentBuff.Reset() + subjectBuff.Reset() + } +} + +func NewNewsletter(ctx context.Context, timeRange time.Duration, baseURL, contentTemplate, subjectTemplate, from string) *Newsletter { + return &Newsletter{ + ctx: ctx, + timeRange: timeRange, + baseURL: baseURL, + contentTemplate: contentTemplate, + subjectTemplate: subjectTemplate, + from: from, + } +} diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 0000000..7a9c215 --- /dev/null +++ b/internal/task/task.go @@ -0,0 +1,6 @@ +package task + +type Task interface { + Name() string + Run() +}