github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/dashboard/app/reporting_email.go (about) 1 // Copyright 2017 syzkaller project authors. All rights reserved. 2 // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. 3 4 package main 5 6 import ( 7 "bytes" 8 "context" 9 "encoding/json" 10 "errors" 11 "fmt" 12 "io" 13 "maps" 14 "net/http" 15 "net/mail" 16 "regexp" 17 "slices" 18 "sort" 19 "strconv" 20 "strings" 21 "sync" 22 "text/tabwriter" 23 "time" 24 25 "cloud.google.com/go/civil" 26 "github.com/google/syzkaller/dashboard/dashapi" 27 "github.com/google/syzkaller/pkg/cover" 28 "github.com/google/syzkaller/pkg/coveragedb" 29 "github.com/google/syzkaller/pkg/email" 30 "github.com/google/syzkaller/pkg/email/lore" 31 "github.com/google/syzkaller/pkg/html" 32 "github.com/google/syzkaller/sys/targets" 33 "google.golang.org/appengine/v2" 34 db "google.golang.org/appengine/v2/datastore" 35 "google.golang.org/appengine/v2/log" 36 aemail "google.golang.org/appengine/v2/mail" 37 ) 38 39 // Email reporting interface. 40 41 func initEmailReporting() { 42 http.HandleFunc("/cron/email_coverage_reports", handleCoverageReports) 43 http.HandleFunc("/cron/email_poll", handleEmailPoll) 44 http.HandleFunc("/_ah/mail/", handleIncomingMail) 45 http.HandleFunc("/_ah/bounce", handleEmailBounce) 46 47 mailingLists = make(map[string]bool) 48 for _, cfg := range getConfig(context.Background()).Namespaces { 49 for _, reporting := range cfg.Reporting { 50 if cfg, ok := reporting.Config.(*EmailConfig); ok { 51 mailingLists[email.CanonicalEmail(cfg.Email)] = true 52 } 53 } 54 } 55 } 56 57 const ( 58 emailType = "email" 59 // This plays an important role at least for job replies. 60 // If we CC a kernel mailing list and it uses Patchwork, 61 // then any emails with a patch attached create a new patch 62 // entry pending for review. The prefix makes Patchwork 63 // treat it as a comment for a previous patch. 64 replySubjectPrefix = "Re: " 65 66 replyNoBugID = "I see the command but can't find the corresponding bug.\n" + 67 "Please resend the email to %[1]v address\n" + 68 "that is the sender of the bug report (also present in the Reported-by tag)." 69 replyAmbiguousBugID = "I see the command, but I cannot identify the bug that was meant.\n" + 70 "Several bugs with the exact same title were earlier sent to the mailing list.\n" + 71 "Please resend the email to %[1]v address\n" + 72 "that is the sender of the original bug report (also present in the Reported-by tag)." 73 replyBadBugID = "I see the command but can't find the corresponding bug.\n" + 74 "The email is sent to %[1]v address\n" + 75 "but the HASH does not correspond to any known bug.\n" + 76 "Please double check the address." 77 replyMalformedSyzTest = "I've failed to parse your command.\n" + 78 "Did you perhaps forget to provide the branch name, or added an extra ':'?\n" + 79 "Please use one of the two supported formats:\n" + 80 "1. #syz test\n" + 81 "2. #syz test: repo branch-or-commit-hash\n" + 82 "Note the lack of ':' in option 1." 83 ) 84 85 var mailingLists map[string]bool 86 87 type EmailConfig struct { 88 Email string 89 HandleListEmails bool // This is a temporary option to simplify the feature deployment. 90 MailMaintainers bool 91 DefaultMaintainers []string 92 SubjectPrefix string 93 } 94 95 func (cfg *EmailConfig) Type() string { 96 return emailType 97 } 98 99 func (cfg *EmailConfig) Validate() error { 100 if _, err := mail.ParseAddress(cfg.Email); err != nil { 101 return fmt.Errorf("bad email address %q: %w", cfg.Email, err) 102 } 103 for _, email := range cfg.DefaultMaintainers { 104 if _, err := mail.ParseAddress(email); err != nil { 105 return fmt.Errorf("bad email address %q: %w", email, err) 106 } 107 } 108 if cfg.MailMaintainers && len(cfg.DefaultMaintainers) == 0 { 109 return fmt.Errorf("email config: MailMaintainers is set but no DefaultMaintainers") 110 } 111 if cfg.SubjectPrefix != strings.TrimSpace(cfg.SubjectPrefix) { 112 return fmt.Errorf("email config: subject prefix %q contains leading/trailing spaces", cfg.SubjectPrefix) 113 } 114 return nil 115 } 116 117 func (cfg *EmailConfig) getSubject(title string) string { 118 if cfg.SubjectPrefix != "" { 119 return cfg.SubjectPrefix + " " + title 120 } 121 return title 122 } 123 124 // handleCoverageReports sends a coverage report for the two full months preceding the current one. 125 // Assuming it is called June 15, the monthly report will cover April-May diff. 126 func handleCoverageReports(w http.ResponseWriter, r *http.Request) { 127 ctx := r.Context() 128 targetDate := civil.DateOf(timeNow(ctx)).AddMonths(-1) 129 periods, err := coveragedb.GenNPeriodsTill(2, targetDate, "month") 130 if err != nil { 131 msg := fmt.Sprintf("error generating coverage report: %s", err.Error()) 132 log.Errorf(ctx, "%s", msg) 133 http.Error(w, "%s: %w", http.StatusBadRequest) 134 return 135 } 136 wg := sync.WaitGroup{} 137 for nsName, nsConfig := range getConfig(ctx).Namespaces { 138 if nsConfig.Coverage == nil || nsConfig.Coverage.EmailRegressionsTo == "" { 139 continue 140 } 141 emailTo := nsConfig.Coverage.EmailRegressionsTo 142 minDrop := defaultRegressionThreshold 143 if nsConfig.Coverage.RegressionThreshold > 0 { 144 minDrop = nsConfig.Coverage.RegressionThreshold 145 } 146 147 wg.Add(1) 148 go func() { 149 defer wg.Done() 150 if err := sendNsCoverageReport(ctx, nsName, emailTo, periods, minDrop); err != nil { 151 msg := fmt.Sprintf("error generating coverage report for ns '%s': %s", nsName, err.Error()) 152 log.Errorf(ctx, "%s", msg) 153 return 154 } 155 }() 156 } 157 wg.Wait() 158 } 159 160 func sendNsCoverageReport(ctx context.Context, ns, email string, 161 period []coveragedb.TimePeriod, minDrop int) error { 162 var days int 163 for _, p := range period { 164 days += p.Days 165 } 166 periodFrom := fmt.Sprintf("%s %d", period[0].DateTo.Month.String(), period[0].DateTo.Year) 167 periodTo := fmt.Sprintf("%s %d", period[1].DateTo.Month.String(), period[1].DateTo.Year) 168 table, err := coverageTable(ctx, ns, period, minDrop) 169 if err != nil { 170 return fmt.Errorf("coverageTable: %w", err) 171 } 172 args := struct { 173 Namespace string 174 PeriodFrom string 175 PeriodFromDays int 176 PeriodTo string 177 PeriodToDays int 178 Link string 179 Table string 180 }{ 181 Namespace: ns, 182 PeriodFrom: periodFrom, 183 PeriodFromDays: period[0].Days, 184 PeriodTo: periodTo, 185 PeriodToDays: period[1].Days, 186 Link: fmt.Sprintf("%s%s", appURL(ctx), 187 coveragePageLink(ns, period[1].Type, period[1].DateTo.String(), minDrop, 2, true)), 188 Table: table, 189 } 190 title := fmt.Sprintf("%s coverage regression in %s", ns, periodTo) 191 err = sendMailTemplate(ctx, &mailSendParams{ 192 templateName: "mail_ns_coverage.txt", 193 templateArg: args, 194 title: title, 195 cfg: &EmailConfig{ 196 Email: email, 197 }, 198 reportID: "coverage-report", 199 }) 200 if err != nil { 201 err2 := fmt.Errorf("error generating coverage report: %w", err) 202 log.Errorf(ctx, "%s", err2.Error()) 203 return err2 204 } 205 return nil 206 } 207 208 func coverageTable(ctx context.Context, ns string, fromTo []coveragedb.TimePeriod, minDrop int) (string, error) { 209 covAndDates, err := coveragedb.FilesCoverageWithDetails( 210 ctx, 211 getCoverageDBClient(ctx), 212 &coveragedb.SelectScope{ 213 Ns: ns, 214 Periods: fromTo, 215 }, 216 false) 217 if err != nil { 218 return "", fmt.Errorf("coveragedb.FilesCoverageWithDetails: %w", err) 219 } 220 templData := cover.FilesCoverageToTemplateData(covAndDates) 221 cover.FormatResult(templData, cover.Format{ 222 OrderByCoveredLinesDrop: true, 223 FilterMinCoveredLinesDrop: minDrop, 224 }) 225 res := "Blocks diff,\tPath\n" 226 templData.Root.Visit(func(path string, summary int64, isDir bool) { 227 if !isDir { 228 res += fmt.Sprintf("% 11d\t%s\n", summary, path) 229 } 230 }) 231 return res, nil 232 } 233 234 // handleEmailPoll is called by cron and sends emails for new bugs, if any. 235 func handleEmailPoll(w http.ResponseWriter, r *http.Request) { 236 c := appengine.NewContext(r) 237 stop, err := emergentlyStopped(c) 238 if err != nil { 239 log.Errorf(c, "emergency stop querying failed: %v", err) 240 http.Error(w, err.Error(), http.StatusInternalServerError) 241 return 242 } 243 if stop { 244 log.Errorf(c, "aborting email poll due to an emergency stop") 245 return 246 } 247 if err := emailPollJobs(c); err != nil { 248 log.Errorf(c, "job poll failed: %v", err) 249 http.Error(w, err.Error(), http.StatusInternalServerError) 250 return 251 } 252 if err := emailPollNotifications(c); err != nil { 253 log.Errorf(c, "notif poll failed: %v", err) 254 http.Error(w, err.Error(), http.StatusInternalServerError) 255 return 256 } 257 if err := emailPollBugs(c); err != nil { 258 log.Errorf(c, "bug poll failed: %v", err) 259 http.Error(w, err.Error(), http.StatusInternalServerError) 260 return 261 } 262 if err := emailPollBugLists(c); err != nil { 263 log.Errorf(c, "bug list poll failed: %v", err) 264 http.Error(w, err.Error(), http.StatusInternalServerError) 265 return 266 } 267 w.Write([]byte("OK")) 268 } 269 270 func emailPollBugLists(c context.Context) error { 271 reports := reportingPollBugLists(c, emailType) 272 for _, rep := range reports { 273 if err := emailSendBugListReport(c, rep); err != nil { 274 log.Errorf(c, "emailPollBugLists: %v", err) 275 } 276 } 277 return nil 278 } 279 280 func emailPollBugs(c context.Context) error { 281 reports := reportingPollBugs(c, emailType) 282 for _, rep := range reports { 283 if err := emailSendBugReport(c, rep); err != nil { 284 log.Errorf(c, "emailPollBugs: %v", err) 285 } 286 } 287 return nil 288 } 289 290 func emailSendBugReport(c context.Context, rep *dashapi.BugReport) error { 291 cfg := new(EmailConfig) 292 if err := json.Unmarshal(rep.Config, cfg); err != nil { 293 return fmt.Errorf("failed to unmarshal email config: %w", err) 294 } 295 if err := emailReport(c, rep); err != nil { 296 return fmt.Errorf("failed to report bug: %w", err) 297 } 298 cmd := &dashapi.BugUpdate{ 299 ID: rep.ID, 300 Status: dashapi.BugStatusOpen, 301 ReproLevel: dashapi.ReproLevelNone, 302 CrashID: rep.CrashID, 303 } 304 if len(rep.ReproC) != 0 { 305 cmd.ReproLevel = dashapi.ReproLevelC 306 } else if len(rep.ReproSyz) != 0 { 307 cmd.ReproLevel = dashapi.ReproLevelSyz 308 } 309 for label := range rep.LabelMessages { 310 cmd.Labels = append(cmd.Labels, label) 311 } 312 ok, reason, err := incomingCommand(c, cmd) 313 if !ok || err != nil { 314 return fmt.Errorf("failed to update reported bug: ok=%v reason=%v err=%w", ok, reason, err) 315 } 316 return nil 317 } 318 319 func emailSendBugListReport(c context.Context, rep *dashapi.BugListReport) error { 320 cfg := new(EmailConfig) 321 if err := json.Unmarshal(rep.Config, cfg); err != nil { 322 return fmt.Errorf("failed to unmarshal email config: %w", err) 323 } 324 err := emailListReport(c, rep, cfg) 325 if err != nil { 326 return fmt.Errorf("failed to send the bug list message: %w", err) 327 } 328 upd := &dashapi.BugListUpdate{ 329 ID: rep.ID, 330 Command: dashapi.BugListSentCmd, 331 } 332 _, err = reportingBugListCommand(c, upd) 333 if err != nil { 334 return fmt.Errorf("failed to update the bug list: %w", err) 335 } 336 return nil 337 } 338 339 func emailPollNotifications(c context.Context) error { 340 notifs := reportingPollNotifications(c, emailType) 341 for _, notif := range notifs { 342 if err := emailSendBugNotif(c, notif); err != nil { 343 log.Errorf(c, "emailPollNotifications: %v", err) 344 } 345 } 346 return nil 347 } 348 349 func emailSendBugNotif(c context.Context, notif *dashapi.BugNotification) error { 350 status, body := dashapi.BugStatusOpen, "" 351 var statusReason dashapi.BugStatusReason 352 switch notif.Type { 353 case dashapi.BugNotifUpstream: 354 body = "Sending this report to the next reporting stage." 355 status = dashapi.BugStatusUpstream 356 case dashapi.BugNotifBadCommit: 357 var err error 358 body, err = buildBadCommitMessage(c, notif) 359 if err != nil { 360 return err 361 } 362 case dashapi.BugNotifObsoleted: 363 body = "Auto-closing this bug as obsolete.\n" 364 statusReason = dashapi.BugStatusReason(notif.Text) 365 if statusReason == dashapi.InvalidatedByRevokedRepro { 366 body += "No recent activity, existing reproducers are no longer triggering the issue." 367 } else { 368 body += "Crashes did not happen for a while, no reproducer and no activity." 369 } 370 status = dashapi.BugStatusInvalid 371 case dashapi.BugNotifLabel: 372 bodyBuf := new(bytes.Buffer) 373 if err := mailTemplates.ExecuteTemplate(bodyBuf, "mail_label_notif.txt", notif); err != nil { 374 return fmt.Errorf("failed to execute mail_label_notif.txt: %w", err) 375 } 376 body = bodyBuf.String() 377 default: 378 return fmt.Errorf("bad notification type %v", notif.Type) 379 } 380 cfg := new(EmailConfig) 381 if err := json.Unmarshal(notif.Config, cfg); err != nil { 382 return fmt.Errorf("failed to unmarshal email config: %w", err) 383 } 384 to := email.MergeEmailLists([]string{cfg.Email}, notif.CC) 385 if cfg.MailMaintainers && notif.Public { 386 to = email.MergeEmailLists(to, notif.Maintainers, cfg.DefaultMaintainers) 387 } 388 from, err := email.AddAddrContext(fromAddr(c), notif.ID) 389 if err != nil { 390 return err 391 } 392 log.Infof(c, "sending notif %v for %q to %q: %v", notif.Type, notif.Title, to, body) 393 if err := sendMailText(c, cfg.getSubject(notif.Title), from, to, notif.ExtID, body); err != nil { 394 return err 395 } 396 cmd := &dashapi.BugUpdate{ 397 ID: notif.ID, 398 Status: status, 399 StatusReason: statusReason, 400 Notification: true, 401 } 402 if notif.Label != "" { 403 cmd.Labels = []string{notif.Label} 404 } 405 ok, reason, err := incomingCommand(c, cmd) 406 if !ok || err != nil { 407 return fmt.Errorf("notif update failed: ok=%v reason=%v err=%w", ok, reason, err) 408 } 409 return nil 410 } 411 412 func buildBadCommitMessage(c context.Context, notif *dashapi.BugNotification) (string, error) { 413 var sb strings.Builder 414 days := int(notifyAboutBadCommitPeriod / time.Hour / 24) 415 nsConfig := getNsConfig(c, notif.Namespace) 416 fmt.Fprintf(&sb, `This bug is marked as fixed by commit: 417 %v 418 419 But I can't find it in the tested trees[1] for more than %v days. 420 Is it a correct commit? Please update it by replying: 421 422 #syz fix: exact-commit-title 423 424 Until then the bug is still considered open and new crashes with 425 the same signature are ignored. 426 427 Kernel: %s 428 Dashboard link: %s 429 430 --- 431 [1] I expect the commit to be present in: 432 `, notif.Text, days, nsConfig.DisplayTitle, notif.Link) 433 434 repos, err := loadRepos(c, notif.Namespace) 435 if err != nil { 436 return "", err 437 } 438 const maxShow = 4 439 for i, repo := range repos { 440 if i >= maxShow { 441 break 442 } 443 fmt.Fprintf(&sb, "\n%d. %s branch of\n%s\n", i+1, repo.Branch, repo.URL) 444 } 445 if len(repos) > maxShow { 446 fmt.Fprintf(&sb, "\nThe full list of %d trees can be found at\n%s\n", 447 len(repos), fmt.Sprintf("%v/%v/repos", appURL(c), notif.Namespace)) 448 } 449 return sb.String(), nil 450 } 451 452 func emailPollJobs(c context.Context) error { 453 jobs, err := pollCompletedJobs(c, emailType) 454 if err != nil { 455 return err 456 } 457 for _, job := range jobs { 458 if err := emailReport(c, job); err != nil { 459 log.Errorf(c, "failed to report job: %v", err) 460 continue 461 } 462 if err := jobReported(c, job.JobID); err != nil { 463 log.Errorf(c, "failed to mark job reported: %v", err) 464 continue 465 } 466 } 467 return nil 468 } 469 470 func emailReport(c context.Context, rep *dashapi.BugReport) error { 471 cfg := new(EmailConfig) 472 if err := json.Unmarshal(rep.Config, cfg); err != nil { 473 return fmt.Errorf("failed to unmarshal email config: %w", err) 474 } 475 if rep.UserSpaceArch == targets.AMD64 { 476 // This is default, so don't include the info. 477 rep.UserSpaceArch = "" 478 } 479 templ := "" 480 switch rep.Type { 481 case dashapi.ReportNew, dashapi.ReportRepro: 482 templ = "mail_bug.txt" 483 case dashapi.ReportTestPatch: 484 templ = "mail_test_result.txt" 485 cfg.MailMaintainers = false 486 case dashapi.ReportBisectCause: 487 templ = "mail_bisect_result.txt" 488 case dashapi.ReportBisectFix: 489 if rep.BisectFix.CrossTree { 490 templ = "mail_fix_candidate.txt" 491 if rep.BisectFix.Commit == nil { 492 return fmt.Errorf("reporting failed fix candidate bisection for %s", rep.ID) 493 } 494 } else { 495 templ = "mail_bisect_result.txt" 496 } 497 default: 498 return fmt.Errorf("unknown report type %v", rep.Type) 499 } 500 return sendMailTemplate(c, &mailSendParams{ 501 templateName: templ, 502 templateArg: rep, 503 cfg: cfg, 504 title: generateEmailBugTitle(rep, cfg), 505 reportID: rep.ID, 506 replyTo: rep.ExtID, 507 cc: rep.CC, 508 maintainers: rep.Maintainers, 509 }) 510 } 511 512 func emailListReport(c context.Context, rep *dashapi.BugListReport, cfg *EmailConfig) error { 513 if rep.Moderation { 514 cfg.MailMaintainers = false 515 } 516 args := struct { 517 *dashapi.BugListReport 518 Table string 519 }{BugListReport: rep} 520 521 var b bytes.Buffer 522 w := tabwriter.NewWriter(&b, 0, 0, 1, ' ', 0) 523 fmt.Fprintln(w, "Ref\tCrashes\tRepro\tTitle") 524 for i, bug := range rep.Bugs { 525 repro := "No" 526 if bug.ReproLevel > dashapi.ReproLevelNone { 527 repro = "Yes" 528 } 529 fmt.Fprintf(w, "<%d>\t%d\t%s\t%s\n", i+1, bug.Hits, repro, bug.Title) 530 fmt.Fprintf(w, "\t\t\t%s\n", bug.Link) 531 } 532 w.Flush() 533 args.Table = b.String() 534 535 return sendMailTemplate(c, &mailSendParams{ 536 templateName: "mail_subsystem.txt", 537 templateArg: args, 538 cfg: cfg, 539 title: fmt.Sprintf("Monthly %s report (%s)", 540 rep.Subsystem, rep.Created.Format("Jan 2006")), 541 reportID: rep.ID, 542 maintainers: rep.Maintainers, 543 }) 544 } 545 546 type mailSendParams struct { 547 templateName string 548 templateArg any 549 cfg *EmailConfig 550 title string 551 reportID string 552 replyTo string 553 cc []string 554 maintainers []string 555 } 556 557 func sendMailTemplate(c context.Context, params *mailSendParams) error { 558 cfg := params.cfg 559 to := email.MergeEmailLists([]string{cfg.Email}, params.cc) 560 if cfg.MailMaintainers { 561 to = email.MergeEmailLists(to, params.maintainers, cfg.DefaultMaintainers) 562 } 563 from, err := email.AddAddrContext(fromAddr(c), params.reportID) 564 if err != nil { 565 return err 566 } 567 body := new(bytes.Buffer) 568 if err := mailTemplates.ExecuteTemplate(body, params.templateName, params.templateArg); err != nil { 569 return fmt.Errorf("failed to execute %v template: %w", params.templateName, err) 570 } 571 log.Infof(c, "sending email %q to %q", params.title, to) 572 return sendMailText(c, params.cfg.getSubject(params.title), from, to, params.replyTo, body.String()) 573 } 574 func generateEmailBugTitle(rep *dashapi.BugReport, emailConfig *EmailConfig) string { 575 title := "" 576 for i := len(rep.Subsystems) - 1; i >= 0; i-- { 577 question := "" 578 if rep.Subsystems[i].SetBy == "" { 579 // Include the question mark for automatically created tags. 580 question = "?" 581 } 582 title = fmt.Sprintf("[%s%s] %s", rep.Subsystems[i].Name, question, title) 583 } 584 return title + rep.Title 585 } 586 587 // handleIncomingMail is the entry point for incoming emails. 588 func handleIncomingMail(w http.ResponseWriter, r *http.Request) { 589 c := appengine.NewContext(r) 590 url := r.URL.RequestURI() 591 myEmail := "" 592 if index := strings.LastIndex(url, "/"); index >= 0 { 593 myEmail = url[index+1:] 594 } else { 595 log.Errorf(c, "invalid email handler URL: %s", url) 596 return 597 } 598 msg, err := email.Parse(r.Body, ownEmails(c), ownMailingLists(c), []string{ 599 appURL(c), 600 }) 601 if err != nil { 602 // Malformed emails constantly appear from spammers. 603 // But we have not seen errors parsing legit emails. 604 // These errors are annoying. Warn and ignore them. 605 log.Warningf(c, "failed to parse email: %v", err) 606 return 607 } 608 source := matchDiscussionEmail(c, myEmail) 609 inbox := matchInbox(c, msg) 610 log.Infof(c, "received email at %q, source %q, matched ignored inbox=%v", 611 myEmail, source, inbox != nil) 612 if inbox != nil { 613 err = processInboxEmail(c, msg, inbox) 614 } else if source != dashapi.NoDiscussion { 615 // Discussions are safe to handle even during an emergency stop. 616 err = processDiscussionEmail(c, msg, source) 617 } else { 618 if stop, err := emergentlyStopped(c); err != nil || stop { 619 log.Errorf(c, "abort email processing due to emergency stop (stop %v, err %v)", 620 stop, err) 621 return 622 } 623 err = processIncomingEmail(c, msg) 624 } 625 if err != nil { 626 log.Errorf(c, "email processing failed: %s", err) 627 } 628 } 629 630 func matchDiscussionEmail(c context.Context, myEmail string) dashapi.DiscussionSource { 631 for _, item := range getConfig(c).DiscussionEmails { 632 if item.ReceiveAddress != myEmail { 633 continue 634 } 635 return item.Source 636 } 637 return dashapi.NoDiscussion 638 } 639 640 func matchInbox(c context.Context, msg *email.Email) *PerInboxConfig { 641 // We look at all raw addresses in To or Cc because, after forwarding, someone's reply 642 // will arrive to us both via the email through which we have forwarded and through the 643 // address that matched InboxRe. 644 for _, item := range getConfig(c).MonitoredInboxes { 645 rg := regexp.MustCompile(item.InboxRe) 646 for _, cc := range msg.RawCc { 647 if rg.MatchString(cc) { 648 return item 649 } 650 } 651 } 652 return nil 653 } 654 655 func processInboxEmail(c context.Context, msg *email.Email, inbox *PerInboxConfig) error { 656 if len(msg.Commands) == 0 || len(msg.BugIDs) == 0 || msg.OwnEmail { 657 // Do not forward emails with no commands. 658 // Also, we don't care about the emails that don't include any BugIDs. 659 return nil 660 } 661 needForwardTo := map[string]bool{} 662 for _, cc := range inbox.ForwardTo { 663 needForwardTo[cc] = true 664 } 665 for _, email := range msg.Cc { 666 delete(needForwardTo, email) 667 } 668 missing := slices.Collect(maps.Keys(needForwardTo)) 669 sort.Strings(missing) 670 if len(missing) == 0 { 671 // Everything's OK. 672 log.Infof(c, "email %q has all necessary lists in Cc", msg.MessageID) 673 return nil 674 } 675 // We don't want to forward from a name+hash@domain address because 676 // the automation could confuse that with bug reports and not react to the commamnds in there. 677 // So we forward just from name@domain, but Cc name+hash@domain to still identify the email 678 // as related to the bug identified by the hash. 679 cc, err := email.AddAddrContext(fromAddr(c), msg.BugIDs[0]) 680 if err != nil { 681 return err 682 } 683 if !stringInList(msg.Cc, cc) { 684 msg.Cc = append(msg.Cc, cc) 685 } 686 return forwardEmail(c, msg, missing, []string{cc, msg.Author}, "", msg.MessageID) 687 } 688 689 // nolint: gocyclo 690 func processIncomingEmail(c context.Context, msg *email.Email) error { 691 // Ignore any incoming emails from syzbot itself. 692 if ownEmail(c) == msg.Author { 693 // But we still want to remember the id of our own message, so just neutralize the command. 694 msg.Commands = nil 695 } 696 log.Infof(c, "received email: subject %q, author %q, cc %q, msg %q, bug %v, %d cmds, link %q, list %q", 697 msg.Subject, msg.Author, msg.Cc, msg.MessageID, msg.BugIDs, len(msg.Commands), msg.Link, msg.MailingList) 698 excludeSampleCommands(msg) 699 bugInfo, bugListInfo, emailConfig := identifyEmail(c, msg) 700 if bugInfo == nil && bugListInfo == nil { 701 return nil // error was already logged 702 } 703 // A mailing list can send us a duplicate email, to not process/reply 704 // to such duplicate emails, we ignore emails coming from our mailing lists. 705 fromMailingList := msg.MailingList != "" 706 missingLists := missingMailingLists(c, msg, emailConfig) 707 log.Infof(c, "from/cc mailing list: %v (missing: %v)", fromMailingList, missingLists) 708 if fromMailingList && len(msg.BugIDs) > 0 && len(msg.Commands) > 0 { 709 // Note that if syzbot was not directly mentioned in To or Cc, this is not really 710 // a duplicate message, so it must be processed. We detect it by looking at BugID. 711 712 // There's also a chance that the user mentioned syzbot directly, but without BugID. 713 // We don't need to worry about this case, as we won't recognize the bug anyway. 714 log.Infof(c, "duplicate email from mailing list, ignoring") 715 return nil 716 } 717 718 var replies []string 719 if bugListInfo != nil { 720 const maxCommands = 10 721 if len(msg.Commands) > maxCommands { 722 return replyTo(c, msg, bugListInfo.id, 723 fmt.Sprintf("Too many commands (%d > %d)", len(msg.Commands), maxCommands)) 724 } 725 for _, command := range msg.Commands { 726 replies = append(replies, handleBugListCommand(c, bugListInfo, msg, command)) 727 } 728 if reply := groupEmailReplies(replies); reply != "" { 729 return replyTo(c, msg, bugListInfo.id, reply) 730 } 731 } else { 732 const maxCommands = 3 733 if len(msg.Commands) > maxCommands { 734 return replyTo(c, msg, bugInfo.bugReporting.ID, 735 fmt.Sprintf("Too many commands (%d > %d)", len(msg.Commands), maxCommands)) 736 } 737 unCc := false 738 for _, command := range msg.Commands { 739 if command.Command == email.CmdUnCC { 740 unCc = true 741 } 742 replies = append(replies, handleBugCommand(c, bugInfo, msg, command)) 743 } 744 if len(msg.Commands) == 0 { 745 // Even if there are 0 commands we'd still like to just ping the bug. 746 replies = append(replies, handleBugCommand(c, bugInfo, msg, nil)) 747 } 748 reply := groupEmailReplies(replies) 749 if reply == "" && len(msg.Commands) > 0 && len(missingLists) > 0 && !unCc { 750 return forwardEmail(c, msg, missingLists, nil, bugInfo.bugReporting.ID, bugInfo.bugReporting.ExtID) 751 } 752 if reply != "" { 753 return replyTo(c, msg, bugInfo.bugReporting.ID, reply) 754 } 755 } 756 return nil 757 } 758 759 func excludeSampleCommands(msg *email.Email) { 760 // Sometimes it happens that somebody sends us our own text back, ignore it. 761 var newCommands []*email.SingleCommand 762 for _, cmd := range msg.Commands { 763 ok := true 764 switch cmd.Command { 765 case email.CmdFix: 766 ok = cmd.Args != "exact-commit-title" 767 case email.CmdTest: 768 ok = cmd.Args != "git://repo/address.git branch-or-commit-hash" 769 case email.CmdSet: 770 ok = cmd.Args != "subsystems: new-subsystem" 771 case email.CmdUnset: 772 ok = cmd.Args != "some-label" 773 case email.CmdDup: 774 ok = cmd.Args != "exact-subject-of-another-report" 775 } 776 if ok { 777 newCommands = append(newCommands, cmd) 778 } 779 } 780 msg.Commands = newCommands 781 } 782 783 func groupEmailReplies(replies []string) string { 784 // If there's just one reply, return it. 785 if len(replies) == 1 { 786 return replies[0] 787 } 788 var totalReply strings.Builder 789 for i, reply := range replies { 790 if reply == "" { 791 continue 792 } 793 if totalReply.Len() > 0 { 794 totalReply.WriteString("\n\n") 795 } 796 totalReply.WriteString(fmt.Sprintf("Command #%d:\n", i+1)) 797 totalReply.WriteString(reply) 798 } 799 return totalReply.String() 800 } 801 802 func handleBugCommand(c context.Context, bugInfo *bugInfoResult, msg *email.Email, 803 command *email.SingleCommand) string { 804 status := dashapi.BugStatusUpdate 805 if command != nil { 806 status = emailCmdToStatus[command.Command] 807 } 808 cmd := &dashapi.BugUpdate{ 809 Status: status, 810 ID: bugInfo.bugReporting.ID, 811 ExtID: msg.MessageID, 812 Link: msg.Link, 813 CC: msg.Cc, 814 } 815 if command != nil { 816 switch command.Command { 817 case email.CmdTest: 818 return handleTestCommand(c, bugInfo, msg, command) 819 case email.CmdSet: 820 return handleSetCommand(c, bugInfo.bug, msg, command) 821 case email.CmdUnset: 822 return handleUnsetCommand(c, bugInfo.bug, msg, command) 823 case email.CmdUpstream, email.CmdInvalid, email.CmdUnDup: 824 case email.CmdFix: 825 if command.Args == "" { 826 return "no commit title" 827 } 828 cmd.FixCommits = []string{command.Args} 829 case email.CmdUnFix: 830 cmd.ResetFixCommits = true 831 case email.CmdDup: 832 if command.Args == "" { 833 return "no dup title" 834 } 835 var err error 836 cmd.DupOf, err = getSubjectParser(c).parseFullTitle(command.Args) 837 if err != nil { 838 return "failed to parse the dup title" 839 } 840 case email.CmdUnCC: 841 cmd.CC = []string{msg.Author} 842 default: 843 if command.Command != email.CmdUnknown { 844 log.Errorf(c, "unknown email command %v %q", command.Command, command.Str) 845 } 846 return fmt.Sprintf("unknown command %q", command.Str) 847 } 848 } 849 ok, reply, err := incomingCommand(c, cmd) 850 if err != nil { 851 return "" // the error was already logged 852 } 853 if !ok && reply != "" { 854 return reply 855 } 856 return "" 857 } 858 859 func processDiscussionEmail(c context.Context, msg *email.Email, source dashapi.DiscussionSource) error { 860 log.Debugf(c, "processDiscussionEmail %s from source %v", msg.MessageID, source) 861 if len(msg.BugIDs) == 0 { 862 return nil 863 } 864 const limitIDs = 10 865 if len(msg.BugIDs) > limitIDs { 866 msg.BugIDs = msg.BugIDs[:limitIDs] 867 } 868 log.Debugf(c, "saving to discussions for %q", msg.BugIDs) 869 dType := dashapi.DiscussionMention 870 if source == dashapi.DiscussionLore { 871 dType = lore.DiscussionType(msg) 872 } 873 extIDs := []string{} 874 for _, id := range msg.BugIDs { 875 if isBugListHash(id) { 876 dType = dashapi.DiscussionReminder 877 continue 878 } 879 _, _, err := findBugByReportingID(c, id) 880 if err == nil { 881 extIDs = append(extIDs, id) 882 } 883 } 884 msg.BugIDs = extIDs 885 err := saveDiscussionMessage(c, msg, source, dType) 886 if err != nil { 887 return fmt.Errorf("failed to save in discussions: %w", err) 888 } 889 return nil 890 } 891 892 var emailCmdToStatus = map[email.Command]dashapi.BugStatus{ 893 email.CmdUpstream: dashapi.BugStatusUpstream, 894 email.CmdInvalid: dashapi.BugStatusInvalid, 895 email.CmdUnDup: dashapi.BugStatusOpen, 896 email.CmdFix: dashapi.BugStatusOpen, 897 email.CmdUnFix: dashapi.BugStatusUpdate, 898 email.CmdDup: dashapi.BugStatusDup, 899 email.CmdUnCC: dashapi.BugStatusUnCC, 900 } 901 902 func handleTestCommand(c context.Context, info *bugInfoResult, 903 msg *email.Email, command *email.SingleCommand) string { 904 args := strings.Fields(command.Args) 905 if len(args) != 0 && len(args) != 2 { 906 return replyMalformedSyzTest 907 } 908 repo, branch := "", "" 909 if len(args) == 2 { 910 repo, branch = args[0], args[1] 911 } 912 if info.bug.sanitizeAccess(c, AccessPublic) != AccessPublic { 913 log.Warningf(c, "%v: bug is not AccessPublic, patch testing request is denied", info.bug.Title) 914 return "" 915 } 916 reply := "" 917 err := handleTestRequest(c, &testReqArgs{ 918 bug: info.bug, bugKey: info.bugKey, bugReporting: info.bugReporting, 919 user: msg.Author, extID: msg.MessageID, link: msg.Link, 920 patch: []byte(msg.Patch), repo: repo, branch: branch, jobCC: msg.Cc}) 921 if err != nil { 922 var testDenied *TestRequestDeniedError 923 var badTest *BadTestRequestError 924 switch { 925 case errors.As(err, &testDenied): 926 // Don't send a reply in this case. 927 log.Errorf(c, "patch test request denied: %v", testDenied) 928 case errors.As(err, &badTest): 929 reply = badTest.Error() 930 default: 931 // Don't leak any details to the reply email. 932 reply = "Processing failed due to an internal error" 933 // .. but they are useful for debugging, so we'd like to see it on the Admin page. 934 log.Errorf(c, "handleTestRequest error: %v", err) 935 } 936 } 937 return reply 938 } 939 940 var ( 941 // The supported formats are: 942 // For bugs: 943 // #syz set LABEL[: value_1, [value_2, ....]] 944 // For bug lists: 945 // #syz set <N> LABEL[: value_1, [value_2, ....]] 946 setCmdRe = regexp.MustCompile(`(?m)\s*([-\w]+)\s*(?:\:\s*([,\-\w\s]*?))?$`) 947 setCmdArgSplitRe = regexp.MustCompile(`[\s,]+`) 948 setBugCmdFormat = `I've failed to parse your command. Please use the following format(s): 949 #syz set some-flag 950 #syz set label: value 951 #syz set subsystems: one-subsystem, another-subsystem 952 953 Or, for bug lists, 954 #syz set <Ref> some-flag 955 #syz set <Ref> label: value 956 #syz set <Ref> subsystems: one-subsystem, another-subsystem 957 958 The following labels are suported: 959 %s` 960 setCmdUnknownLabel = `The specified label %q is unknown. 961 Please use one of the supported labels. 962 963 The following labels are suported: 964 %s` 965 setCmdUnknownValue = `The specified label value is incorrect. 966 %s. 967 Please use one of the supported label values. 968 969 The following labels are suported: 970 %s` 971 cmdInternalErrorReply = `The command was not executed due to an internal error. 972 Please contact the bot's maintainers.` 973 ) 974 975 func handleSetCommand(c context.Context, bug *Bug, msg *email.Email, 976 command *email.SingleCommand) string { 977 labelSet := makeLabelSet(c, bug.Namespace) 978 979 match := setCmdRe.FindStringSubmatch(command.Args) 980 if match == nil { 981 return fmt.Sprintf(setBugCmdFormat, labelSet.Help()) 982 } 983 label, values := BugLabelType(match[1]), match[2] 984 log.Infof(c, "bug=%q label=%s values=%s", bug.displayTitle(), label, values) 985 if !labelSet.FindLabel(label) { 986 return fmt.Sprintf(setCmdUnknownLabel, label, labelSet.Help()) 987 } 988 var labels []BugLabel 989 for _, value := range unique(setCmdArgSplitRe.Split(values, -1)) { 990 labels = append(labels, BugLabel{ 991 Label: label, 992 Value: value, 993 SetBy: msg.Author, 994 Link: msg.Link, 995 }) 996 } 997 var setError error 998 err := updateSingleBug(c, bug.key(c), func(bug *Bug) error { 999 setError = bug.SetLabels(labelSet, labels) 1000 return setError 1001 }) 1002 if setError != nil { 1003 return fmt.Sprintf(setCmdUnknownValue, setError, labelSet.Help()) 1004 } 1005 if err != nil { 1006 log.Errorf(c, "failed to set bug tags: %s", err) 1007 return cmdInternalErrorReply 1008 } 1009 return "" 1010 } 1011 1012 var ( 1013 unsetBugCmdFormat = `I've failed to parse your command. Please use the following format(s): 1014 #syz unset any-label 1015 1016 Or, for bug lists, 1017 #syz unset <Ref> any-label 1018 ` 1019 unsetLabelsNotFound = `The following labels did not exist: %s` 1020 ) 1021 1022 func handleUnsetCommand(c context.Context, bug *Bug, msg *email.Email, 1023 command *email.SingleCommand) string { 1024 match := setCmdRe.FindStringSubmatch(command.Args) 1025 if match == nil { 1026 return unsetBugCmdFormat 1027 } 1028 var labels []BugLabelType 1029 for _, name := range unique(setCmdArgSplitRe.Split(command.Args, -1)) { 1030 labels = append(labels, BugLabelType(name)) 1031 } 1032 1033 var notFound map[BugLabelType]struct{} 1034 var notFoundErr = fmt.Errorf("some labels were not found") 1035 err := updateSingleBug(c, bug.key(c), func(bug *Bug) error { 1036 notFound = bug.UnsetLabels(labels...) 1037 if len(notFound) > 0 { 1038 return notFoundErr 1039 } 1040 return nil 1041 }) 1042 if err == notFoundErr { 1043 var names []string 1044 for label := range notFound { 1045 names = append(names, string(label)) 1046 } 1047 return fmt.Sprintf(unsetLabelsNotFound, strings.Join(names, ", ")) 1048 } else if err != nil { 1049 log.Errorf(c, "failed to unset bug labels: %s", err) 1050 return cmdInternalErrorReply 1051 } 1052 return "" 1053 } 1054 1055 func handleEmailBounce(w http.ResponseWriter, r *http.Request) { 1056 c := appengine.NewContext(r) 1057 body, err := io.ReadAll(r.Body) 1058 if err != nil { 1059 log.Errorf(c, "email bounced: failed to read body: %v", err) 1060 return 1061 } 1062 if nonCriticalBounceRe.Match(body) { 1063 log.Infof(c, "email bounced: address not found") 1064 } else { 1065 log.Errorf(c, "email bounced") 1066 } 1067 log.Infof(c, "%s", body) 1068 } 1069 1070 var ( 1071 setGroupCmdRe = regexp.MustCompile(`(?m)\s*<(\d+)>\s*(.*)$`) 1072 setGroupCmdFormat = `I've failed to parse your command. Please use the following format(s): 1073 #syz set <Ref> some-label, another-label 1074 #syz set <Ref> subsystems: one-subsystem, another-subsystem 1075 #syz unset <Ref> some-label 1076 ` 1077 setGroupCmdBadRef = `The specified <Ref> number is invalid. It must be one of the <NUM> values 1078 listed in the bug list table. 1079 ` 1080 ) 1081 1082 func handleBugListCommand(c context.Context, bugListInfo *bugListInfoResult, 1083 msg *email.Email, command *email.SingleCommand) string { 1084 upd := &dashapi.BugListUpdate{ 1085 ID: bugListInfo.id, 1086 ExtID: msg.MessageID, 1087 Link: msg.Link, 1088 } 1089 switch command.Command { 1090 case email.CmdUpstream: 1091 upd.Command = dashapi.BugListUpstreamCmd 1092 case email.CmdRegenerate: 1093 upd.Command = dashapi.BugListRegenerateCmd 1094 case email.CmdSet, email.CmdUnset: 1095 // Extract and cut the <Ref> part. 1096 match := setGroupCmdRe.FindStringSubmatch(command.Args) 1097 if match == nil { 1098 return setGroupCmdFormat 1099 } 1100 ref, args := match[1], match[2] 1101 numRef, err := strconv.Atoi(ref) 1102 if err != nil { 1103 return setGroupCmdFormat 1104 } 1105 if numRef < 1 || numRef > len(bugListInfo.keys) { 1106 return setGroupCmdBadRef 1107 } 1108 bugKey := bugListInfo.keys[numRef-1] 1109 bug := new(Bug) 1110 if err := db.Get(c, bugKey, bug); err != nil { 1111 log.Errorf(c, "failed to fetch bug by key %s: %s", bugKey, err) 1112 return cmdInternalErrorReply 1113 } 1114 command.Args = args 1115 switch command.Command { 1116 case email.CmdSet: 1117 return handleSetCommand(c, bug, msg, command) 1118 case email.CmdUnset: 1119 return handleUnsetCommand(c, bug, msg, command) 1120 } 1121 default: 1122 upd.Command = dashapi.BugListUpdateCmd 1123 } 1124 log.Infof(c, "bug list update: id=%s, cmd=%v", upd.ID, upd.Command) 1125 reply, err := reportingBugListCommand(c, upd) 1126 if err != nil { 1127 log.Errorf(c, "bug list command failed: %s", err) 1128 return cmdInternalErrorReply 1129 } 1130 return reply 1131 } 1132 1133 // These are just stale emails in MAINTAINERS. 1134 var nonCriticalBounceRe = regexp.MustCompile(`\*\* Address not found \*\*|550 #5\.1\.0 Address rejected`) 1135 1136 type bugListInfoResult struct { 1137 id string 1138 config *EmailConfig 1139 keys []*db.Key 1140 } 1141 1142 func identifyEmail(c context.Context, msg *email.Email) (*bugInfoResult, *bugListInfoResult, *EmailConfig) { 1143 bugID := "" 1144 if len(msg.BugIDs) > 0 { 1145 // For now let's only consider one of them. 1146 bugID = msg.BugIDs[0] 1147 } 1148 if isBugListHash(bugID) { 1149 subsystem, report, stage, err := findSubsystemReportByID(c, bugID) 1150 if err != nil { 1151 log.Errorf(c, "findBugListByID failed: %s", err) 1152 return nil, nil, nil 1153 } 1154 if subsystem == nil { 1155 log.Errorf(c, "no bug list with the %v ID found", bugID) 1156 return nil, nil, nil 1157 } 1158 reminderConfig := getNsConfig(c, subsystem.Namespace).Subsystems.Reminder 1159 if reminderConfig == nil { 1160 log.Errorf(c, "reminder configuration is empty") 1161 return nil, nil, nil 1162 } 1163 emailConfig, ok := bugListReportingConfig(c, subsystem.Namespace, stage).(*EmailConfig) 1164 if !ok { 1165 log.Errorf(c, "bug list's reporting config is not EmailConfig (id=%v)", bugID) 1166 return nil, nil, nil 1167 } 1168 keys, err := report.getBugKeys() 1169 if err != nil { 1170 log.Errorf(c, "failed to extract keys from bug list: %s", err) 1171 return nil, nil, nil 1172 } 1173 return nil, &bugListInfoResult{ 1174 id: bugID, 1175 config: emailConfig, 1176 keys: keys, 1177 }, emailConfig 1178 } 1179 bugInfo := loadBugInfo(c, msg) 1180 if bugInfo == nil { 1181 return nil, nil, nil 1182 } 1183 return bugInfo, nil, bugInfo.reporting.Config.(*EmailConfig) 1184 } 1185 1186 type bugInfoResult struct { 1187 bug *Bug 1188 bugKey *db.Key 1189 bugReporting *BugReporting 1190 reporting *Reporting 1191 } 1192 1193 func loadBugInfo(c context.Context, msg *email.Email) *bugInfoResult { 1194 bugID := "" 1195 if len(msg.BugIDs) > 0 { 1196 // For now let's only consider one of them. 1197 bugID = msg.BugIDs[0] 1198 } 1199 if bugID == "" { 1200 var matchingErr error 1201 // Give it one more try -- maybe we can determine the bug from the subject + mailing list. 1202 if msg.MailingList != "" { 1203 var ret *bugInfoResult 1204 ret, matchingErr = matchBugFromList(c, msg.MailingList, msg.Subject) 1205 if matchingErr == nil { 1206 return ret 1207 } 1208 log.Infof(c, "mailing list matching failed: %s", matchingErr) 1209 } 1210 if len(msg.Commands) == 0 { 1211 // This happens when people CC syzbot on unrelated emails. 1212 log.Infof(c, "no bug ID (%q)", msg.Subject) 1213 } else { 1214 log.Errorf(c, "no bug ID (%q)", msg.Subject) 1215 from, err := email.AddAddrContext(ownEmail(c), "HASH") 1216 if err != nil { 1217 log.Errorf(c, "failed to format sender email address: %v", err) 1218 from = "ERROR" 1219 } 1220 message := fmt.Sprintf(replyNoBugID, from) 1221 if matchingErr == errAmbiguousTitle { 1222 message = fmt.Sprintf(replyAmbiguousBugID, from) 1223 } 1224 if err := replyTo(c, msg, "", message); err != nil { 1225 log.Errorf(c, "failed to send reply: %v", err) 1226 } 1227 } 1228 return nil 1229 } 1230 bug, bugKey, err := findBugByReportingID(c, bugID) 1231 if err != nil { 1232 log.Errorf(c, "can't find bug: %v", err) 1233 from, err := email.AddAddrContext(ownEmail(c), "HASH") 1234 if err != nil { 1235 log.Errorf(c, "failed to format sender email address: %v", err) 1236 from = "ERROR" 1237 } 1238 if err := replyTo(c, msg, "", fmt.Sprintf(replyBadBugID, from)); err != nil { 1239 log.Errorf(c, "failed to send reply: %v", err) 1240 } 1241 return nil 1242 } 1243 bugReporting, _ := bugReportingByID(bug, bugID) 1244 if bugReporting == nil { 1245 log.Errorf(c, "can't find bug reporting: %v", err) 1246 if err := replyTo(c, msg, "", "Can't find the corresponding bug."); err != nil { 1247 log.Errorf(c, "failed to send reply: %v", err) 1248 } 1249 return nil 1250 } 1251 reporting := getNsConfig(c, bug.Namespace).ReportingByName(bugReporting.Name) 1252 if reporting == nil { 1253 log.Errorf(c, "can't find reporting for this bug: namespace=%q reporting=%q", 1254 bug.Namespace, bugReporting.Name) 1255 return nil 1256 } 1257 if reporting.Config.Type() != emailType { 1258 log.Errorf(c, "reporting is not email: namespace=%q reporting=%q config=%q", 1259 bug.Namespace, bugReporting.Name, reporting.Config.Type()) 1260 return nil 1261 } 1262 return &bugInfoResult{bug, bugKey, bugReporting, reporting} 1263 } 1264 1265 func ownMailingLists(c context.Context) []string { 1266 configs := []ReportingType{} 1267 for _, ns := range getConfig(c).Namespaces { 1268 for _, rep := range ns.Reporting { 1269 configs = append(configs, rep.Config) 1270 } 1271 if ns.Subsystems.Reminder == nil { 1272 continue 1273 } 1274 reminderConfig := ns.Subsystems.Reminder 1275 if reminderConfig.ModerationConfig != nil { 1276 configs = append(configs, reminderConfig.ModerationConfig) 1277 } 1278 if reminderConfig.Config != nil { 1279 configs = append(configs, reminderConfig.Config) 1280 } 1281 } 1282 ret := []string{} 1283 for _, config := range configs { 1284 emailConfig, ok := config.(*EmailConfig) 1285 if !ok { 1286 continue 1287 } 1288 ret = append(ret, emailConfig.Email) 1289 } 1290 return ret 1291 } 1292 1293 var ( 1294 // Use getSubjectParser(c) instead. 1295 defaultSubjectParser *subjectTitleParser 1296 subjectParserInit sync.Once 1297 errAmbiguousTitle = errors.New("ambiguous bug title") 1298 ) 1299 1300 func getSubjectParser(c context.Context) *subjectTitleParser { 1301 if getConfig(c) != getConfig(context.Background()) { 1302 // For the non-default config, do not cache the parser. 1303 return makeSubjectTitleParser(c) 1304 } 1305 subjectParserInit.Do(func() { 1306 defaultSubjectParser = makeSubjectTitleParser(c) 1307 }) 1308 return defaultSubjectParser 1309 } 1310 1311 func matchBugFromList(c context.Context, sender, subject string) (*bugInfoResult, error) { 1312 title, seq, err := getSubjectParser(c).parseTitle(subject) 1313 if err != nil { 1314 return nil, err 1315 } 1316 // Query all bugs with this title. 1317 var bugs []*Bug 1318 bugKeys, err := db.NewQuery("Bug"). 1319 Filter("Title=", title). 1320 GetAll(c, &bugs) 1321 if err != nil { 1322 return nil, fmt.Errorf("failed to fetch bugs: %w", err) 1323 } 1324 // Filter the bugs by the email. 1325 candidates := []*bugInfoResult{} 1326 for i, bug := range bugs { 1327 log.Infof(c, "processing bug %v", bug.displayTitle()) 1328 // We could add it to the query, but it's probably not worth it - we already have 1329 // tons of db indexes while the number of matching bugs should not be large anyway. 1330 if bug.Seq != seq { 1331 log.Infof(c, "bug's seq is %v, wanted %d", bug.Seq, seq) 1332 continue 1333 } 1334 if bug.sanitizeAccess(c, AccessPublic) != AccessPublic { 1335 log.Infof(c, "access denied") 1336 continue 1337 } 1338 reporting, bugReporting, _, _, err := currentReporting(c, bug) 1339 if err != nil || reporting == nil { 1340 log.Infof(c, "could not query reporting: %s", err) 1341 continue 1342 } 1343 emailConfig, ok := reporting.Config.(*EmailConfig) 1344 if !ok { 1345 log.Infof(c, "reporting is not EmailConfig (%q)", subject) 1346 continue 1347 } 1348 if !emailConfig.HandleListEmails { 1349 log.Infof(c, "the feature is disabled for the config") 1350 continue 1351 } 1352 if emailConfig.Email != sender { 1353 log.Infof(c, "config's Email is %v, wanted %v", emailConfig.Email, sender) 1354 continue 1355 } 1356 candidates = append(candidates, &bugInfoResult{ 1357 bug: bug, bugKey: bugKeys[i], 1358 bugReporting: bugReporting, reporting: reporting, 1359 }) 1360 } 1361 if len(candidates) > 1 { 1362 return nil, errAmbiguousTitle 1363 } else if len(candidates) == 0 { 1364 return nil, fmt.Errorf("unable to determine the bug") 1365 } 1366 return candidates[0], nil 1367 } 1368 1369 type subjectTitleParser struct { 1370 pattern *regexp.Regexp 1371 } 1372 1373 func makeSubjectTitleParser(c context.Context) *subjectTitleParser { 1374 stripPrefixes := []string{`R[eE]:`} 1375 for _, ns := range getConfig(c).Namespaces { 1376 for _, rep := range ns.Reporting { 1377 emailConfig, ok := rep.Config.(*EmailConfig) 1378 if !ok { 1379 continue 1380 } 1381 if ok && emailConfig.SubjectPrefix != "" { 1382 stripPrefixes = append(stripPrefixes, 1383 regexp.QuoteMeta(emailConfig.SubjectPrefix)) 1384 } 1385 } 1386 } 1387 rePrefixes := `^(?:(?:` + strings.Join(stripPrefixes, "|") + `)\s*)*` 1388 pattern := regexp.MustCompile(rePrefixes + `(?:\[[^\]]+\]\s*)*\s*(.*)$`) 1389 return &subjectTitleParser{pattern} 1390 } 1391 1392 func (p *subjectTitleParser) parseTitle(subject string) (string, int64, error) { 1393 rawTitle, err := p.parseFullTitle(subject) 1394 if err != nil { 1395 return "", 0, err 1396 } 1397 return splitDisplayTitle(rawTitle) 1398 } 1399 1400 func (p *subjectTitleParser) parseFullTitle(subject string) (string, error) { 1401 subject = strings.TrimSpace(subject) 1402 parts := p.pattern.FindStringSubmatch(subject) 1403 if parts == nil || parts[len(parts)-1] == "" { 1404 return "", fmt.Errorf("failed to extract the title") 1405 } 1406 return parts[len(parts)-1], nil 1407 } 1408 1409 func missingMailingLists(c context.Context, msg *email.Email, emailConfig *EmailConfig) []string { 1410 // We want to ensure that the incoming message is recorded on both our mailing list 1411 // and the archive mailing list (in case of Linux -- linux-kernel@vger.kernel.org). 1412 mailingLists := []string{ 1413 email.CanonicalEmail(emailConfig.Email), 1414 } 1415 if emailConfig.MailMaintainers { 1416 mailingLists = append(mailingLists, emailConfig.DefaultMaintainers...) 1417 } 1418 // Consider all recipients. 1419 exists := map[string]struct{}{} 1420 if msg.MailingList != "" { 1421 exists[msg.MailingList] = struct{}{} 1422 } 1423 for _, email := range msg.Cc { 1424 exists[email] = struct{}{} 1425 } 1426 var missing []string 1427 for _, list := range mailingLists { 1428 if _, ok := exists[list]; !ok { 1429 missing = append(missing, list) 1430 } 1431 } 1432 sort.Strings(missing) 1433 msg.Cc = append(msg.Cc, missing...) 1434 return missing 1435 } 1436 1437 func forwardEmail(c context.Context, msg *email.Email, mailingLists, cc []string, 1438 bugID, inReplyTo string) error { 1439 log.Infof(c, "forwarding email: id=%q from=%q to=%q", msg.MessageID, msg.Author, mailingLists) 1440 body := fmt.Sprintf(`For archival purposes, forwarding an incoming command email to 1441 %v. 1442 1443 *** 1444 1445 Subject: %s 1446 Author: %s 1447 1448 %s`, strings.Join(mailingLists, ", "), msg.Subject, msg.Author, msg.Body) 1449 from, err := email.AddAddrContext(fromAddr(c), bugID) 1450 if err != nil { 1451 return err 1452 } 1453 return sendEmail(c, &aemail.Message{ 1454 Sender: from, 1455 To: mailingLists, 1456 Cc: cc, 1457 Subject: email.ForwardedPrefix + msg.Subject, 1458 Body: body, 1459 Headers: mail.Header{"In-Reply-To": []string{inReplyTo}}, 1460 }) 1461 } 1462 1463 func sendMailText(c context.Context, subject, from string, to []string, replyTo, body string) error { 1464 msg := &aemail.Message{ 1465 Sender: from, 1466 To: to, 1467 Subject: subject, 1468 Body: body, 1469 } 1470 if replyTo != "" { 1471 msg.Headers = mail.Header{"In-Reply-To": []string{replyTo}} 1472 msg.Subject = replySubject(msg.Subject) 1473 } 1474 return sendEmail(c, msg) 1475 } 1476 1477 func replyTo(c context.Context, msg *email.Email, bugID, reply string) error { 1478 from, err := email.AddAddrContext(fromAddr(c), bugID) 1479 if err != nil { 1480 log.Errorf(c, "failed to build the From address: %v", err) 1481 return err 1482 } 1483 log.Infof(c, "sending reply: to=%q cc=%q subject=%q reply=%q", 1484 msg.Author, msg.Cc, msg.Subject, reply) 1485 replyMsg := &aemail.Message{ 1486 Sender: from, 1487 To: []string{msg.Author}, 1488 Cc: msg.Cc, 1489 Subject: replySubject(msg.Subject), 1490 Body: email.FormReply(msg, reply), 1491 Headers: mail.Header{"In-Reply-To": []string{msg.MessageID}}, 1492 } 1493 return sendEmail(c, replyMsg) 1494 } 1495 1496 // Sends email, can be stubbed for testing. 1497 var sendEmail = func(c context.Context, msg *aemail.Message) error { 1498 if err := aemail.Send(c, msg); err != nil { 1499 return fmt.Errorf("failed to send email: %w", err) 1500 } 1501 return nil 1502 } 1503 1504 func replySubject(subject string) string { 1505 if !strings.HasPrefix(subject, replySubjectPrefix) { 1506 return replySubjectPrefix + subject 1507 } 1508 return subject 1509 } 1510 1511 func ownEmail(c context.Context) string { 1512 if getConfig(c).OwnEmailAddress != "" { 1513 return getConfig(c).OwnEmailAddress 1514 } 1515 return fmt.Sprintf("syzbot@%v.appspotmail.com", appengine.AppID(c)) 1516 } 1517 1518 func fromAddr(c context.Context) string { 1519 return fmt.Sprintf("\"syzbot\" <%v>", ownEmail(c)) 1520 } 1521 1522 func ownEmails(c context.Context) []string { 1523 emails := []string{ownEmail(c)} 1524 config := getConfig(c) 1525 if config.ExtraOwnEmailAddresses != nil { 1526 emails = append(emails, config.ExtraOwnEmailAddresses...) 1527 } else if config.OwnEmailAddress == "" { 1528 // Now we use syzbot@ but we used to use bot@, so we add them both. 1529 emails = append(emails, fmt.Sprintf("bot@%v.appspotmail.com", appengine.AppID(c))) 1530 } 1531 return emails 1532 } 1533 1534 func sanitizeCC(c context.Context, cc []string) []string { 1535 var res []string 1536 for _, addr := range cc { 1537 mail, err := mail.ParseAddress(addr) 1538 if err != nil { 1539 continue 1540 } 1541 if email.CanonicalEmail(mail.Address) == ownEmail(c) { 1542 continue 1543 } 1544 res = append(res, mail.Address) 1545 } 1546 return res 1547 } 1548 1549 func externalLink(c context.Context, tag string, id int64) string { 1550 if id == 0 { 1551 return "" 1552 } 1553 return fmt.Sprintf("%v/x/%v?x=%v", appURL(c), textFilename(tag), strconv.FormatUint(uint64(id), 16)) 1554 } 1555 1556 func appURL(c context.Context) string { 1557 appURL := getConfig(c).AppURL 1558 if appURL != "" { 1559 return appURL 1560 } 1561 return fmt.Sprintf("https://%v.appspot.com", appengine.AppID(c)) 1562 } 1563 1564 var mailTemplates = html.CreateTextGlob("mail_*.txt")