github.com/justinjmoses/evergreen@v0.0.0-20170530173719-1d50e381ff0d/alerts/email.go (about) 1 package alerts 2 3 import ( 4 "bytes" 5 "crypto/tls" 6 "encoding/base64" 7 "fmt" 8 "net/mail" 9 "net/smtp" 10 "strings" 11 12 "github.com/evergreen-ci/evergreen" 13 "github.com/evergreen-ci/evergreen/model" 14 "github.com/evergreen-ci/evergreen/model/alertrecord" 15 "github.com/evergreen-ci/evergreen/model/task" 16 "github.com/evergreen-ci/render" 17 "github.com/mongodb/grip" 18 "github.com/pkg/errors" 19 ) 20 21 const EmailSubjectPrologue = "[Evergreen]" 22 23 type SMTPSettings struct { 24 From string 25 Server string 26 Port int 27 UseSSL bool 28 Username string 29 Password string 30 } 31 32 // EmailDeliverer is an implementation of Deliverer that sends notifications to an SMTP server 33 type EmailDeliverer struct { 34 SMTPSettings 35 render *render.Render 36 } 37 38 func (es *EmailDeliverer) Deliver(alertCtx AlertContext, alertConf model.AlertConfig) error { 39 rcptRaw, ok := alertConf.Settings["recipient"] 40 if !ok { 41 return errors.New("missing email address") 42 } 43 grip.Infof("Sending email to %v", rcptRaw) 44 45 var rcpt string 46 if rcpt, ok = rcptRaw.(string); !ok { 47 return errors.New("email address must be a string") 48 } 49 50 var err error 51 subject := getSubject(alertCtx) 52 body, err := es.getBody(alertCtx) 53 if err != nil { 54 return err 55 } 56 57 var c *smtp.Client 58 var tlsCon *tls.Conn 59 if es.UseSSL { 60 tlsCon, err = tls.Dial("tcp", fmt.Sprintf("%v:%v", es.Server, es.Port), &tls.Config{}) 61 if err != nil { 62 return err 63 } 64 c, err = smtp.NewClient(tlsCon, es.Server) 65 if err != nil { 66 return err 67 } 68 } else { 69 c, err = smtp.Dial(fmt.Sprintf("%v:%v", es.Server, es.Port)) 70 } 71 72 if err != nil { 73 return err 74 } 75 76 if es.Username != "" { 77 err = c.Auth(smtp.PlainAuth("", es.Username, es.Password, es.Server)) 78 if err != nil { 79 return err 80 } 81 } 82 83 // Set the sender 84 from := mail.Address{ 85 Name: "Evergreen Alerts", 86 Address: es.From, 87 } 88 err = c.Mail(es.From) 89 if err != nil { 90 grip.Errorf("Error establishing mail sender (%s): %+v", es.From, err) 91 return errors.WithStack(err) 92 } 93 94 err = c.Rcpt(rcpt) 95 if err != nil { 96 grip.Errorf("Error establishing mail recipient (%s): %+v", rcpt, err) 97 return errors.WithStack(err) 98 } 99 100 // Send the email body. 101 wc, err := c.Data() 102 if err != nil { 103 return errors.WithStack(err) 104 } 105 defer wc.Close() 106 107 // set header information 108 header := make(map[string]string) 109 header["From"] = from.String() 110 header["To"] = rcpt 111 header["Subject"] = subject 112 header["MIME-Version"] = "1.0" 113 header["Content-Type"] = "text/html; charset=\"utf-8\"" 114 header["Content-Transfer-Encoding"] = "base64" 115 116 message := "" 117 for k, v := range header { 118 message += fmt.Sprintf("%s: %s\r\n", k, v) 119 } 120 121 message += "\r\n" + base64.StdEncoding.EncodeToString([]byte(body)) 122 123 // write the body 124 buf := bytes.NewBufferString(message) 125 if _, err = buf.WriteTo(wc); err != nil { 126 return errors.WithStack(err) 127 } 128 129 return nil 130 } 131 132 func getTemplate(alertCtx AlertContext) string { 133 switch alertCtx.AlertRequest.Trigger { 134 case alertrecord.SpawnHostTwoHourWarning: 135 fallthrough 136 case alertrecord.SpawnHostTwelveHourWarning: 137 return "email/host_spawn.html" 138 default: 139 return "email/task_fail.html" 140 } 141 } 142 143 // getBody executes a template with the alert data and returns the body of the notification 144 // e-mail to be sent as an HTML string. 145 func (es *EmailDeliverer) getBody(alertCtx AlertContext) (string, error) { 146 out := &bytes.Buffer{} 147 template := getTemplate(alertCtx) 148 err := es.render.HTML(out, alertCtx, "content", template) 149 if err != nil { 150 return "", errors.WithStack(err) 151 } 152 return out.String(), nil 153 } 154 155 // taskFailureSubject creates an email subject for a task failure in the style of 156 // Test Failures: Task_name on Variant (test1, test2) // ProjectName @ githash 157 // based on the given AlertContext. 158 func taskFailureSubject(ctx AlertContext) string { 159 subj := &bytes.Buffer{} 160 failed := []string{} 161 for _, test := range ctx.Task.TestResults { 162 if test.Status == evergreen.TestFailedStatus { 163 failed = append(failed, cleanTestName(test.TestFile)) 164 } 165 } 166 167 switch { 168 case ctx.Task.Details.Description == task.AgentHeartbeat: 169 subj.WriteString("Task System Failure: ") 170 case ctx.Task.Details.Type == model.SystemCommandType: 171 subj.WriteString("Task System Failure: ") 172 case ctx.Task.Details.TimedOut: 173 subj.WriteString("Task Timed Out: ") 174 case len(failed) == 1: 175 subj.WriteString("Test Failure: ") 176 case len(failed) > 1: 177 subj.WriteString("Test Failures: ") 178 default: 179 subj.WriteString("Task Failed: ") 180 } 181 182 fmt.Fprintf(subj, "%s on %s ", ctx.Task.DisplayName, ctx.Build.DisplayName) 183 184 // include test names if <= 4 failed, otherwise print two plus the number remaining 185 if len(failed) > 0 { 186 subj.WriteString("(") 187 if len(failed) <= 4 { 188 subj.WriteString(strings.Join(failed, ", ")) 189 } else { 190 fmt.Fprintf(subj, "%s, %s, +%v more", failed[0], failed[1], len(failed)-2) 191 } 192 subj.WriteString(") ") 193 } 194 195 fmt.Fprintf(subj, "// %s @ %s", ctx.ProjectRef.DisplayName, ctx.Version.Revision[0:8]) 196 return subj.String() 197 } 198 199 // getSubject generates a subject line for an e-mail for the given alert. 200 func getSubject(alertCtx AlertContext) string { 201 switch alertCtx.AlertRequest.Trigger { 202 case alertrecord.FirstVersionFailureId: 203 return fmt.Sprintf("First Task Failure: %s on %s // %s @ %s", 204 alertCtx.Task.DisplayName, 205 alertCtx.Build.DisplayName, 206 alertCtx.ProjectRef.DisplayName, 207 alertCtx.Version.Revision[0:8]) 208 case alertrecord.FirstVariantFailureId: 209 return fmt.Sprintf("Variant Failure: %s // %s @ %s", 210 alertCtx.Build.DisplayName, 211 alertCtx.ProjectRef.DisplayName, 212 alertCtx.Version.Revision[0:8], 213 ) 214 case alertrecord.SpawnHostTwoHourWarning: 215 return fmt.Sprintf("Your %s host (%s) will expire in two hours.", 216 alertCtx.Host.Distro, alertCtx.Host.Id) 217 case alertrecord.SpawnHostTwelveHourWarning: 218 return fmt.Sprintf("Your %s host (%s) will expire in twelve hours.", 219 alertCtx.Host.Distro, alertCtx.Host.Id) 220 // TODO(EVG-224) alertrecord.SpawnHostExpired: 221 } 222 return taskFailureSubject(alertCtx) 223 } 224 225 // cleanTestName returns the last item of a test's path. 226 // TODO: stop accommodating this. 227 func cleanTestName(path string) string { 228 if unixIdx := strings.LastIndex(path, "/"); unixIdx != -1 { 229 // if the path ends in a slash, remove it and try again 230 if unixIdx == len(path)-1 { 231 return cleanTestName(path[:len(path)-1]) 232 } 233 return path[unixIdx+1:] 234 } 235 if windowsIdx := strings.LastIndex(path, `\`); windowsIdx != -1 { 236 // if the path ends in a slash, remove it and try again 237 if windowsIdx == len(path)-1 { 238 return cleanTestName(path[:len(path)-1]) 239 } 240 return path[windowsIdx+1:] 241 } 242 return path 243 }