feat: refactoring of alerts and send correct email alerts

Previously the sent email alerts have been missing mandatory headers
like `Date` and it was also missing content type, content transfer
encoding and mime version. I have taken proper examples form the
unmaintained gomail library to build right emails.

Besides that I have refactored the calls for alerts, they git the same
structure now and it should be prepared to inject custom templates for
all altering methods at some later point. Generally it is prepared for a
more flexible alert handling.
This commit is contained in:
Thomas Boerger 2024-02-27 11:47:22 +01:00
parent 7c0fed0809
commit 5c8b87620e
No known key found for this signature in database
GPG Key ID: F630596501026DB5
6 changed files with 354 additions and 207 deletions

View File

@ -2,23 +2,20 @@ package tasks
import (
"bytes"
"github.com/ansible-semaphore/semaphore/lib"
"github.com/ansible-semaphore/semaphore/util"
"html/template"
"embed"
"fmt"
"net/http"
"strconv"
"strings"
"text/template"
"github.com/ansible-semaphore/semaphore/lib"
"github.com/ansible-semaphore/semaphore/util"
"github.com/ansible-semaphore/semaphore/util/mailer"
)
const emailTemplate = "Subject: Task '{{ .Name }}' failed\r\n" +
"From: {{ .From }}\r\n" +
"\r\n" +
"Task {{ .TaskID }} with template '{{ .Name }}' has failed!`\n" +
"Task Log: {{ .TaskURL }}"
const telegramTemplate = `{"chat_id": "{{ .ChatID }}","parse_mode":"HTML","text":"<code>{{ .Name }}</code>\n#{{ .TaskID }} <b>{{ .TaskResult }}</b> <code>{{ .TaskVersion }}</code> {{ .TaskDescription }}\nby {{ .Author }}\n{{ .TaskURL }}"}`
const slackTemplate = `{ "attachments": [ { "title": "Task: {{ .Name }}", "title_link": "{{ .TaskURL }}", "text": "execution ID #{{ .TaskID }}, status: {{ .TaskResult }}!", "color": "{{ .Color }}", "mrkdwn_in": ["text"], "fields": [ { "title": "Author", "value": "{{ .Author }}", "short": true }] } ]}`
//go:embed templates/*.tmpl
var templates embed.FS
const microsoftTeamsTemplate = `{
"type": "message",
@ -75,16 +72,23 @@ const microsoftTeamsTemplate = `{
// Alert represents an alert that will be templated and sent to the appropriate service
type Alert struct {
TaskID string
Name string
TaskURL string
ChatID string
TaskResult string
TaskDescription string
TaskVersion string
Author string
Color string
From string
Task alertTask
Chat alertChat
}
type alertTask struct {
ID string
URL string
Result string
Desc string
Version string
}
type alertChat struct {
ID string
}
func (t *TaskRunner) sendMailAlert() {
@ -92,45 +96,58 @@ func (t *TaskRunner) sendMailAlert() {
return
}
mailHost := util.Config.EmailHost + ":" + util.Config.EmailPort
body := bytes.NewBufferString("")
author, version := t.alertInfos()
var mailBuffer bytes.Buffer
alert := Alert{
TaskID: strconv.Itoa(t.Task.ID),
Name: t.Template.Name,
TaskURL: util.Config.WebHost + "/project/" + strconv.Itoa(t.Template.ProjectID) +
"/templates/" + strconv.Itoa(t.Template.ID) +
"?t=" + strconv.Itoa(t.Task.ID),
From: util.Config.EmailSender,
Author: author,
Color: t.alertColor("email"),
Task: alertTask{
ID: strconv.Itoa(t.Task.ID),
URL: t.taskLink(),
Result: strings.ToUpper(string(t.Task.Status)),
Version: version,
Desc: t.Task.Message,
},
}
tpl := template.New("mail body template")
tpl, err := tpl.Parse(emailTemplate)
tpl, err := template.ParseFS(templates, "templates/email.tmpl")
if err != nil {
t.Log("Can't parse email alert template!")
panic(err)
}
if err := tpl.Execute(body, alert); err != nil {
t.Log("Can't generate email alert template!")
panic(err)
}
for _, uid := range t.users {
user, err := t.pool.store.GetUser(uid)
if !user.Alert {
continue
}
if err != nil {
util.LogError(err)
t.panicOnError(tpl.Execute(&mailBuffer, alert), "Can't generate alert template!")
for _, user := range t.users {
userObj, err2 := t.pool.store.GetUser(user)
if !userObj.Alert {
continue
}
if err2 != nil {
util.LogError(err2)
continue
}
if util.Config.EmailSecure {
err2 = util.SendSecureMail(util.Config.EmailHost, util.Config.EmailPort,
util.Config.EmailSender, util.Config.EmailUsername, util.Config.EmailPassword,
userObj.Email, mailBuffer)
} else {
err2 = util.SendMail(mailHost, util.Config.EmailSender, userObj.Email, mailBuffer)
}
if err2 != nil {
util.LogError(err2)
if err := mailer.Send(
util.Config.EmailSecure,
util.Config.EmailHost,
util.Config.EmailPort,
util.Config.EmailUsername,
util.Config.EmailPassword,
util.Config.EmailSender,
user.Email,
fmt.Sprintf("Task '%s' failed", t.Template.Name),
body.String(),
); err != nil {
util.LogError(err)
}
}
}
@ -153,60 +170,45 @@ func (t *TaskRunner) sendTelegramAlert() {
return
}
var telegramBuffer bytes.Buffer
var version string
if t.Task.Version != nil {
version = *t.Task.Version
} else if t.Task.BuildTaskID != nil {
buildVer := t.Task.GetIncomingVersion(t.pool.store)
if buildVer != nil {
version = *buildVer
}
} else {
version = ""
}
var message string
if t.Task.Message != "" {
message = "- " + t.Task.Message
}
var author string
if t.Task.UserID != nil {
user, err := t.pool.store.GetUser(*t.Task.UserID)
if err != nil {
panic(err)
}
author = user.Name
}
body := bytes.NewBufferString("")
author, version := t.alertInfos()
alert := Alert{
TaskID: strconv.Itoa(t.Task.ID),
Name: t.Template.Name,
TaskURL: util.Config.WebHost + "/project/" + strconv.Itoa(t.Template.ProjectID) + "/templates/" + strconv.Itoa(t.Template.ID) + "?t=" + strconv.Itoa(t.Task.ID),
ChatID: chatID,
TaskResult: strings.ToUpper(string(t.Task.Status)),
TaskVersion: version,
TaskDescription: message,
Author: author,
Color: t.alertColor("telegram"),
Task: alertTask{
ID: strconv.Itoa(t.Task.ID),
URL: t.taskLink(),
Result: strings.ToUpper(string(t.Task.Status)),
Version: version,
Desc: t.Task.Message,
},
Chat: alertChat{
ID: chatID,
},
}
tpl := template.New("telegram body template")
tpl, err := template.ParseFS(templates, "templates/telegram.tmpl")
tpl, err := tpl.Parse(telegramTemplate)
if err != nil {
t.Log("Can't parse telegram template!")
t.Log("Can't parse telegram alert template!")
panic(err)
}
err = tpl.Execute(&telegramBuffer, alert)
if err != nil {
t.Log("Can't generate alert template!")
if err := tpl.Execute(body, alert); err != nil {
t.Log("Can't generate telegram alert template!")
panic(err)
}
resp, err := http.Post("https://api.telegram.org/bot"+util.Config.TelegramToken+"/sendMessage", "application/json", &telegramBuffer)
resp, err := http.Post(
fmt.Sprintf(
"https://api.telegram.org/bot%s/sendMessage",
util.Config.TelegramToken,
),
"application/json",
body,
)
if err != nil {
t.Log("Can't send telegram alert! Error: " + err.Error())
@ -224,11 +226,50 @@ func (t *TaskRunner) sendSlackAlert() {
return
}
slackUrl := util.Config.SlackUrl
body := bytes.NewBufferString("")
author, version := t.alertInfos()
var slackBuffer bytes.Buffer
alert := Alert{
Name: t.Template.Name,
Author: author,
Color: t.alertColor("slack"),
Task: alertTask{
ID: strconv.Itoa(t.Task.ID),
URL: t.taskLink(),
Result: strings.ToUpper(string(t.Task.Status)),
Version: version,
Desc: t.Task.Message,
},
}
tpl, err := template.ParseFS(templates, "templates/slack.tmpl")
if err != nil {
t.Log("Can't parse slack alert template!")
panic(err)
}
if err := tpl.Execute(body, alert); err != nil {
t.Log("Can't generate slack alert template!")
panic(err)
}
resp, err := http.Post(
util.Config.SlackUrl,
"application/json",
body,
)
if err != nil {
t.Log("Can't send slack alert! Error: " + err.Error())
} else if resp.StatusCode != 200 {
t.Log("Can't send slack alert! Response code: " + strconv.Itoa(resp.StatusCode))
}
}
func (t *TaskRunner) alertInfos() (string, string) {
version := ""
var version string
if t.Task.Version != nil {
version = *t.Task.Version
} else if t.Task.BuildTaskID != nil {
@ -237,65 +278,51 @@ func (t *TaskRunner) sendSlackAlert() {
version = ""
}
var message string
if t.Task.Message != "" {
message = "- " + t.Task.Message
}
author := ""
var author string
if t.Task.UserID != nil {
user, err := t.pool.store.GetUser(*t.Task.UserID)
if err != nil {
panic(err)
}
author = user.Name
}
var color string
if t.Task.Status == lib.TaskSuccessStatus {
color = "good"
} else if t.Task.Status == lib.TaskFailStatus {
color = "danger"
} else if t.Task.Status == lib.TaskRunningStatus {
color = "#333CFF"
} else if t.Task.Status == lib.TaskWaitingStatus {
color = "#FFFC33"
} else if t.Task.Status == lib.TaskStoppingStatus {
color = "#BEBEBE"
} else if t.Task.Status == lib.TaskStoppedStatus {
color = "#5B5B5B"
return version, author
}
func (t *TaskRunner) alertColor(kind string) string {
switch kind {
case "slack":
switch t.Task.Status {
case lib.TaskSuccessStatus:
return "good"
case lib.TaskFailStatus:
return "danger"
case lib.TaskRunningStatus:
return "#333CFF"
case lib.TaskWaitingStatus:
return "#FFFC33"
case lib.TaskStoppingStatus:
return "#BEBEBE"
case lib.TaskStoppedStatus:
return "#5B5B5B"
}
alert := Alert{
TaskID: strconv.Itoa(t.Task.ID),
Name: t.Template.Name,
TaskURL: util.Config.WebHost + "/project/" + strconv.Itoa(t.Template.ProjectID) + "/templates/" + strconv.Itoa(t.Template.ID) + "?t=" + strconv.Itoa(t.Task.ID),
TaskResult: strings.ToUpper(string(t.Task.Status)),
TaskVersion: version,
TaskDescription: message,
Author: author,
Color: color,
}
tpl := template.New("slack body template")
return ""
}
tpl, err := tpl.Parse(slackTemplate)
if err != nil {
t.Log("Can't parse slack template!")
panic(err)
}
err = tpl.Execute(&slackBuffer, alert)
if err != nil {
t.Log("Can't generate alert template!")
panic(err)
}
resp, err := http.Post(slackUrl, "application/json", &slackBuffer)
if err != nil {
t.Log("Can't send slack alert! Error: " + err.Error())
} else if resp.StatusCode != 200 {
t.Log("Can't send slack alert! Response code: " + strconv.Itoa(resp.StatusCode))
}
func (t *TaskRunner) taskLink() string {
return fmt.Sprintf(
"%s/project/%d/templates/%d?t=%d",
util.Config.WebHost,
t.Template.ProjectID,
t.Template.ID,
t.Task.ID,
)
}
func (t *TaskRunner) sendMicrosoftTeamsAlert() {

View File

@ -0,0 +1,2 @@
Task {{ .Task.ID }} with template '{{ .Name }}' has failed!
Task Log: {{ .Task.URL }}

View File

@ -0,0 +1,20 @@
{
"attachments": [
{
"title": "Task: {{ .Name }}",
"title_link": "{{ .Task.URL }}",
"text": "execution #{{ .Task.ID }}, status: {{ .Task.Result }}!",
"color": "{{ .Color }}",
"mrkdwn_in": [
"text"
],
"fields": [
{
"title": "Author",
"value": "{{ .Author }}",
"short": true
}
]
}
]
}

View File

@ -0,0 +1,5 @@
{
"chat_id": "{{ .Chat.ID }}",
"parse_mode": "HTML",
"text": "<code>{{ .Name }}</code>\n#{{ .Task.ID }} <b>{{ .Task.Result }}</b> <code>{{ .Task.Version }}</code> - {{ .Task.Desc }}\nby {{ .Author }}\n{{ .Task.URL }}"
}

View File

@ -1,67 +0,0 @@
package util
import (
"bytes"
log "github.com/sirupsen/logrus"
"io"
"net/smtp"
)
// SendMail dispatches a mail using smtp
func SendMail(emailHost, mailSender, mailRecipient string, mail bytes.Buffer) error {
c, err := smtp.Dial(emailHost)
if err != nil {
return err
}
defer func(c *smtp.Client) {
err = c.Close()
if err != nil {
log.Error(err)
}
}(c)
// Set the sender and recipient.
err = c.Mail(mailSender)
if err != nil {
return err
}
err = c.Rcpt(mailRecipient)
if err != nil {
return err
}
// Send the email body.
wc, err := c.Data()
if err != nil {
return err
}
defer func(wc io.WriteCloser) {
err = wc.Close()
if err != nil {
log.Error(err)
}
}(wc)
_, err = mail.WriteTo(wc)
return err
}
// SendSecureMail dispatches a mail using smtp with authentication and StartTLS
func SendSecureMail(emailHost, emailPort, mailSender, mailUsername, mailPassword, mailRecipient string, mail bytes.Buffer) error {
// Receiver email address.
to := []string{
mailRecipient,
}
// Authentication.
auth := smtp.PlainAuth("", mailUsername, mailPassword, emailHost)
// Sending email.
err := smtp.SendMail(emailHost+":"+emailPort, auth, mailSender, to, mail.Bytes())
if err != nil {
log.Error(err)
}
return err
}

160
util/mailer/mailer.go Normal file
View File

@ -0,0 +1,160 @@
package mailer
import (
"bytes"
"net"
"net/smtp"
"strings"
"text/template"
"time"
)
const (
mailerBase = "MIME-version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"Content-Transfer-Encoding: quoted-printable\r\n" +
"Date: {{ .Date }}\r\n" +
"To: {{ .To }}\r\n" +
"From: {{ .From }}\r\n" +
"Subject: {{ .Subject }}\r\n\r\n" +
"{{ .Body }}"
)
var (
r = strings.NewReplacer(
"\r\n", "",
"\r", "",
"\n", "",
"%0a", "",
"%0d", "",
)
)
// Send simply sends the defined mail via SMTP.
func Send(
secure bool,
host string,
port string,
username,
password,
from,
to,
subject string,
content string,
) error {
body := bytes.NewBufferString("")
tpl, err := template.New("").Parse(mailerBase)
if err != nil {
return err
}
if err := tpl.Execute(body, struct {
Date string
To string
From string
Subject string
Body string
}{
Date: time.Now().UTC().Format(time.RFC1123),
To: r.Replace(to),
From: r.Replace(from),
Subject: r.Replace(subject),
Body: content,
}); err != nil {
return err
}
if secure {
return plainauth(
host,
port,
username,
password,
from,
to,
body,
)
}
return anonymous(
host,
port,
from,
to,
body,
)
}
func plainauth(
host string,
port string,
username string,
password string,
from string,
to string,
body *bytes.Buffer,
) error {
return smtp.SendMail(
net.JoinHostPort(
host,
port,
),
smtp.PlainAuth(
"",
username,
password,
host,
),
from,
[]string{to},
body.Bytes(),
)
}
func anonymous(
host string,
port string,
from string,
to string,
body *bytes.Buffer,
) error {
c, err := smtp.Dial(
net.JoinHostPort(
host,
port,
),
)
if err != nil {
return err
}
defer c.Close()
if err := c.Mail(
r.Replace(from),
); err != nil {
return err
}
if err = c.Rcpt(
r.Replace(to),
); err != nil {
return err
}
w, err := c.Data()
if err != nil {
return err
}
defer w.Close()
if _, err := body.WriteTo(w); err != nil {
return err
}
return nil
}