Files
gitea-MIRROR/services/mailer/mail_workflow_run.go
2026-09-09 17:58:27 +00:00

180 lines
5.5 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"cmp"
"context"
"embed"
"fmt"
"slices"
"time"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/translation"
"gitea.dev/modules/util"
sender_service "gitea.dev/services/mailer/sender"
)
const tplWorkflowRun templates.TplName = "mail/repo/actions/workflow_run"
//go:embed icons/*.png
var iconsFS embed.FS
// LoadMailIcon returns an embedded mail icon.
func LoadMailIcon(name string) ([]byte, error) {
return iconsFS.ReadFile("icons/" + name)
}
type workflowRunMailJob struct {
HTMLURL string
Name string
Status actions_model.Status
StatusIconCID string
StatusIconAlt string
StatusClass string
Attempt int64
Duration time.Duration
}
func workflowRunJobStatusPresentation(status actions_model.Status) (icon, class string) {
switch {
case status.IsSuccess():
return "status-success.png", "status-success"
case status.IsCancelled():
return "status-cancelled.png", "status-neutral"
case status.IsSkipped():
return "status-skipped.png", "status-neutral"
default:
return "status-failure.png", "status-failure"
}
}
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, recipient *user_model.User) error {
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
if err != nil {
return err
}
for _, job := range jobs {
if !job.Status.IsDone() {
log.Debug("composeAndSendActionsWorkflowRunStatusEmail: A job is not done. Will not compose and send actions email.")
return nil
}
}
locale := translation.NewLocale(recipient.Language)
slices.SortStableFunc(jobs, func(a, b *actions_model.ActionRunJob) int {
if a.Status.IsSuccess() != b.Status.IsSuccess() {
return util.Iif(a.Status.IsSuccess(), 1, -1)
}
return cmp.Compare(a.Status, b.Status)
})
mailJobs := make([]workflowRunMailJob, 0, len(jobs))
var embeds []sender_service.EmbeddedFile
embedded := make(container.Set[string])
for _, job := range jobs {
icon, class := workflowRunJobStatusPresentation(job.Status)
contentID := fmt.Sprintf("%s.actions-run-%d@%s", icon, run.ID, setting.Domain)
mailJobs = append(mailJobs, workflowRunMailJob{
HTMLURL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.HTMLURL(ctx), run.ID, job.ID),
Name: job.Name,
Status: job.Status,
StatusIconCID: contentID,
StatusIconAlt: job.Status.LocaleString(locale),
StatusClass: class,
Attempt: job.Attempt,
Duration: job.Duration(),
})
if !embedded.Add(icon) {
continue
}
content, err := LoadMailIcon(icon)
if err != nil {
return err
}
embeds = append(embeds, sender_service.EmbeddedFile{Name: icon, ContentID: contentID, Content: content})
}
var runStatusTrString string
switch run.Status {
case actions_model.StatusSuccess:
runStatusTrString = "mail.repo.actions.jobs.all_succeeded"
case actions_model.StatusFailure:
runStatusTrString = "mail.repo.actions.jobs.all_failed"
for _, job := range jobs {
if !job.Status.IsFailure() {
runStatusTrString = "mail.repo.actions.jobs.some_not_successful"
break
}
}
case actions_model.StatusCancelled:
runStatusTrString = "mail.repo.actions.jobs.all_cancelled"
}
subject := fmt.Sprintf("[%s] %s: %s (%s - %s)", repo.FullName(), run.Status.LocaleString(locale), run.WorkflowID, run.PrettyRef(), base.ShortSha(run.CommitSHA))
var mailBody bytes.Buffer
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplWorkflowRun), map[string]any{
"Subject": subject,
"Repo": repo,
"Run": run,
"RunStatusText": locale.TrString(runStatusTrString),
"Jobs": mailJobs,
"locale": locale,
}); err != nil {
return err
}
log.Trace("Sending actions email to %s (UID: %d)", recipient.Name, recipient.ID)
msg := sender_service.NewMessageFrom(recipient.Email, fromDisplayName(recipient), setting.MailService.FromEmail, subject, mailBody.String())
msg.Info = subject
msg.Embeds = embeds
for key, value := range generateSenderRecipientHeaders(recipient, recipient) {
msg.SetHeader(key, value)
}
for key, value := range generateMetadataHeaders(repo) {
msg.SetHeader(key, value)
}
msg.SetHeader("Message-ID", fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain))
SendAsync(msg)
return nil
}
func MailActionsTrigger(ctx context.Context, recipient *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) error {
if setting.MailService == nil {
return nil
}
if !run.Status.IsDone() || run.Status.IsSkipped() {
return nil
}
if !recipient.IsMailable() {
return nil
}
notifyPref, err := user_model.GetUserSetting(ctx, recipient.ID,
user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
if err != nil {
return err
}
// "disabled" never sends
if notifyPref == user_model.SettingEmailNotificationGiteaActionsDisabled {
return nil
}
// "failure-only" skips non-failure runs
if notifyPref != user_model.SettingEmailNotificationGiteaActionsAll && !run.Status.IsFailure() {
return nil
}
log.Debug("MailActionsTrigger: Initiate email composition")
return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, recipient)
}