go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luci_notify/mailtmpl/bundle.go (about)

     1  // Copyright 2019 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package mailtmpl implements email template bundling and execution.
    16  package mailtmpl
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"fmt"
    22  	html "html/template"
    23  	"strings"
    24  	text "text/template"
    25  	"time"
    26  
    27  	"github.com/russross/blackfriday/v2"
    28  	"google.golang.org/protobuf/types/known/timestamppb"
    29  
    30  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    31  	"go.chromium.org/luci/buildbucket/protoutil"
    32  	"go.chromium.org/luci/common/data/text/sanitizehtml"
    33  	"go.chromium.org/luci/common/errors"
    34  	"go.chromium.org/luci/common/logging"
    35  
    36  	"go.chromium.org/luci/luci_notify/api/config"
    37  )
    38  
    39  const (
    40  	// FileExt is a file extension of template files.
    41  	FileExt = ".template"
    42  
    43  	// DefaultTemplateName of the default template.
    44  	DefaultTemplateName = "default"
    45  )
    46  
    47  // Funcs is functions available to email subject and body templates.
    48  var Funcs = map[string]any{
    49  	"time": func(ts *timestamppb.Timestamp) time.Time {
    50  		t := ts.AsTime()
    51  		return t
    52  	},
    53  
    54  	"formatBuilderID": protoutil.FormatBuilderID,
    55  
    56  	// markdown renders the given text as markdown HTML.
    57  	//
    58  	// This uses blackfriday to convert from markdown to HTML,
    59  	// and sanitizehtml to allow only a small subset of HTML through.
    60  	"markdown": func(inputMD string) html.HTML {
    61  		// We don't want auto punctuation, which changes "foo" into “foo”
    62  		r := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{
    63  			Flags: blackfriday.UseXHTML,
    64  		})
    65  		untrusted := blackfriday.Run(
    66  			[]byte(inputMD),
    67  			blackfriday.WithRenderer(r),
    68  			blackfriday.WithExtensions(
    69  				blackfriday.NoIntraEmphasis|
    70  					blackfriday.FencedCode|
    71  					blackfriday.Autolink,
    72  				// TODO(tandrii): support Tables, which are currently sanitized away by
    73  				// sanitizehtml.Sanitize.
    74  			))
    75  		out := bytes.NewBuffer(nil)
    76  		if err := sanitizehtml.Sanitize(out, bytes.NewReader(untrusted)); err != nil {
    77  			return html.HTML(fmt.Sprintf("Failed to render markdown: %s", html.HTMLEscapeString(err.Error())))
    78  		}
    79  		return html.HTML(out.String())
    80  	},
    81  
    82  	"stepNames": func(steps []*buildbucketpb.Step) string {
    83  		var sb strings.Builder
    84  		for i, step := range steps {
    85  			if i != 0 {
    86  				sb.WriteString(", ")
    87  			}
    88  			fmt.Fprintf(&sb, "%q", step.Name)
    89  		}
    90  
    91  		return sb.String()
    92  	},
    93  
    94  	"buildUrl": func(input *config.TemplateInput) string {
    95  		return fmt.Sprintf("https://%s/build/%d",
    96  			input.BuildbucketHostname, input.Build.Id)
    97  	},
    98  }
    99  
   100  // Template is an email template.
   101  // To render it, use NewBundle.
   102  type Template struct {
   103  	// Name identifies the email template. It is unique within a bundle.
   104  	Name string
   105  
   106  	// SubjectTextTemplate is a text.Template of the email subject.
   107  	// See Funcs for available functions.
   108  	SubjectTextTemplate string
   109  
   110  	// BodyHTMLTemplate is a html.Template of the email body.
   111  	// See Funcs for available functions.
   112  	BodyHTMLTemplate string
   113  
   114  	// URL to the template definition.
   115  	// Will be used in template error reports.
   116  	DefinitionURL string
   117  }
   118  
   119  // Bundle is a collection of email templates bundled together, so they
   120  // can use each other.
   121  type Bundle struct {
   122  	// Error found among templates.
   123  	// If non-nil, GenerateEmail will generate error emails.
   124  	Err error
   125  
   126  	templates map[string]*Template
   127  	subjects  *text.Template
   128  	bodies    *html.Template
   129  }
   130  
   131  // NewBundle bundles templates together and makes them renderable.
   132  // If templates do not have a template "default", bundles in one.
   133  // May return a bundle with an non-nil Err.
   134  func NewBundle(templates []*Template) *Bundle {
   135  	b := &Bundle{
   136  		subjects:  text.New("").Funcs(Funcs),
   137  		bodies:    html.New("").Funcs(Funcs),
   138  		templates: make(map[string]*Template, len(templates)+1),
   139  	}
   140  
   141  	addTemplate := func(t *Template) error {
   142  		if _, err := b.subjects.New(t.Name).Parse(t.SubjectTextTemplate); err != nil {
   143  			return err
   144  		}
   145  
   146  		_, err := b.bodies.New(t.Name).Parse(t.BodyHTMLTemplate)
   147  		return err
   148  	}
   149  
   150  	var errs errors.MultiError
   151  
   152  	hasDefault := false
   153  	for _, t := range templates {
   154  		if _, ok := b.templates[t.Name]; ok {
   155  			errs = append(errs, fmt.Errorf("duplicate template %q", t.Name))
   156  		}
   157  		b.templates[t.Name] = t
   158  
   159  		if t.Name == DefaultTemplateName {
   160  			hasDefault = true
   161  		}
   162  		if err := addTemplate(t); err != nil {
   163  			errs = append(errs, errors.Annotate(addTemplate(t), "template %q", t.Name).Err())
   164  		}
   165  	}
   166  
   167  	if !hasDefault {
   168  		if err := addTemplate(defaultTemplate); err != nil {
   169  			panic(err)
   170  		}
   171  	}
   172  
   173  	if len(errs) > 0 {
   174  		b.Err = errs
   175  	}
   176  
   177  	return b
   178  }
   179  
   180  // GenerateEmail generates an email using the named template. If the template
   181  // fails, an error template is used, which includes error details and a link to
   182  // the definition of the failed template.
   183  func (b *Bundle) GenerateEmail(templateName string, input *config.TemplateInput) (subject, body string) {
   184  	var err error
   185  	if subject, body, err = b.executeUserTemplate(templateName, input); err != nil {
   186  		// Execution of the user-defined template failed.
   187  		// Fallback to the error template.
   188  		subject, body = b.generateErrorEmail(templateName, input, err)
   189  	}
   190  	return
   191  }
   192  
   193  // GenerateStatusMessage generates a message to be posted to a tree status instance.
   194  // If the template fails, a default template is used.
   195  func (b *Bundle) GenerateStatusMessage(c context.Context, templateName string, input *config.TemplateInput) (message string) {
   196  	var err error
   197  	if message, _, err = b.executeUserTemplate(templateName, input); err != nil {
   198  		logging.Errorf(c, "Template %q failed to render: %s", templateName, err)
   199  		message = generateDefaultStatusMessage(input)
   200  	}
   201  	return
   202  }
   203  
   204  // executeUserTemplate executed a user-defined template.
   205  func (b *Bundle) executeUserTemplate(templateName string, input *config.TemplateInput) (subject, body string, err error) {
   206  	var buf bytes.Buffer
   207  	if err = b.subjects.ExecuteTemplate(&buf, templateName, input); err != nil {
   208  		return
   209  	}
   210  	subject = buf.String()
   211  
   212  	buf.Reset()
   213  	if err = b.bodies.ExecuteTemplate(&buf, templateName, input); err != nil {
   214  		return
   215  	}
   216  	body = buf.String()
   217  	return
   218  }
   219  
   220  // generateErrorEmail generates a spartan email that contains information
   221  // about an error during execution of a user-defined template.
   222  func (b *Bundle) generateErrorEmail(templateName string, input *config.TemplateInput, err error) (subject, body string) {
   223  	subject = fmt.Sprintf(`[Build Status] Builder %q`, protoutil.FormatBuilderID(input.Build.Builder))
   224  
   225  	errorTemplateInput := map[string]any{
   226  		"Build":               input.Build,
   227  		"BuildbucketHostname": input.BuildbucketHostname,
   228  		"TemplateName":        templateName,
   229  		"TemplateURL":         "",
   230  		"Error":               err.Error(),
   231  	}
   232  	if t := b.templates[templateName]; t != nil {
   233  		errorTemplateInput["TemplateURL"] = t.DefinitionURL
   234  	}
   235  
   236  	var buf bytes.Buffer
   237  	if err := errorBodyTemplate.Execute(&buf, errorTemplateInput); err != nil {
   238  		// Error template MAY NOT fail.
   239  		panic(errors.Annotate(err, "execution of the error template has failed").Err())
   240  	}
   241  	body = buf.String()
   242  	return
   243  }
   244  
   245  const defaultStatusTemplateStr = "{{ stepNames .MatchingFailedSteps }} on {{ buildUrl . }} {{ .Build.Builder.Builder }}{{ if .Build.Input.GitilesCommit }} from {{ .Build.Input.GitilesCommit.Id }}{{end}}"
   246  
   247  var defaultStatusTemplate *text.Template = text.Must(text.New("").Funcs(Funcs).Parse(defaultStatusTemplateStr))
   248  
   249  func generateDefaultStatusMessage(input *config.TemplateInput) string {
   250  	var buf bytes.Buffer
   251  	if err := defaultStatusTemplate.Execute(&buf, input); err != nil {
   252  		panic(errors.Annotate(err, "execution of the default status message template has failed").Err())
   253  	}
   254  
   255  	return buf.String()
   256  }
   257  
   258  // SplitTemplateFile splits an email template file into subject and body.
   259  // Does not validate their syntaxes.
   260  // See notify.proto for file format.
   261  func SplitTemplateFile(content string) (subject, body string, err error) {
   262  	if len(content) == 0 {
   263  		return "", "", fmt.Errorf("empty file")
   264  	}
   265  
   266  	parts := strings.SplitN(content, "\n", 3)
   267  	switch {
   268  	case len(parts) == 1:
   269  		return strings.TrimSpace(parts[0]), "", nil
   270  
   271  	case len(strings.TrimSpace(parts[1])) > 0:
   272  		return "", "", fmt.Errorf("second line is not blank: %q", parts[1])
   273  
   274  	case len(parts) == 2:
   275  		// In this case the second line must be blank, because of the
   276  		// check above, so we're just dropping the blank line.
   277  		return strings.TrimSpace(parts[0]), "", nil
   278  
   279  	default:
   280  		return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[2]), nil
   281  	}
   282  }