Ajout d'une newsletter basique

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é
This commit is contained in:
wpetit 2020-10-05 14:16:25 +02:00
parent 6cdbea92d1
commit 137709adea
14 changed files with 739 additions and 0 deletions

View File

@ -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
}

View File

@ -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
View 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
View File

@ -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
View File

@ -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=

View File

@ -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
View 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
View 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
View 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
View 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
}

View File

@ -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"`

View File

@ -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
View 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
View File

@ -0,0 +1,6 @@
package task
type Task interface {
Name() string
Run()
}