Files
forge/services/mailer/mail.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

216 lines
6.3 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"html/template"
"io"
"mime"
"regexp"
"strings"
repo_model "github.com/hanzoai/git/models/repo"
user_model "github.com/hanzoai/git/models/user"
"github.com/hanzoai/git/modules/httplib"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/storage"
"github.com/hanzoai/git/modules/templates"
"github.com/hanzoai/git/modules/typesniffer"
sender_service "github.com/hanzoai/git/services/mailer/sender"
"golang.org/x/net/html"
)
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
func LoadedTemplates() *templates.MailRender {
return templates.MailRenderer()
}
// SendTestMail sends a test mail
func SendTestMail(email string) error {
if setting.MailService == nil {
// No mail service configured
return nil
}
return sender_service.Send(sender, sender_service.NewMessage(email, "Hanzo Git Test Email!", "Hanzo Git Test Email!"))
}
func sanitizeSubject(subject string) string {
runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
if len(runes) > mailMaxSubjectRunes {
runes = runes[:mailMaxSubjectRunes]
}
// Encode non-ASCII characters
return mime.QEncoding.Encode("utf-8", string(runes))
}
type mailAttachmentBase64Embedder struct {
doer *user_model.User
repo *repo_model.Repository
maxSize int64
estimateSize int64
}
func newMailAttachmentBase64Embedder(doer *user_model.User, repo *repo_model.Repository, maxSize int64) *mailAttachmentBase64Embedder {
return &mailAttachmentBase64Embedder{doer: doer, repo: repo, maxSize: maxSize}
}
func (b64embedder *mailAttachmentBase64Embedder) Base64InlineImages(ctx context.Context, body template.HTML) (template.HTML, error) {
doc, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
return "", fmt.Errorf("html.Parse failed: %w", err)
}
b64embedder.estimateSize = int64(len(string(body)))
var processNode func(*html.Node)
processNode = func(n *html.Node) {
if n.Type == html.ElementNode {
if n.Data == "img" {
for i, attr := range n.Attr {
if attr.Key == "src" {
attachmentSrc := attr.Val
dataURI, err := b64embedder.AttachmentSrcToBase64DataURI(ctx, attachmentSrc)
if err != nil {
// Not an error, just skip. This is probably an image from outside this forge instance.
log.Trace("Unable to embed attachment %q to mail body: %v", attachmentSrc, err)
} else {
n.Attr[i].Val = dataURI
}
break
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
processNode(c)
}
}
processNode(doc)
var buf bytes.Buffer
err = html.Render(&buf, doc)
if err != nil {
return "", fmt.Errorf("html.Render failed: %w", err)
}
return template.HTML(buf.String()), nil
}
func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ctx context.Context, attachmentSrc string) (string, error) {
parsedSrc := httplib.ParseGitSiteURL(ctx, attachmentSrc)
var attachmentUUID string
if parsedSrc != nil {
var ok bool
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RoutePath, "/attachments/")
if !ok {
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RepoSubPath, "/attachments/")
}
if !ok {
return "", errors.New("not an attachment")
}
}
attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
if err != nil {
return "", err
}
if attachment.RepoID != b64embedder.repo.ID {
return "", errors.New("attachment does not belong to the repository")
}
if attachment.Size+b64embedder.estimateSize > b64embedder.maxSize {
return "", errors.New("total embedded images exceed max limit")
}
fr, err := storage.Attachments.Open(attachment.RelativePath())
if err != nil {
return "", err
}
defer fr.Close()
lr := &io.LimitedReader{R: fr, N: b64embedder.maxSize + 1}
content, err := io.ReadAll(lr)
if err != nil {
return "", fmt.Errorf("LimitedReader ReadAll: %w", err)
}
mimeType := typesniffer.DetectContentType(content)
if !mimeType.IsImage() {
return "", errors.New("not an image")
}
encoded := base64.StdEncoding.EncodeToString(content)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType.GetMimeType(), encoded)
b64embedder.estimateSize += int64(len(dataURI))
return dataURI, nil
}
func fromDisplayName(u *user_model.User) string {
if setting.MailService.FromDisplayNameFormatTemplate != nil {
var buf bytes.Buffer
err := setting.MailService.FromDisplayNameFormatTemplate.Execute(&buf, map[string]any{
"DisplayName": u.DisplayName(),
"AppName": setting.AppName,
"Domain": setting.Domain,
})
if err == nil {
return mime.QEncoding.Encode("utf-8", buf.String())
}
log.Error("fromDisplayName: %w", err)
}
def := u.Name
if fullName := strings.TrimSpace(u.FullName); fullName != "" {
// use "Full Name (username)" for email's sender name if Full Name is not empty
def = fullName + " (" + u.Name + ")"
}
return def
}
func generateMetadataHeaders(repo *repo_model.Repository) map[string]string {
return map[string]string{
// https://datatracker.ietf.org/doc/html/rfc2919
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
// https://datatracker.ietf.org/doc/html/rfc2369
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
"X-Mailer": "Hanzo Git",
"X-Git-Repository": repo.Name,
"X-Git-Repository-Path": repo.FullName(),
"X-Git-Repository-Link": repo.HTMLURL(),
"X-GitLab-Project": repo.Name,
"X-GitLab-Project-Path": repo.FullName(),
}
}
func generateSenderRecipientHeaders(doer, recipient *user_model.User) map[string]string {
return map[string]string{
"X-Git-Sender": doer.Name,
"X-Git-Recipient": recipient.Name,
"X-Git-Recipient-Address": recipient.Email,
"X-GitHub-Sender": doer.Name,
"X-GitHub-Recipient": recipient.Name,
"X-GitHub-Recipient-Address": recipient.Email,
}
}
func generateReasonHeaders(reason string) map[string]string {
return map[string]string{
"X-Git-Reason": reason,
"X-GitHub-Reason": reason,
"X-GitLab-NotificationReason": reason,
}
}