diff --git a/routers/private/mail.go b/routers/private/mail.go index cf3abb31c6..6c33467af7 100644 --- a/routers/private/mail.go +++ b/routers/private/mail.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" + sender_service "code.gitea.io/gitea/services/mailer/sender" ) // SendEmail pushes messages to mail queue @@ -81,7 +82,7 @@ func SendEmail(ctx *context.PrivateContext) { func sendEmail(ctx *context.PrivateContext, subject, message string, to []string) { for _, email := range to { - msg := mailer.NewMessage(email, subject, message) + msg := sender_service.NewMessage(email, subject, message) mailer.SendAsync(msg) } diff --git a/services/mailer/mail.go b/services/mailer/mail.go index 162e497dc0..9f69713c61 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -29,9 +29,8 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/translation" incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" + sender_service "code.gitea.io/gitea/services/mailer/sender" "code.gitea.io/gitea/services/mailer/token" - - "gopkg.in/gomail.v2" ) const ( @@ -60,7 +59,7 @@ func SendTestMail(email string) error { // No mail service configured return nil } - return gomail.Send(Sender, NewMessage(email, "Gitea Test Email!", "Gitea Test Email!").ToMessage()) + return sender_service.Send(sender, sender_service.NewMessage(email, "Gitea Test Email!", "Gitea Test Email!")) } // sendUserMail sends a mail to the user @@ -82,7 +81,7 @@ func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, s return } - msg := NewMessage(u.EmailTo(), subject, content.String()) + msg := sender_service.NewMessage(u.EmailTo(), subject, content.String()) msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info) SendAsync(msg) @@ -130,7 +129,7 @@ func SendActivateEmailMail(u *user_model.User, email string) { return } - msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String()) + msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String()) msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID) SendAsync(msg) @@ -158,7 +157,7 @@ func SendRegisterNotifyMail(u *user_model.User) { return } - msg := NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String()) + msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String()) msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID) SendAsync(msg) @@ -189,13 +188,13 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) return } - msg := NewMessage(u.EmailTo(), subject, content.String()) + msg := sender_service.NewMessage(u.EmailTo(), subject, content.String()) msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID) SendAsync(msg) } -func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*Message, error) { +func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) { var ( subject string link string @@ -306,9 +305,9 @@ func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipient return nil, err } - msgs := make([]*Message, 0, len(recipients)) + msgs := make([]*sender_service.Message, 0, len(recipients)) for _, recipient := range recipients { - msg := NewMessageFrom( + msg := sender_service.NewMessageFrom( recipient.Email, fromDisplayName(ctx.Doer), setting.MailService.FromEmail, diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 3298c2273a..7edd42260a 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" + sender_service "code.gitea.io/gitea/services/mailer/sender" ) const ( @@ -82,11 +83,11 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re return } - msgs := make([]*Message, 0, len(tos)) + msgs := make([]*sender_service.Message, 0, len(tos)) publisherName := fromDisplayName(rel.Publisher) msgID := generateMessageIDForRelease(rel) for _, to := range tos { - msg := NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String()) + msg := sender_service.NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String()) msg.Info = subject msg.SetHeader("Message-ID", msgID) msgs = append(msgs, msg) diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go index 7003584786..5f80654bcd 100644 --- a/services/mailer/mail_repo.go +++ b/services/mailer/mail_repo.go @@ -13,6 +13,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" + sender_service "code.gitea.io/gitea/services/mailer/sender" ) // SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created @@ -79,7 +80,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U } for _, to := range emailTos { - msg := NewMessageFrom(to.EmailTo(), fromDisplayName(doer), setting.MailService.FromEmail, subject, content.String()) + msg := sender_service.NewMessageFrom(to.EmailTo(), fromDisplayName(doer), setting.MailService.FromEmail, subject, content.String()) msg.Info = fmt.Sprintf("UID: %d, repository pending transfer notification", newOwner.ID) SendAsync(msg) diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go index ceecefa50f..4f2d5e4ca7 100644 --- a/services/mailer/mail_team_invite.go +++ b/services/mailer/mail_team_invite.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" + sender_service "code.gitea.io/gitea/services/mailer/sender" ) const ( @@ -67,7 +68,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod return err } - msg := NewMessage(invite.Email, subject, mailBody.String()) + msg := sender_service.NewMessage(invite.Email, subject, mailBody.String()) msg.Info = subject SendAsync(msg) diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 40fd21dea5..e81f5cbb34 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -23,6 +23,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" + sender_service "code.gitea.io/gitea/services/mailer/sender" "github.com/stretchr/testify/assert" ) @@ -167,7 +168,7 @@ func TestTemplateSelection(t *testing.T) { template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body")) template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body")) - expect := func(t *testing.T, msg *Message, expSubject, expBody string) { + expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) { subject := msg.ToMessage().GetHeader("Subject") msgbuf := new(bytes.Buffer) _, _ = msg.ToMessage().WriteTo(msgbuf) @@ -252,7 +253,7 @@ func TestTemplateServices(t *testing.T) { "//Re: //") } -func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *Message { +func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *sender_service.Message { msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, info) assert.NoError(t, err) assert.Len(t, msgs, 1) diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go index 5cb6d03521..0058710a5d 100644 --- a/services/mailer/mailer.go +++ b/services/mailer/mailer.go @@ -1,395 +1,24 @@ -// Copyright 2014 The Gogs Authors. All rights reserved. -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package mailer import ( - "bytes" "context" - "crypto/tls" - "fmt" - "hash/fnv" - "io" - "net" - "net/smtp" - "os" - "os/exec" - "strings" - "time" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + sender_service "code.gitea.io/gitea/services/mailer/sender" notify_service "code.gitea.io/gitea/services/notify" - - ntlmssp "github.com/Azure/go-ntlmssp" - "github.com/jaytaylor/html2text" - "gopkg.in/gomail.v2" ) -// Message mail body and log info -type Message struct { - Info string // Message information for log purpose. - FromAddress string - FromDisplayName string - To string // Use only one recipient to prevent leaking of addresses - ReplyTo string - Subject string - Date time.Time - Body string - Headers map[string][]string -} +var mailQueue *queue.WorkerPoolQueue[*sender_service.Message] -// ToMessage converts a Message to gomail.Message -func (m *Message) ToMessage() *gomail.Message { - msg := gomail.NewMessage() - msg.SetAddressHeader("From", m.FromAddress, m.FromDisplayName) - msg.SetHeader("To", m.To) - if m.ReplyTo != "" { - msg.SetHeader("Reply-To", m.ReplyTo) - } - for header := range m.Headers { - msg.SetHeader(header, m.Headers[header]...) - } - - if setting.MailService.SubjectPrefix != "" { - msg.SetHeader("Subject", setting.MailService.SubjectPrefix+" "+m.Subject) - } else { - msg.SetHeader("Subject", m.Subject) - } - msg.SetDateHeader("Date", m.Date) - msg.SetHeader("X-Auto-Response-Suppress", "All") - - plainBody, err := html2text.FromString(m.Body) - if err != nil || setting.MailService.SendAsPlainText { - if strings.Contains(base.TruncateString(m.Body, 100), "") { - log.Warn("Mail contains HTML but configured to send as plain text.") - } - msg.SetBody("text/plain", plainBody) - } else { - msg.SetBody("text/plain", plainBody) - msg.AddAlternative("text/html", m.Body) - } - - if len(msg.GetHeader("Message-ID")) == 0 { - msg.SetHeader("Message-ID", m.generateAutoMessageID()) - } - - for k, v := range setting.MailService.OverrideHeader { - if len(msg.GetHeader(k)) != 0 { - log.Debug("Mailer override header '%s' as per config", k) - } - msg.SetHeader(k, v...) - } - - return msg -} - -// SetHeader adds additional headers to a message -func (m *Message) SetHeader(field string, value ...string) { - m.Headers[field] = value -} - -func (m *Message) generateAutoMessageID() string { - dateMs := m.Date.UnixNano() / 1e6 - h := fnv.New64() - if len(m.To) > 0 { - _, _ = h.Write([]byte(m.To)) - } - _, _ = h.Write([]byte(m.Subject)) - _, _ = h.Write([]byte(m.Body)) - return fmt.Sprintf("", dateMs, h.Sum64(), setting.Domain) -} - -// NewMessageFrom creates new mail message object with custom From header. -func NewMessageFrom(to, fromDisplayName, fromAddress, subject, body string) *Message { - log.Trace("NewMessageFrom (body):\n%s", body) - - return &Message{ - FromAddress: fromAddress, - FromDisplayName: fromDisplayName, - To: to, - Subject: subject, - Date: time.Now(), - Body: body, - Headers: map[string][]string{}, - } -} - -// NewMessage creates new mail message object with default From header. -func NewMessage(to, subject, body string) *Message { - return NewMessageFrom(to, setting.MailService.FromName, setting.MailService.FromEmail, subject, body) -} - -type loginAuth struct { - username, password string -} - -// LoginAuth SMTP AUTH LOGIN Auth Handler -func LoginAuth(username, password string) smtp.Auth { - return &loginAuth{username, password} -} - -// Start start SMTP login auth -func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { - return "LOGIN", []byte{}, nil -} - -// Next next step of SMTP login auth -func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { - if more { - switch string(fromServer) { - case "Username:": - return []byte(a.username), nil - case "Password:": - return []byte(a.password), nil - default: - return nil, fmt.Errorf("unknown fromServer: %s", string(fromServer)) - } - } - return nil, nil -} - -type ntlmAuth struct { - username, password, domain string - domainNeeded bool -} - -// NtlmAuth SMTP AUTH NTLM Auth Handler -func NtlmAuth(username, password string) smtp.Auth { - user, domain, domainNeeded := ntlmssp.GetDomain(username) - return &ntlmAuth{user, password, domain, domainNeeded} -} - -// Start starts SMTP NTLM Auth -func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { - negotiateMessage, err := ntlmssp.NewNegotiateMessage(a.domain, "") - return "NTLM", negotiateMessage, err -} - -// Next next step of SMTP ntlm auth -func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) { - if more { - if len(fromServer) == 0 { - return nil, fmt.Errorf("ntlm ChallengeMessage is empty") - } - authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded) - return authenticateMessage, err - } - return nil, nil -} - -// Sender SMTP mail sender -type smtpSender struct{} - -// Send send email -func (s *smtpSender) Send(from string, to []string, msg io.WriterTo) error { - opts := setting.MailService - - var network string - var address string - if opts.Protocol == "smtp+unix" { - network = "unix" - address = opts.SMTPAddr - } else { - network = "tcp" - address = net.JoinHostPort(opts.SMTPAddr, opts.SMTPPort) - } - - conn, err := net.Dial(network, address) - if err != nil { - return fmt.Errorf("failed to establish network connection to SMTP server: %w", err) - } - defer conn.Close() - - var tlsconfig *tls.Config - if opts.Protocol == "smtps" || opts.Protocol == "smtp+starttls" { - tlsconfig = &tls.Config{ - InsecureSkipVerify: opts.ForceTrustServerCert, - ServerName: opts.SMTPAddr, - } - - if opts.UseClientCert { - cert, err := tls.LoadX509KeyPair(opts.ClientCertFile, opts.ClientKeyFile) - if err != nil { - return fmt.Errorf("could not load SMTP client certificate: %w", err) - } - tlsconfig.Certificates = []tls.Certificate{cert} - } - } - - if opts.Protocol == "smtps" { - conn = tls.Client(conn, tlsconfig) - } - - host := "localhost" - if opts.Protocol == "smtp+unix" { - host = opts.SMTPAddr - } - client, err := smtp.NewClient(conn, host) - if err != nil { - return fmt.Errorf("could not initiate SMTP session: %w", err) - } - - if opts.EnableHelo { - hostname := opts.HeloHostname - if len(hostname) == 0 { - hostname, err = os.Hostname() - if err != nil { - return fmt.Errorf("could not retrieve system hostname: %w", err) - } - } - - if err = client.Hello(hostname); err != nil { - return fmt.Errorf("failed to issue HELO command: %w", err) - } - } - - if opts.Protocol == "smtp+starttls" { - hasStartTLS, _ := client.Extension("STARTTLS") - if hasStartTLS { - if err = client.StartTLS(tlsconfig); err != nil { - return fmt.Errorf("failed to start TLS connection: %w", err) - } - } else { - log.Warn("StartTLS requested, but SMTP server does not support it; falling back to regular SMTP") - } - } - - canAuth, options := client.Extension("AUTH") - if len(opts.User) > 0 { - if !canAuth { - return fmt.Errorf("SMTP server does not support AUTH, but credentials provided") - } - - var auth smtp.Auth - - if strings.Contains(options, "CRAM-MD5") { - auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd) - } else if strings.Contains(options, "PLAIN") { - auth = smtp.PlainAuth("", opts.User, opts.Passwd, host) - } else if strings.Contains(options, "LOGIN") { - // Patch for AUTH LOGIN - auth = LoginAuth(opts.User, opts.Passwd) - } else if strings.Contains(options, "NTLM") { - auth = NtlmAuth(opts.User, opts.Passwd) - } - - if auth != nil { - if err = client.Auth(auth); err != nil { - return fmt.Errorf("failed to authenticate SMTP: %w", err) - } - } - } - - if opts.OverrideEnvelopeFrom { - if err = client.Mail(opts.EnvelopeFrom); err != nil { - return fmt.Errorf("failed to issue MAIL command: %w", err) - } - } else { - if err = client.Mail(from); err != nil { - return fmt.Errorf("failed to issue MAIL command: %w", err) - } - } - - for _, rec := range to { - if err = client.Rcpt(rec); err != nil { - return fmt.Errorf("failed to issue RCPT command: %w", err) - } - } - - w, err := client.Data() - if err != nil { - return fmt.Errorf("failed to issue DATA command: %w", err) - } else if _, err = msg.WriteTo(w); err != nil { - return fmt.Errorf("SMTP write failed: %w", err) - } else if err = w.Close(); err != nil { - return fmt.Errorf("SMTP close failed: %w", err) - } - - return client.Quit() -} - -// Sender sendmail mail sender -type sendmailSender struct{} - -// Send send email -func (s *sendmailSender) Send(from string, to []string, msg io.WriterTo) error { - var err error - var closeError error - var waitError error - - envelopeFrom := from - if setting.MailService.OverrideEnvelopeFrom { - envelopeFrom = setting.MailService.EnvelopeFrom - } - - args := []string{"-f", envelopeFrom, "-i"} - args = append(args, setting.MailService.SendmailArgs...) - args = append(args, to...) - log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args) - - desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args) - - ctx, _, finished := process.GetManager().AddContextTimeout(graceful.GetManager().HammerContext(), setting.MailService.SendmailTimeout, desc) - defer finished() - - cmd := exec.CommandContext(ctx, setting.MailService.SendmailPath, args...) - pipe, err := cmd.StdinPipe() - if err != nil { - return err - } - process.SetSysProcAttribute(cmd) - - if err = cmd.Start(); err != nil { - _ = pipe.Close() - return err - } - - if setting.MailService.SendmailConvertCRLF { - buf := &strings.Builder{} - _, err = msg.WriteTo(buf) - if err == nil { - _, err = strings.NewReplacer("\r\n", "\n").WriteString(pipe, buf.String()) - } - } else { - _, err = msg.WriteTo(pipe) - } - - // we MUST close the pipe or sendmail will hang waiting for more of the message - // Also we should wait on our sendmail command even if something fails - closeError = pipe.Close() - waitError = cmd.Wait() - if err != nil { - return err - } else if closeError != nil { - return closeError - } - return waitError -} - -// Sender sendmail mail sender -type dummySender struct{} - -// Send send email -func (s *dummySender) Send(from string, to []string, msg io.WriterTo) error { - buf := bytes.Buffer{} - if _, err := msg.WriteTo(&buf); err != nil { - return err - } - log.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String()) - return nil -} - -var mailQueue *queue.WorkerPoolQueue[*Message] - -// Sender sender for sending mail synchronously -var Sender gomail.Sender +// sender sender for sending mail synchronously +var sender sender_service.Sender // NewContext start mail queue service func NewContext(ctx context.Context) { @@ -406,20 +35,20 @@ func NewContext(ctx context.Context) { switch setting.MailService.Protocol { case "sendmail": - Sender = &sendmailSender{} + sender = &sender_service.SendmailSender{} case "dummy": - Sender = &dummySender{} + sender = &sender_service.DummySender{} default: - Sender = &smtpSender{} + sender = &sender_service.SMTPSender{} } subjectTemplates, bodyTemplates = templates.Mailer(ctx) - mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*Message) []*Message { + mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message { for _, msg := range items { gomailMsg := msg.ToMessage() log.Trace("New e-mail sending request %s: %s", gomailMsg.GetHeader("To"), msg.Info) - if err := gomail.Send(Sender, gomailMsg); err != nil { + if err := sender_service.Send(sender, msg); err != nil { log.Error("Failed to send emails %s: %s - %v", gomailMsg.GetHeader("To"), msg.Info, err) } else { log.Trace("E-mails sent %s: %s", gomailMsg.GetHeader("To"), msg.Info) @@ -436,7 +65,7 @@ func NewContext(ctx context.Context) { // SendAsync send emails asynchronously (make it mockable) var SendAsync = sendAsync -func sendAsync(msgs ...*Message) { +func sendAsync(msgs ...*sender_service.Message) { if setting.MailService == nil { log.Error("Mailer: SendAsync is being invoked but mail service hasn't been initialized") return diff --git a/services/mailer/sender/dummy.go b/services/mailer/sender/dummy.go new file mode 100644 index 0000000000..dd5f14abec --- /dev/null +++ b/services/mailer/sender/dummy.go @@ -0,0 +1,26 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import ( + "bytes" + "io" + + "code.gitea.io/gitea/modules/log" +) + +// DummySender Sender sendmail mail sender +type DummySender struct{} + +var _ Sender = &DummySender{} + +// Send send email +func (s *DummySender) Send(from string, to []string, msg io.WriterTo) error { + buf := bytes.Buffer{} + if _, err := msg.WriteTo(&buf); err != nil { + return err + } + log.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String()) + return nil +} diff --git a/services/mailer/sender/message.go b/services/mailer/sender/message.go new file mode 100644 index 0000000000..a3255692f0 --- /dev/null +++ b/services/mailer/sender/message.go @@ -0,0 +1,112 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import ( + "fmt" + "hash/fnv" + "strings" + "time" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/jaytaylor/html2text" + "gopkg.in/gomail.v2" +) + +// Message mail body and log info +type Message struct { + Info string // Message information for log purpose. + FromAddress string + FromDisplayName string + To string // Use only one recipient to prevent leaking of addresses + ReplyTo string + Subject string + Date time.Time + Body string + Headers map[string][]string +} + +// ToMessage converts a Message to gomail.Message +func (m *Message) ToMessage() *gomail.Message { + msg := gomail.NewMessage() + msg.SetAddressHeader("From", m.FromAddress, m.FromDisplayName) + msg.SetHeader("To", m.To) + if m.ReplyTo != "" { + msg.SetHeader("Reply-To", m.ReplyTo) + } + for header := range m.Headers { + msg.SetHeader(header, m.Headers[header]...) + } + + if setting.MailService.SubjectPrefix != "" { + msg.SetHeader("Subject", setting.MailService.SubjectPrefix+" "+m.Subject) + } else { + msg.SetHeader("Subject", m.Subject) + } + msg.SetDateHeader("Date", m.Date) + msg.SetHeader("X-Auto-Response-Suppress", "All") + + plainBody, err := html2text.FromString(m.Body) + if err != nil || setting.MailService.SendAsPlainText { + if strings.Contains(base.TruncateString(m.Body, 100), "") { + log.Warn("Mail contains HTML but configured to send as plain text.") + } + msg.SetBody("text/plain", plainBody) + } else { + msg.SetBody("text/plain", plainBody) + msg.AddAlternative("text/html", m.Body) + } + + if len(msg.GetHeader("Message-ID")) == 0 { + msg.SetHeader("Message-ID", m.generateAutoMessageID()) + } + + for k, v := range setting.MailService.OverrideHeader { + if len(msg.GetHeader(k)) != 0 { + log.Debug("Mailer override header '%s' as per config", k) + } + msg.SetHeader(k, v...) + } + + return msg +} + +// SetHeader adds additional headers to a message +func (m *Message) SetHeader(field string, value ...string) { + m.Headers[field] = value +} + +func (m *Message) generateAutoMessageID() string { + dateMs := m.Date.UnixNano() / 1e6 + h := fnv.New64() + if len(m.To) > 0 { + _, _ = h.Write([]byte(m.To)) + } + _, _ = h.Write([]byte(m.Subject)) + _, _ = h.Write([]byte(m.Body)) + return fmt.Sprintf("", dateMs, h.Sum64(), setting.Domain) +} + +// NewMessageFrom creates new mail message object with custom From header. +func NewMessageFrom(to, fromDisplayName, fromAddress, subject, body string) *Message { + log.Trace("NewMessageFrom (body):\n%s", body) + + return &Message{ + FromAddress: fromAddress, + FromDisplayName: fromDisplayName, + To: to, + Subject: subject, + Date: time.Now(), + Body: body, + Headers: map[string][]string{}, + } +} + +// NewMessage creates new mail message object with default From header. +func NewMessage(to, subject, body string) *Message { + return NewMessageFrom(to, setting.MailService.FromName, setting.MailService.FromEmail, subject, body) +} diff --git a/services/mailer/mailer_test.go b/services/mailer/sender/message_test.go similarity index 97% rename from services/mailer/mailer_test.go rename to services/mailer/sender/message_test.go index 6d7c44f40c..d47052685e 100644 --- a/services/mailer/mailer_test.go +++ b/services/mailer/sender/message_test.go @@ -1,7 +1,7 @@ -// Copyright 2021 The Gogs Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package mailer +package sender import ( "strings" diff --git a/services/mailer/sender/sender.go b/services/mailer/sender/sender.go new file mode 100644 index 0000000000..bf317aa846 --- /dev/null +++ b/services/mailer/sender/sender.go @@ -0,0 +1,27 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import ( + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "gopkg.in/gomail.v2" +) + +type Sender gomail.Sender + +var Send = send + +func send(sender Sender, msgs ...*Message) error { + if setting.MailService == nil { + log.Error("Mailer: Send is being invoked but mail service hasn't been initialized") + return nil + } + goMsgs := []*gomail.Message{} + for _, msg := range msgs { + goMsgs = append(goMsgs, msg.ToMessage()) + } + return gomail.Send(sender, goMsgs...) +} diff --git a/services/mailer/sender/sendmail.go b/services/mailer/sender/sendmail.go new file mode 100644 index 0000000000..64c7f8f081 --- /dev/null +++ b/services/mailer/sender/sendmail.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import ( + "fmt" + "io" + "os/exec" + "strings" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" +) + +// SendmailSender Sender sendmail mail sender +type SendmailSender struct{} + +var _ Sender = &SendmailSender{} + +// Send send email +func (s *SendmailSender) Send(from string, to []string, msg io.WriterTo) error { + var err error + var closeError error + var waitError error + + envelopeFrom := from + if setting.MailService.OverrideEnvelopeFrom { + envelopeFrom = setting.MailService.EnvelopeFrom + } + + args := []string{"-f", envelopeFrom, "-i"} + args = append(args, setting.MailService.SendmailArgs...) + args = append(args, to...) + log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args) + + desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args) + + ctx, _, finished := process.GetManager().AddContextTimeout(graceful.GetManager().HammerContext(), setting.MailService.SendmailTimeout, desc) + defer finished() + + cmd := exec.CommandContext(ctx, setting.MailService.SendmailPath, args...) + pipe, err := cmd.StdinPipe() + if err != nil { + return err + } + process.SetSysProcAttribute(cmd) + + if err = cmd.Start(); err != nil { + _ = pipe.Close() + return err + } + + if setting.MailService.SendmailConvertCRLF { + buf := &strings.Builder{} + _, err = msg.WriteTo(buf) + if err == nil { + _, err = strings.NewReplacer("\r\n", "\n").WriteString(pipe, buf.String()) + } + } else { + _, err = msg.WriteTo(pipe) + } + + // we MUST close the pipe or sendmail will hang waiting for more of the message + // Also we should wait on our sendmail command even if something fails + closeError = pipe.Close() + waitError = cmd.Wait() + if err != nil { + return err + } else if closeError != nil { + return closeError + } + return waitError +} diff --git a/services/mailer/sender/smtp.go b/services/mailer/sender/smtp.go new file mode 100644 index 0000000000..ab49b7e5f8 --- /dev/null +++ b/services/mailer/sender/smtp.go @@ -0,0 +1,150 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import ( + "crypto/tls" + "fmt" + "io" + "net" + "net/smtp" + "os" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// SMTPSender Sender SMTP mail sender +type SMTPSender struct{} + +var _ Sender = &SMTPSender{} + +// Send send email +func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error { + opts := setting.MailService + + var network string + var address string + if opts.Protocol == "smtp+unix" { + network = "unix" + address = opts.SMTPAddr + } else { + network = "tcp" + address = net.JoinHostPort(opts.SMTPAddr, opts.SMTPPort) + } + + conn, err := net.Dial(network, address) + if err != nil { + return fmt.Errorf("failed to establish network connection to SMTP server: %w", err) + } + defer conn.Close() + + var tlsconfig *tls.Config + if opts.Protocol == "smtps" || opts.Protocol == "smtp+starttls" { + tlsconfig = &tls.Config{ + InsecureSkipVerify: opts.ForceTrustServerCert, + ServerName: opts.SMTPAddr, + } + + if opts.UseClientCert { + cert, err := tls.LoadX509KeyPair(opts.ClientCertFile, opts.ClientKeyFile) + if err != nil { + return fmt.Errorf("could not load SMTP client certificate: %w", err) + } + tlsconfig.Certificates = []tls.Certificate{cert} + } + } + + if opts.Protocol == "smtps" { + conn = tls.Client(conn, tlsconfig) + } + + host := "localhost" + if opts.Protocol == "smtp+unix" { + host = opts.SMTPAddr + } + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("could not initiate SMTP session: %w", err) + } + + if opts.EnableHelo { + hostname := opts.HeloHostname + if len(hostname) == 0 { + hostname, err = os.Hostname() + if err != nil { + return fmt.Errorf("could not retrieve system hostname: %w", err) + } + } + + if err = client.Hello(hostname); err != nil { + return fmt.Errorf("failed to issue HELO command: %w", err) + } + } + + if opts.Protocol == "smtp+starttls" { + hasStartTLS, _ := client.Extension("STARTTLS") + if hasStartTLS { + if err = client.StartTLS(tlsconfig); err != nil { + return fmt.Errorf("failed to start TLS connection: %w", err) + } + } else { + log.Warn("StartTLS requested, but SMTP server does not support it; falling back to regular SMTP") + } + } + + canAuth, options := client.Extension("AUTH") + if len(opts.User) > 0 { + if !canAuth { + return fmt.Errorf("SMTP server does not support AUTH, but credentials provided") + } + + var auth smtp.Auth + + if strings.Contains(options, "CRAM-MD5") { + auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd) + } else if strings.Contains(options, "PLAIN") { + auth = smtp.PlainAuth("", opts.User, opts.Passwd, host) + } else if strings.Contains(options, "LOGIN") { + // Patch for AUTH LOGIN + auth = LoginAuth(opts.User, opts.Passwd) + } else if strings.Contains(options, "NTLM") { + auth = NtlmAuth(opts.User, opts.Passwd) + } + + if auth != nil { + if err = client.Auth(auth); err != nil { + return fmt.Errorf("failed to authenticate SMTP: %w", err) + } + } + } + + if opts.OverrideEnvelopeFrom { + if err = client.Mail(opts.EnvelopeFrom); err != nil { + return fmt.Errorf("failed to issue MAIL command: %w", err) + } + } else { + if err = client.Mail(from); err != nil { + return fmt.Errorf("failed to issue MAIL command: %w", err) + } + } + + for _, rec := range to { + if err = client.Rcpt(rec); err != nil { + return fmt.Errorf("failed to issue RCPT command: %w", err) + } + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("failed to issue DATA command: %w", err) + } else if _, err = msg.WriteTo(w); err != nil { + return fmt.Errorf("SMTP write failed: %w", err) + } else if err = w.Close(); err != nil { + return fmt.Errorf("SMTP close failed: %w", err) + } + + return client.Quit() +} diff --git a/services/mailer/sender/smtp_auth.go b/services/mailer/sender/smtp_auth.go new file mode 100644 index 0000000000..df65498a5a --- /dev/null +++ b/services/mailer/sender/smtp_auth.go @@ -0,0 +1,69 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sender + +import ( + "fmt" + "net/smtp" + + "github.com/Azure/go-ntlmssp" +) + +type loginAuth struct { + username, password string +} + +// LoginAuth SMTP AUTH LOGIN Auth Handler +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} + +// Start start SMTP login auth +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +// Next next step of SMTP login auth +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, fmt.Errorf("unknown fromServer: %s", string(fromServer)) + } + } + return nil, nil +} + +type ntlmAuth struct { + username, password, domain string + domainNeeded bool +} + +// NtlmAuth SMTP AUTH NTLM Auth Handler +func NtlmAuth(username, password string) smtp.Auth { + user, domain, domainNeeded := ntlmssp.GetDomain(username) + return &ntlmAuth{user, password, domain, domainNeeded} +} + +// Start starts SMTP NTLM Auth +func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + negotiateMessage, err := ntlmssp.NewNegotiateMessage(a.domain, "") + return "NTLM", negotiateMessage, err +} + +// Next next step of SMTP ntlm auth +func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + if len(fromServer) == 0 { + return nil, fmt.Errorf("ntlm ChallengeMessage is empty") + } + authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded) + return authenticateMessage, err + } + return nil, nil +} diff --git a/tests/integration/incoming_email_test.go b/tests/integration/incoming_email_test.go index 88571303ac..e968a2956e 100644 --- a/tests/integration/incoming_email_test.go +++ b/tests/integration/incoming_email_test.go @@ -19,11 +19,11 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/mailer/incoming" incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" + sender_service "code.gitea.io/gitea/services/mailer/sender" token_service "code.gitea.io/gitea/services/mailer/token" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" - "gopkg.in/gomail.v2" ) func TestIncomingEmail(t *testing.T) { @@ -189,11 +189,15 @@ func TestIncomingEmail(t *testing.T) { token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) assert.NoError(t, err) - msg := gomail.NewMessage() - msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)) - msg.SetHeader("From", user.Email) - msg.SetBody("text/plain", token) - err = gomail.Send(&smtpTestSender{}, msg) + msg := sender_service.NewMessageFrom( + strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1), + "", + user.Email, + "", + token, + ) + + err = sender_service.Send(&smtpTestSender{}, msg) assert.NoError(t, err) assert.Eventually(t, func() bool {