mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
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]>
216 lines
6.3 KiB
Go
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,
|
|
}
|
|
}
|