aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorwxiaoguang <wxiaoguang@gmail.com>2025-07-09 10:25:25 +0800
committerGitHub <noreply@github.com>2025-07-09 10:25:25 +0800
commit55f350542ce6db6621ceed987d3d11b8ab5dd2dd (patch)
tree04c0641a80426d85a8e8bd5f8a91803cf1e82c1b
parent2cc33686104da45b426eb4bb4892468f612ac404 (diff)
downloadgitea-55f350542ce6db6621ceed987d3d11b8ab5dd2dd.tar.gz
gitea-55f350542ce6db6621ceed987d3d11b8ab5dd2dd.zip
Refactor mail template and support preview (#34990)
-rw-r--r--modules/templates/mailer.go33
-rw-r--r--routers/web/devtest/mail_preview.go58
-rw-r--r--routers/web/web.go2
-rw-r--r--services/mailer/mail.go15
-rw-r--r--services/mailer/mail_issue_common.go10
-rw-r--r--services/mailer/mail_release.go2
-rw-r--r--services/mailer/mail_repo.go2
-rw-r--r--services/mailer/mail_team_invite.go2
-rw-r--r--services/mailer/mail_test.go40
-rw-r--r--services/mailer/mail_user.go8
-rw-r--r--services/mailer/mailer.go2
-rw-r--r--templates/devtest/devtest-header.tmpl1
-rw-r--r--templates/devtest/mail-preview.tmpl27
-rw-r--r--templates/mail/auth/activate.mock.yml3
14 files changed, 156 insertions, 49 deletions
diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go
index 310d645328..c43b760777 100644
--- a/modules/templates/mailer.go
+++ b/modules/templates/mailer.go
@@ -9,6 +9,7 @@ import (
"html/template"
"regexp"
"strings"
+ "sync/atomic"
texttmpl "text/template"
"code.gitea.io/gitea/modules/log"
@@ -16,6 +17,12 @@ import (
"code.gitea.io/gitea/modules/util"
)
+type MailTemplates struct {
+ TemplateNames []string
+ BodyTemplates *template.Template
+ SubjectTemplates *texttmpl.Template
+}
+
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
@@ -52,16 +59,17 @@ func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template,
return nil
}
-// Mailer provides the templates required for sending notification mails.
-func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
- subjectTemplates := texttmpl.New("")
- bodyTemplates := template.New("")
-
- subjectTemplates.Funcs(mailSubjectTextFuncMap())
- bodyTemplates.Funcs(NewFuncMap())
-
+// LoadMailTemplates provides the templates required for sending notification mails.
+func LoadMailTemplates(ctx context.Context, loadedTemplates *atomic.Pointer[MailTemplates]) {
assetFS := AssetFS()
refreshTemplates := func(firstRun bool) {
+ var templateNames []string
+ subjectTemplates := texttmpl.New("")
+ bodyTemplates := template.New("")
+
+ subjectTemplates.Funcs(mailSubjectTextFuncMap())
+ bodyTemplates.Funcs(NewFuncMap())
+
if !firstRun {
log.Trace("Reloading mail templates")
}
@@ -81,6 +89,7 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
if firstRun {
log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
}
+ templateNames = append(templateNames, tmplName)
if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil {
if firstRun {
log.Fatal("Failed to parse mail template, err: %v", err)
@@ -88,6 +97,12 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
log.Error("Failed to parse mail template, err: %v", err)
}
}
+ loaded := &MailTemplates{
+ TemplateNames: templateNames,
+ BodyTemplates: bodyTemplates,
+ SubjectTemplates: subjectTemplates,
+ }
+ loadedTemplates.Store(loaded)
}
refreshTemplates(true)
@@ -99,6 +114,4 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
refreshTemplates(false)
})
}
-
- return subjectTemplates, bodyTemplates
}
diff --git a/routers/web/devtest/mail_preview.go b/routers/web/devtest/mail_preview.go
new file mode 100644
index 0000000000..79dd441eab
--- /dev/null
+++ b/routers/web/devtest/mail_preview.go
@@ -0,0 +1,58 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package devtest
+
+import (
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/mailer"
+
+ "gopkg.in/yaml.v3"
+)
+
+func MailPreviewRender(ctx *context.Context) {
+ tmplName := ctx.PathParam("*")
+ mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".mock.yml")
+ mockData := map[string]any{}
+ if err == nil {
+ err = yaml.Unmarshal(mockDataContent, &mockData)
+ if err != nil {
+ http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+ mockData["locale"] = ctx.Locale
+ err = mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(ctx.Resp, tmplName, mockData)
+ if err != nil {
+ _, _ = ctx.Resp.Write([]byte(err.Error()))
+ }
+}
+
+func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
+ tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
+ if tmplSubject == nil {
+ ctx.Data["RenderMailSubject"] = "default subject"
+ } else {
+ var buf strings.Builder
+ err := tmplSubject.Execute(&buf, nil)
+ if err != nil {
+ ctx.Data["RenderMailSubject"] = err.Error()
+ } else {
+ ctx.Data["RenderMailSubject"] = buf.String()
+ }
+ }
+ ctx.Data["RenderMailTemplateName"] = tmplName
+}
+
+func MailPreview(ctx *context.Context) {
+ ctx.Data["MailTemplateNames"] = mailer.LoadedTemplates().TemplateNames
+ tmplName := ctx.FormString("tmpl")
+ if tmplName != "" {
+ prepareMailPreviewRender(ctx, tmplName)
+ }
+ ctx.HTML(http.StatusOK, "devtest/mail-preview")
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 66c3a2da09..ddea468d17 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1659,6 +1659,8 @@ func registerWebRoutes(m *web.Router) {
m.Group("/devtest", func() {
m.Any("", devtest.List)
m.Any("/fetch-action-test", devtest.FetchActionTest)
+ m.Any("/mail-preview", devtest.MailPreview)
+ m.Any("/mail-preview/*", devtest.MailPreviewRender)
m.Any("/{sub}", devtest.TmplCommon)
m.Get("/repo-action-view/{run}/{job}", devtest.MockActionsView)
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index aa51cbdbcf..b7602e0321 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -15,7 +15,7 @@ import (
"mime"
"regexp"
"strings"
- texttmpl "text/template"
+ "sync/atomic"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/typesniffer"
sender_service "code.gitea.io/gitea/services/mailer/sender"
@@ -31,11 +32,13 @@ import (
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
-var (
- bodyTemplates *template.Template
- subjectTemplates *texttmpl.Template
- subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
-)
+var loadedTemplates atomic.Pointer[templates.MailTemplates]
+
+var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
+
+func LoadedTemplates() *templates.MailTemplates {
+ return loadedTemplates.Load()
+}
// SendTestMail sends a test mail
func SendTestMail(email string) error {
diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go
index ebfd52162c..107f57772c 100644
--- a/services/mailer/mail_issue_common.go
+++ b/services/mailer/mail_issue_common.go
@@ -119,7 +119,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
}
var mailSubject bytes.Buffer
- if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
+ if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
subject = sanitizeSubject(mailSubject.String())
if subject == "" {
subject = fallback
@@ -134,7 +134,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
}
@@ -260,14 +260,14 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act
}
template = typeName + "/" + name
- ok := bodyTemplates.Lookup(template) != nil
+ ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil
if !ok && typeName != "issue" {
template = "issue/" + name
- ok = bodyTemplates.Lookup(template) != nil
+ ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
}
if !ok {
template = typeName + "/default"
- ok = bodyTemplates.Lookup(template) != nil
+ ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
}
if !ok {
template = "issue/default"
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index bfff73c39c..fd97fb5312 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -79,7 +79,7 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err)
return
}
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
index b6b2d5ca07..1ec7995ab9 100644
--- a/services/mailer/mail_repo.go
+++ b/services/mailer/mail_repo.go
@@ -78,7 +78,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
"Destination": destination,
}
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
return err
}
diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go
index f4aa788dec..034dc14e3d 100644
--- a/services/mailer/mail_team_invite.go
+++ b/services/mailer/mail_team_invite.go
@@ -62,7 +62,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
}
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err)
return err
}
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index b15949f352..3996796beb 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -25,6 +25,7 @@ import (
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/attachment"
sender_service "code.gitea.io/gitea/services/mailer/sender"
@@ -95,6 +96,13 @@ func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_mo
return user, repo, issue, att1, att2
}
+func prepareMailTemplates(name, subjectTmpl, bodyTmpl string) {
+ loadedTemplates.Store(&templates.MailTemplates{
+ SubjectTemplates: texttmpl.Must(texttmpl.New(name).Parse(subjectTmpl)),
+ BodyTemplates: template.Must(template.New(name).Parse(bodyTmpl)),
+ })
+}
+
func TestComposeIssueComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t)
@@ -107,8 +115,7 @@ func TestComposeIssueComment(t *testing.T) {
setting.IncomingEmail.Enabled = true
defer func() { setting.IncomingEmail.Enabled = false }()
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
+ prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
@@ -153,8 +160,7 @@ func TestComposeIssueComment(t *testing.T) {
func TestMailMentionsComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t)
comment.Poster = doer
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
+ prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
mails := 0
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
@@ -169,9 +175,7 @@ func TestMailMentionsComment(t *testing.T) {
func TestComposeIssueMessage(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
-
+ prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
@@ -200,15 +204,14 @@ func TestTemplateSelection(t *testing.T) {
doer, repo, issue, comment := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject"))
- texttmpl.Must(subjectTemplates.New("issue/new").Parse("issue/new/subject"))
- texttmpl.Must(subjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
- texttmpl.Must(subjectTemplates.New("issue/close").Parse("")) // Must default to fallback subject
+ prepareMailTemplates("issue/default", "issue/default/subject", "issue/default/body")
- bodyTemplates = template.Must(template.New("issue/default").Parse("issue/default/body"))
- template.Must(bodyTemplates.New("issue/new").Parse("issue/new/body"))
- template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body"))
- template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/new").Parse("issue/new/subject"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/close").Parse("")) // Must default to a fallback subject
+ template.Must(LoadedTemplates().BodyTemplates.New("issue/new").Parse("issue/new/body"))
+ template.Must(LoadedTemplates().BodyTemplates.New("pull/comment").Parse("pull/comment/body"))
+ template.Must(LoadedTemplates().BodyTemplates.New("issue/close").Parse("issue/close/body"))
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
subject := msg.ToMessage().GetGenHeader("Subject")
@@ -253,9 +256,7 @@ func TestTemplateServices(t *testing.T) {
expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User,
actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string,
) {
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject))
- bodyTemplates = template.Must(template.New("issue/default").Parse(tplBody))
-
+ prepareMailTemplates("issue/default", tplSubject, tplBody)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
msg := testComposeIssueCommentMessage(t, &mailComment{
Issue: issue, Doer: doer, ActionType: actionType,
@@ -512,8 +513,7 @@ func TestEmbedBase64Images(t *testing.T) {
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
t.Run("ComposeMessage", func(t *testing.T) {
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
+ prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
diff --git a/services/mailer/mail_user.go b/services/mailer/mail_user.go
index 5a200a5fa7..68df81f6a3 100644
--- a/services/mailer/mail_user.go
+++ b/services/mailer/mail_user.go
@@ -39,7 +39,7 @@ func sendUserMail(language string, u *user_model.User, tpl templates.TplName, co
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err)
return
}
@@ -90,7 +90,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err)
return
}
@@ -118,7 +118,7 @@ func SendRegisterNotifyMail(u *user_model.User) {
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
log.Error("Template: %v", err)
return
}
@@ -149,7 +149,7 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
log.Error("Template: %v", err)
return
}
diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go
index bcd4facca9..db00aac4f1 100644
--- a/services/mailer/mailer.go
+++ b/services/mailer/mailer.go
@@ -43,7 +43,7 @@ func NewContext(ctx context.Context) {
sender = &sender_service.SMTPSender{}
}
- subjectTemplates, bodyTemplates = templates.Mailer(ctx)
+ templates.LoadMailTemplates(ctx, &loadedTemplates)
mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
for _, msg := range items {
diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl
index ee08545640..0775dccc2d 100644
--- a/templates/devtest/devtest-header.tmpl
+++ b/templates/devtest/devtest-header.tmpl
@@ -1,2 +1,3 @@
{{template "base/head" ctx.RootData}}
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
+{{template "base/alert" .}}
diff --git a/templates/devtest/mail-preview.tmpl b/templates/devtest/mail-preview.tmpl
new file mode 100644
index 0000000000..9a3d792904
--- /dev/null
+++ b/templates/devtest/mail-preview.tmpl
@@ -0,0 +1,27 @@
+{{template "devtest/devtest-header"}}
+<div class="page-content devtest ui container">
+ <div class="flex-text-block tw-flex-wrap">
+ {{range $templateName := .MailTemplateNames}}
+ <a class="ui button" href="?tmpl={{$templateName}}">{{$templateName}}</a>
+ {{else}}
+ <p>Mailer service is not enabled or no template is found</p>
+ {{end}}
+ </div>
+
+ {{if .RenderMailTemplateName}}
+ <div class="tw-my-2">
+ <div>Preview of: {{.RenderMailTemplateName}}</div>
+ <div>Subject: {{.RenderMailSubject}}</div>
+ <iframe src="{{AppSubUrl}}/devtest/mail-preview/{{.RenderMailTemplateName}}" class="mail-preview-body"></iframe>
+ <style>
+ .mail-preview-body {
+ border: 1px solid #ccc;
+ width: 100%;
+ height: 400px;
+ overflow: auto;
+ }
+ </style>
+ </div>
+ {{end}}
+</div>
+{{template "devtest/devtest-footer"}}
diff --git a/templates/mail/auth/activate.mock.yml b/templates/mail/auth/activate.mock.yml
new file mode 100644
index 0000000000..f5519a6f6c
--- /dev/null
+++ b/templates/mail/auth/activate.mock.yml
@@ -0,0 +1,3 @@
+DisplayName: User Display Name
+Code: The-Activation-Code
+ActiveCodeLives: 24h