diff --git a/services/tasks/alert.go b/services/tasks/alert.go index 25f98199..9e27c83a 100644 --- a/services/tasks/alert.go +++ b/services/tasks/alert.go @@ -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":"{{ .Name }}\n#{{ .TaskID }} {{ .TaskResult }} {{ .TaskVersion }} {{ .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 + Name string + Author string + Color 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) - util.LogError(err) - t.panicOnError(tpl.Execute(&mailBuffer, alert), "Can't generate alert template!") + tpl, err := template.ParseFS(templates, "templates/email.tmpl") - for _, user := range t.users { - userObj, err2 := t.pool.store.GetUser(user) + if err != nil { + t.Log("Can't parse email alert template!") + panic(err) + } - if !userObj.Alert { + 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 err2 != nil { - util.LogError(err2) + if err != nil { + util.LogError(err) 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, + Name: t.Template.Name, + 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" - } - 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, + 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" + } } - 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() { diff --git a/services/tasks/templates/email.tmpl b/services/tasks/templates/email.tmpl new file mode 100644 index 00000000..850c97a4 --- /dev/null +++ b/services/tasks/templates/email.tmpl @@ -0,0 +1,2 @@ +Task {{ .Task.ID }} with template '{{ .Name }}' has failed! +Task Log: {{ .Task.URL }} diff --git a/services/tasks/templates/slack.tmpl b/services/tasks/templates/slack.tmpl new file mode 100644 index 00000000..18afc956 --- /dev/null +++ b/services/tasks/templates/slack.tmpl @@ -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 + } + ] + } + ] +} diff --git a/services/tasks/templates/telegram.tmpl b/services/tasks/templates/telegram.tmpl new file mode 100644 index 00000000..33d50d90 --- /dev/null +++ b/services/tasks/templates/telegram.tmpl @@ -0,0 +1,5 @@ +{ + "chat_id": "{{ .Chat.ID }}", + "parse_mode": "HTML", + "text": "{{ .Name }}\n#{{ .Task.ID }} {{ .Task.Result }} {{ .Task.Version }} - {{ .Task.Desc }}\nby {{ .Author }}\n{{ .Task.URL }}" +} diff --git a/util/mail.go b/util/mail.go deleted file mode 100644 index 64bb3447..00000000 --- a/util/mail.go +++ /dev/null @@ -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 -} diff --git a/util/mailer/mailer.go b/util/mailer/mailer.go new file mode 100644 index 00000000..03cc9886 --- /dev/null +++ b/util/mailer/mailer.go @@ -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 +}