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