2022-05-21 12:09:19 +00:00
|
|
|
/*
|
2022-10-15 17:58:58 +00:00
|
|
|
Copyright 2017-2022 CAcert Inc.
|
2022-05-21 12:09:19 +00:00
|
|
|
SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
package notifications
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"fmt"
|
2022-05-29 13:36:27 +00:00
|
|
|
"log"
|
|
|
|
"net"
|
2022-10-15 17:58:58 +00:00
|
|
|
"os"
|
2022-05-15 18:10:49 +00:00
|
|
|
"path"
|
|
|
|
"text/template"
|
2022-10-15 17:58:58 +00:00
|
|
|
"time"
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
"github.com/Masterminds/sprig/v3"
|
|
|
|
"gopkg.in/mail.v2"
|
2022-05-21 12:09:19 +00:00
|
|
|
|
|
|
|
"git.cacert.org/cacert-boardvoting/internal"
|
|
|
|
"git.cacert.org/cacert-boardvoting/internal/models"
|
2022-05-15 18:10:49 +00:00
|
|
|
)
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
type MailConfig struct {
|
|
|
|
SMTPHost string `yaml:"smtp_host"`
|
|
|
|
SMTPPort int `yaml:"smtp_port"`
|
|
|
|
SMTPTimeOut time.Duration `yaml:"smtp_timeout,omitempty"`
|
|
|
|
NotificationSenderAddress string `yaml:"notification_sender_address"`
|
|
|
|
NoticeMailAddress string `yaml:"notice_mail_address"`
|
|
|
|
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
|
|
|
|
BaseURL string `yaml:"base_url"`
|
|
|
|
}
|
|
|
|
|
2022-05-15 18:10:49 +00:00
|
|
|
type recipientData struct {
|
|
|
|
field, address, name string
|
|
|
|
}
|
|
|
|
|
|
|
|
type NotificationContent struct {
|
|
|
|
template string
|
|
|
|
data interface{}
|
|
|
|
subject string
|
2022-05-21 11:51:17 +00:00
|
|
|
headers map[string][]string
|
2022-05-15 18:10:49 +00:00
|
|
|
recipients []recipientData
|
|
|
|
}
|
|
|
|
|
|
|
|
type NotificationMail interface {
|
2022-10-15 17:58:58 +00:00
|
|
|
GetNotificationContent(*MailConfig) *NotificationContent
|
2022-05-15 18:10:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type MailNotifier struct {
|
2022-05-29 13:36:27 +00:00
|
|
|
notifyChannel chan NotificationMail
|
|
|
|
senderAddress string
|
|
|
|
dialer *mail.Dialer
|
|
|
|
quitChannel chan struct{}
|
|
|
|
infoLog, errorLog *log.Logger
|
2022-10-15 17:58:58 +00:00
|
|
|
mailConfig *MailConfig
|
2022-05-15 18:10:49 +00:00
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
type Option func(*MailNotifier)
|
|
|
|
|
|
|
|
func NewMailNotifier(config *MailConfig, opts ...Option) *MailNotifier {
|
|
|
|
n := &MailNotifier{
|
2022-05-15 18:10:49 +00:00
|
|
|
notifyChannel: make(chan NotificationMail, 1),
|
2022-10-15 17:58:58 +00:00
|
|
|
senderAddress: config.NotificationSenderAddress,
|
|
|
|
dialer: mail.NewDialer(config.SMTPHost, config.SMTPPort, "", ""),
|
2022-05-21 12:09:19 +00:00
|
|
|
quitChannel: make(chan struct{}),
|
2022-10-15 17:58:58 +00:00
|
|
|
infoLog: log.New(os.Stdout, "", 0),
|
|
|
|
errorLog: log.New(os.Stderr, "", 0),
|
|
|
|
mailConfig: config,
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, o := range opts {
|
|
|
|
o(n)
|
|
|
|
}
|
|
|
|
|
|
|
|
return n
|
|
|
|
}
|
|
|
|
|
|
|
|
func NotifierLog(infoLog, errorLog *log.Logger) Option {
|
|
|
|
return func(n *MailNotifier) {
|
|
|
|
n.infoLog = infoLog
|
|
|
|
n.errorLog = errorLog
|
2022-05-15 18:10:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-29 13:36:27 +00:00
|
|
|
func (mn *MailNotifier) Start() {
|
|
|
|
mn.infoLog.Print("Launching mail notifier")
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
for {
|
|
|
|
select {
|
2022-05-29 13:36:27 +00:00
|
|
|
case notification := <-mn.notifyChannel:
|
|
|
|
content := notification.GetNotificationContent(mn.mailConfig)
|
2022-05-15 18:10:49 +00:00
|
|
|
|
2022-05-29 13:36:27 +00:00
|
|
|
mailText, err := content.buildMail(mn.mailConfig.BaseURL)
|
2022-05-15 18:10:49 +00:00
|
|
|
if err != nil {
|
2022-05-29 13:36:27 +00:00
|
|
|
mn.errorLog.Printf("building mail failed: %v", err)
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
m := mail.NewMessage()
|
2022-05-21 11:51:17 +00:00
|
|
|
|
|
|
|
m.SetHeaders(content.headers)
|
2022-05-29 13:36:27 +00:00
|
|
|
m.SetAddressHeader("From", mn.senderAddress, "CAcert board voting system")
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
for _, recipient := range content.recipients {
|
|
|
|
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
|
|
|
|
}
|
|
|
|
|
|
|
|
m.SetHeader("Subject", content.subject)
|
|
|
|
|
|
|
|
m.SetBody("text/plain", mailText.String())
|
|
|
|
|
2022-05-29 13:36:27 +00:00
|
|
|
if err = mn.dialer.DialAndSend(m); err != nil {
|
|
|
|
mn.errorLog.Printf("sending mail failed: %v", err)
|
2022-05-15 18:10:49 +00:00
|
|
|
}
|
2022-05-29 13:36:27 +00:00
|
|
|
case <-mn.quitChannel:
|
|
|
|
mn.infoLog.Print("ending mail notifier")
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-29 13:36:27 +00:00
|
|
|
func (mn *MailNotifier) Quit() {
|
|
|
|
mn.quitChannel <- struct{}{}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mn *MailNotifier) Notify(w NotificationMail) {
|
|
|
|
mn.notifyChannel <- w
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mn *MailNotifier) Ping() error {
|
|
|
|
conn, err := net.DialTimeout(
|
|
|
|
"tcp",
|
|
|
|
fmt.Sprintf("%s:%d", mn.mailConfig.SMTPHost, mn.mailConfig.SMTPPort),
|
|
|
|
mn.mailConfig.SMTPTimeOut,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("could not connect to SMTP server: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
defer func(conn net.Conn) {
|
|
|
|
_ = conn.Close()
|
|
|
|
}(conn)
|
|
|
|
|
|
|
|
return nil
|
2022-05-21 12:09:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
|
2022-05-29 13:36:27 +00:00
|
|
|
// TODO: implement a template cache for mail templates too
|
2022-05-21 12:09:19 +00:00
|
|
|
b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template))
|
2022-05-15 18:10:49 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
t, err := template.New(n.template).Funcs(sprig.GenericFuncMap()).Parse(string(b))
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("could not parse mail template %s: %w", n.template, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
data := struct {
|
|
|
|
Data any
|
|
|
|
BaseURL string
|
2022-05-21 12:09:19 +00:00
|
|
|
}{Data: n.data, BaseURL: baseURL}
|
2022-05-15 18:10:49 +00:00
|
|
|
|
|
|
|
mailText := bytes.NewBuffer(make([]byte, 0))
|
|
|
|
if err = t.Execute(mailText, data); err != nil {
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
"failed to execute template %s with context %v: %w", n.template, n.data, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return mailText, nil
|
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func defaultRecipient(mc *MailConfig) recipientData {
|
2022-05-27 15:39:54 +00:00
|
|
|
return recipientData{
|
|
|
|
field: "To",
|
|
|
|
address: mc.NoticeMailAddress,
|
|
|
|
name: "CAcert board mailing list",
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func voteNoticeRecipient(mc *MailConfig) recipientData {
|
2022-05-27 15:39:54 +00:00
|
|
|
return recipientData{
|
|
|
|
field: "To",
|
|
|
|
address: mc.VoteNoticeMailAddress,
|
|
|
|
name: "CAcert board votes mailing list",
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func motionReplyHeaders(m *models.Motion) map[string][]string {
|
|
|
|
return map[string][]string{
|
|
|
|
"References": {fmt.Sprintf("<%s>", m.Tag)},
|
|
|
|
"In-Reply-To": {fmt.Sprintf("<%s>", m.Tag)},
|
|
|
|
}
|
2022-05-26 13:27:25 +00:00
|
|
|
}
|
|
|
|
|
2022-05-29 13:36:27 +00:00
|
|
|
type RemindVoterNotification struct {
|
2022-10-15 17:58:58 +00:00
|
|
|
Voter *models.User
|
|
|
|
Decisions []*models.Motion
|
2022-05-29 13:36:27 +00:00
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (r RemindVoterNotification) GetNotificationContent(*MailConfig) *NotificationContent {
|
2022-06-02 21:14:38 +00:00
|
|
|
recipientAddress := make([]recipientData, 0)
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
if r.Voter.Reminder.Valid {
|
2022-06-02 21:14:38 +00:00
|
|
|
recipientAddress = append(recipientAddress, recipientData{
|
|
|
|
field: "To",
|
2022-10-15 17:58:58 +00:00
|
|
|
address: r.Voter.Reminder.String,
|
|
|
|
name: r.Voter.Name,
|
2022-06-02 21:14:38 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-05-29 13:36:27 +00:00
|
|
|
return &NotificationContent{
|
|
|
|
template: "remind_voter_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
Decisions []*models.Motion
|
|
|
|
Name string
|
2022-10-15 17:58:58 +00:00
|
|
|
}{Decisions: r.Decisions, Name: r.Voter.Name},
|
2022-06-02 21:14:38 +00:00
|
|
|
subject: "Outstanding CAcert board votes",
|
|
|
|
recipients: recipientAddress,
|
2022-05-29 13:36:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-21 11:51:17 +00:00
|
|
|
type ClosedDecisionNotification struct {
|
2022-05-27 15:39:54 +00:00
|
|
|
Decision *models.Motion
|
2022-05-21 11:51:17 +00:00
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (c *ClosedDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
|
2022-05-21 11:51:17 +00:00
|
|
|
return &NotificationContent{
|
2022-05-27 15:39:54 +00:00
|
|
|
template: "closed_motion_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
*models.Motion
|
2022-05-27 18:45:04 +00:00
|
|
|
}{Motion: c.Decision},
|
2022-09-26 15:18:09 +00:00
|
|
|
subject: fmt.Sprintf("Re: %s - %s - finalized", c.Decision.Tag, c.Decision.Title),
|
2022-05-27 15:39:54 +00:00
|
|
|
headers: motionReplyHeaders(c.Decision),
|
2022-05-26 13:27:25 +00:00
|
|
|
recipients: []recipientData{defaultRecipient(mc)},
|
2022-05-21 11:51:17 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-26 13:27:25 +00:00
|
|
|
type NewDecisionNotification struct {
|
2022-05-27 15:39:54 +00:00
|
|
|
Decision *models.Motion
|
|
|
|
Proposer *models.User
|
2022-05-26 13:27:25 +00:00
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (n NewDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
|
2022-05-27 15:39:54 +00:00
|
|
|
voteURL := fmt.Sprintf("/vote/%s", n.Decision.Tag)
|
2022-05-26 13:27:25 +00:00
|
|
|
unvotedURL := "/motions/?unvoted=1"
|
|
|
|
|
|
|
|
return &NotificationContent{
|
|
|
|
template: "create_motion_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
*models.Motion
|
|
|
|
Name string
|
|
|
|
VoteURL string
|
|
|
|
UnvotedURL string
|
2022-05-27 18:45:04 +00:00
|
|
|
}{
|
|
|
|
Motion: n.Decision,
|
|
|
|
Name: n.Proposer.Name,
|
|
|
|
VoteURL: voteURL,
|
|
|
|
UnvotedURL: unvotedURL,
|
|
|
|
},
|
2022-05-27 15:39:54 +00:00
|
|
|
subject: fmt.Sprintf("%s - %s", n.Decision.Tag, n.Decision.Title),
|
2022-05-26 13:27:25 +00:00
|
|
|
headers: n.getHeaders(),
|
|
|
|
recipients: []recipientData{defaultRecipient(mc)},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (n NewDecisionNotification) getHeaders() map[string][]string {
|
|
|
|
return map[string][]string{
|
2022-05-27 15:39:54 +00:00
|
|
|
"Message-ID": {fmt.Sprintf("<%s>", n.Decision.Tag)},
|
2022-05-26 19:04:47 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type UpdateDecisionNotification struct {
|
2022-05-27 15:39:54 +00:00
|
|
|
Decision *models.Motion
|
|
|
|
User *models.User
|
2022-05-26 19:04:47 +00:00
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (u UpdateDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
|
2022-05-27 15:39:54 +00:00
|
|
|
voteURL := fmt.Sprintf("/vote/%s", u.Decision.Tag)
|
2022-05-26 19:04:47 +00:00
|
|
|
unvotedURL := "/motions/?unvoted=1"
|
|
|
|
|
|
|
|
return &NotificationContent{
|
|
|
|
template: "update_motion_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
*models.Motion
|
|
|
|
Name string
|
|
|
|
VoteURL string
|
|
|
|
UnvotedURL string
|
2022-05-27 18:45:04 +00:00
|
|
|
}{
|
|
|
|
Motion: u.Decision,
|
|
|
|
Name: u.User.Name,
|
|
|
|
VoteURL: voteURL,
|
|
|
|
UnvotedURL: unvotedURL,
|
|
|
|
},
|
2022-05-27 15:39:54 +00:00
|
|
|
subject: fmt.Sprintf("%s - %s", u.Decision.Tag, u.Decision.Title),
|
|
|
|
headers: motionReplyHeaders(u.Decision),
|
2022-05-26 19:04:47 +00:00
|
|
|
recipients: []recipientData{defaultRecipient(mc)},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-27 15:39:54 +00:00
|
|
|
type DirectVoteNotification struct {
|
|
|
|
Decision *models.Motion
|
|
|
|
User *models.User
|
|
|
|
Choice *models.VoteChoice
|
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (d DirectVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
|
2022-05-27 15:39:54 +00:00
|
|
|
return &NotificationContent{
|
|
|
|
template: "direct_vote_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
*models.Motion
|
|
|
|
Name string
|
|
|
|
Choice *models.VoteChoice
|
2022-05-27 18:45:04 +00:00
|
|
|
}{
|
|
|
|
Motion: d.Decision,
|
|
|
|
Name: d.User.Name,
|
|
|
|
Choice: d.Choice,
|
|
|
|
},
|
2022-05-27 15:39:54 +00:00
|
|
|
subject: fmt.Sprintf("Re: %s - %s", d.Decision.Tag, d.Decision.Title),
|
|
|
|
headers: motionReplyHeaders(d.Decision),
|
|
|
|
recipients: []recipientData{voteNoticeRecipient(mc)},
|
2022-05-26 13:27:25 +00:00
|
|
|
}
|
2022-05-21 11:51:17 +00:00
|
|
|
}
|
2022-05-27 18:45:04 +00:00
|
|
|
|
|
|
|
type ProxyVoteNotification struct {
|
|
|
|
Decision *models.Motion
|
|
|
|
User *models.User
|
|
|
|
Voter *models.User
|
|
|
|
Choice *models.VoteChoice
|
|
|
|
Justification string
|
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (p ProxyVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
|
2022-05-27 18:45:04 +00:00
|
|
|
return &NotificationContent{
|
|
|
|
template: "proxy_vote_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
*models.Motion
|
|
|
|
Name string
|
|
|
|
Voter string
|
|
|
|
Choice *models.VoteChoice
|
|
|
|
Justification string
|
|
|
|
}{
|
|
|
|
Motion: p.Decision,
|
|
|
|
Name: p.User.Name,
|
|
|
|
Voter: p.Voter.Name,
|
|
|
|
Choice: p.Choice,
|
|
|
|
Justification: p.Justification,
|
|
|
|
},
|
|
|
|
subject: fmt.Sprintf("Re: %s - %s", p.Decision.Tag, p.Decision.Title),
|
|
|
|
headers: motionReplyHeaders(p.Decision),
|
|
|
|
recipients: []recipientData{voteNoticeRecipient(mc)},
|
|
|
|
}
|
|
|
|
}
|
2022-05-29 10:01:58 +00:00
|
|
|
|
|
|
|
type WithDrawMotionNotification struct {
|
|
|
|
Motion *models.Motion
|
|
|
|
Voter *models.User
|
|
|
|
}
|
|
|
|
|
2022-10-15 17:58:58 +00:00
|
|
|
func (w WithDrawMotionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
|
2022-05-29 10:01:58 +00:00
|
|
|
return &NotificationContent{
|
|
|
|
template: "withdraw_motion_mail.txt",
|
|
|
|
data: struct {
|
|
|
|
*models.Motion
|
|
|
|
Name string
|
|
|
|
}{Motion: w.Motion, Name: w.Voter.Name},
|
|
|
|
subject: fmt.Sprintf("Re: %s - %s", w.Motion.Tag, w.Motion.Title),
|
|
|
|
headers: motionReplyHeaders(w.Motion),
|
|
|
|
recipients: []recipientData{defaultRecipient(mc)},
|
|
|
|
}
|
|
|
|
}
|