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  }