smtp/main.go (184 lines of code) (raw):
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bytes"
"context"
"fmt"
htmlTemplate "html/template"
textTemplate "text/template"
"mime/quotedprintable"
"net/smtp"
"strings"
cbpb "cloud.google.com/go/cloudbuild/apiv1/v2/cloudbuildpb"
"github.com/GoogleCloudPlatform/cloud-build-notifiers/lib/notifiers"
log "github.com/golang/glog"
"google.golang.org/protobuf/encoding/prototext"
)
const (
contentType = "text/html"
)
func main() {
if err := notifiers.Main(new(smtpNotifier)); err != nil {
log.Fatalf("fatal error: %v", err)
}
}
type smtpNotifier struct {
filter notifiers.EventFilter
htmlTmpl *htmlTemplate.Template
textTmpl *textTemplate.Template
mcfg mailConfig
br notifiers.BindingResolver
tmplView *notifiers.TemplateView
}
type mailConfig struct {
server, port, sender, from, password, subject string
recipients []string
}
func (s *smtpNotifier) SetUp(ctx context.Context, cfg *notifiers.Config, cfgTemplate string, sg notifiers.SecretGetter, br notifiers.BindingResolver) error {
prd, err := notifiers.MakeCELPredicate(cfg.Spec.Notification.Filter)
if err != nil {
return fmt.Errorf("failed to create CELPredicate: %w", err)
}
s.filter = prd
htmlTmpl, err := htmlTemplate.New("email_template").Parse(cfgTemplate)
if err != nil {
return fmt.Errorf("failed to parse HTML email template: %w", err)
}
s.htmlTmpl = htmlTmpl
if subject, subjectFound := cfg.Spec.Notification.Delivery["subject"]; subjectFound {
textTmpl, err := textTemplate.New("subject_template").Parse(subject.(string))
if err != nil {
return fmt.Errorf("failed to parse TEXT subject template: %w", err)
}
s.textTmpl = textTmpl
}
mcfg, err := getMailConfig(ctx, sg, cfg.Spec)
if err != nil {
return fmt.Errorf("failed to construct a mail delivery config: %w", err)
}
s.mcfg = mcfg
s.br = br
return nil
}
func getMailConfig(ctx context.Context, sg notifiers.SecretGetter, spec *notifiers.Spec) (mailConfig, error) {
delivery := spec.Notification.Delivery
server, ok := delivery["server"].(string)
if !ok {
return mailConfig{}, fmt.Errorf("expected delivery config %v to have string field `server`", delivery)
}
port, ok := delivery["port"].(string)
if !ok {
return mailConfig{}, fmt.Errorf("expected delivery config %v to have string field `port`", delivery)
}
sender, ok := delivery["sender"].(string)
if !ok {
return mailConfig{}, fmt.Errorf("expected delivery config %v to have string field `sender`", delivery)
}
from, ok := delivery["from"].(string)
if !ok {
return mailConfig{}, fmt.Errorf("expected delivery config %v to have string field `from`", delivery)
}
ris, ok := delivery["recipients"].([]interface{})
if !ok {
return mailConfig{}, fmt.Errorf("expected delivery config %v to have repeated field `recipients`", delivery)
}
recipients := make([]string, 0, len(ris))
for _, ri := range ris {
r, ok := ri.(string)
if !ok {
return mailConfig{}, fmt.Errorf("failed to convert recipient (%v) into a string", ri)
}
recipients = append(recipients, r)
}
passwordRef, err := notifiers.GetSecretRef(delivery, "password")
if err != nil {
return mailConfig{}, fmt.Errorf("failed to get ref for secret field `password`: %w", err)
}
passwordResource, err := notifiers.FindSecretResourceName(spec.Secrets, passwordRef)
if err != nil {
return mailConfig{}, fmt.Errorf("failed to find Secret resource name for reference %q: %w", passwordRef, err)
}
password, err := sg.GetSecret(ctx, passwordResource)
if err != nil {
return mailConfig{}, fmt.Errorf("failed to get SMTP password: %w", err)
}
return mailConfig{
server: server,
port: port,
sender: sender,
from: from,
password: password,
recipients: recipients,
}, nil
}
func (s *smtpNotifier) SendNotification(ctx context.Context, build *cbpb.Build) error {
if !s.filter.Apply(ctx, build) {
log.V(2).Infof("no mail for event:\n%s", prototext.Format(build))
return nil
}
bindings, err := s.br.Resolve(ctx, nil, build)
if err != nil {
log.Errorf("failed to resolve bindings :%v", err)
}
s.tmplView = ¬ifiers.TemplateView{
Build: ¬ifiers.BuildView{Build: build},
Params: bindings,
}
log.Infof("sending email for (build id = %q, status = %s)", build.GetId(), build.GetStatus())
return s.sendSMTPNotification()
}
func (s *smtpNotifier) sendSMTPNotification() error {
email, err := s.buildEmail()
if err != nil {
log.Warningf("failed to build email: %v", err)
}
addr := fmt.Sprintf("%s:%s", s.mcfg.server, s.mcfg.port)
auth := smtp.PlainAuth("", s.mcfg.sender, s.mcfg.password, s.mcfg.server)
if err = smtp.SendMail(addr, auth, s.mcfg.from, s.mcfg.recipients, []byte(email)); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
log.V(2).Infoln("email sent successfully")
return nil
}
func (s *smtpNotifier) buildEmail() (string, error) {
build := s.tmplView.Build
logURL, err := notifiers.AddUTMParams(s.tmplView.Build.LogUrl, notifiers.EmailMedium)
if err != nil {
return "", fmt.Errorf("failed to add UTM params: %w", err)
}
build.LogUrl = logURL
body := new(bytes.Buffer)
if err := s.htmlTmpl.Execute(body, s.tmplView); err != nil {
return "", err
}
subject := fmt.Sprintf("Cloud Build [%s]: %s", build.ProjectId, build.Id)
if s.textTmpl != nil {
subjectTmpl := new(bytes.Buffer)
if err := s.textTmpl.Execute(subjectTmpl, s.tmplView); err != nil {
return "", err
}
// Escape any string formatter
subject = strings.Join(strings.Fields(subjectTmpl.String()), " ")
}
header := make(map[string]string)
if s.mcfg.from != s.mcfg.sender {
header["Sender"] = s.mcfg.sender
}
header["From"] = s.mcfg.from
header["To"] = strings.Join(s.mcfg.recipients, ",")
header["Subject"] = subject
header["MIME-Version"] = "1.0"
header["Content-Type"] = fmt.Sprintf(`%s; charset="utf-8"`, contentType)
header["Content-Transfer-Encoding"] = "quoted-printable"
header["Content-Disposition"] = "inline"
var msg string
for key, value := range header {
msg += fmt.Sprintf("%s: %s\r\n", key, value)
}
encoded := new(bytes.Buffer)
finalMsg := quotedprintable.NewWriter(encoded)
finalMsg.Write(body.Bytes())
if err := finalMsg.Close(); err != nil {
return "", fmt.Errorf("failed to close MIME writer: %w", err)
}
msg += "\r\n" + encoded.String()
return msg, nil
}