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:
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,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()
}