Ajout d'une newsletter basique #25
@ -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
|
||||
}
|
||||
|
@ -153,6 +153,8 @@ func main() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
go runTaskScheduler(ctx, conf)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Define base middlewares
|
||||
|
85
cmd/server/scheduler.go
Normal file
85
cmd/server/scheduler.go
Normal file
@ -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()
|
||||
}
|
5
go.mod
5
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
|
||||
)
|
||||
|
14
go.sum
14
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=
|
||||
|
@ -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" }}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
52
internal/mail/mailer.go
Normal file
52
internal/mail/mailer.go
Normal file
@ -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}
|
||||
}
|
11
internal/mail/provider.go
Normal file
11
internal/mail/provider.go
Normal file
@ -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
|
||||
}
|
||||
}
|
207
internal/mail/send.go
Normal file
207
internal/mail/send.go
Normal file
@ -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)
|
||||
}
|
33
internal/mail/service.go
Normal file
33
internal/mail/service.go
Normal file
@ -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
|
||||
}
|
@ -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"`
|
||||
|
@ -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}
|
||||
}
|
||||
|
212
internal/task/newsletter.go
Normal file
212
internal/task/newsletter.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
6
internal/task/task.go
Normal file
6
internal/task/task.go
Normal file
@ -0,0 +1,6 @@
|
||||
package task
|
||||
|
||||
type Task interface {
|
||||
Name() string
|
||||
Run()
|
||||
}
|
Loading…
Reference in New Issue
Block a user