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 }