mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13:01 +00:00
877 lines
23 KiB
Go
877 lines
23 KiB
Go
package mailruntime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
stdmail "net/mail"
|
|
"net/smtp"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap"
|
|
imapclient "github.com/emersion/go-imap/client"
|
|
"github.com/emersion/go-message"
|
|
gomail "github.com/emersion/go-message/mail"
|
|
"go.uber.org/zap"
|
|
|
|
"productier/apps/backend/internal/store"
|
|
)
|
|
|
|
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
|
|
|
|
type Service struct {
|
|
store store.Store
|
|
logger *zap.Logger
|
|
aead cipher.AEAD
|
|
}
|
|
|
|
type ConnectMailboxInput struct {
|
|
WorkspaceSlug string
|
|
Label string
|
|
Email string
|
|
DisplayName string
|
|
IMAPHost string
|
|
IMAPPort int
|
|
IMAPUsername string
|
|
IMAPPassword string
|
|
IMAPUseTLS bool
|
|
SMTPHost string
|
|
SMTPPort int
|
|
SMTPUsername string
|
|
SMTPPassword string
|
|
SMTPUseTLS bool
|
|
}
|
|
|
|
type QueueOutgoingMailInput struct {
|
|
WorkspaceSlug string
|
|
MailboxID string
|
|
To []store.MailAddress
|
|
Cc []store.MailAddress
|
|
Bcc []store.MailAddress
|
|
Subject string
|
|
TextBody string
|
|
HTMLBody string
|
|
ScheduledFor *time.Time
|
|
}
|
|
|
|
func New(dataStore store.Store, logger *zap.Logger, secretSeed string) (*Service, error) {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
|
|
aead, err := newAEAD(secretSeed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Service{
|
|
store: dataStore,
|
|
logger: logger,
|
|
aead: aead,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Start(ctx context.Context) {
|
|
go s.runDueOutgoingLoop(ctx)
|
|
go s.runMailboxSyncLoop(ctx)
|
|
}
|
|
|
|
func (s *Service) ConnectMailbox(ctx context.Context, input ConnectMailboxInput) (store.Mailbox, error) {
|
|
input.normalize()
|
|
if err := input.validate(); err != nil {
|
|
return store.Mailbox{}, err
|
|
}
|
|
if err := s.verifyIMAP(ctx, input); err != nil {
|
|
return store.Mailbox{}, fmt.Errorf("verify imap connection: %w", err)
|
|
}
|
|
if err := s.verifySMTP(input); err != nil {
|
|
return store.Mailbox{}, fmt.Errorf("verify smtp connection: %w", err)
|
|
}
|
|
|
|
imapCiphertext, err := s.encrypt(input.IMAPPassword)
|
|
if err != nil {
|
|
return store.Mailbox{}, err
|
|
}
|
|
smtpCiphertext, err := s.encrypt(input.SMTPPassword)
|
|
if err != nil {
|
|
return store.Mailbox{}, err
|
|
}
|
|
|
|
mailbox, err := s.store.CreateMailbox(store.CreateMailboxRecordInput{
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
Label: input.Label,
|
|
Email: input.Email,
|
|
DisplayName: input.DisplayName,
|
|
IMAPHost: input.IMAPHost,
|
|
IMAPPort: input.IMAPPort,
|
|
IMAPUsername: input.IMAPUsername,
|
|
IMAPPasswordCiphertext: imapCiphertext,
|
|
IMAPUseTLS: input.IMAPUseTLS,
|
|
SMTPHost: input.SMTPHost,
|
|
SMTPPort: input.SMTPPort,
|
|
SMTPUsername: input.SMTPUsername,
|
|
SMTPPasswordCiphertext: smtpCiphertext,
|
|
SMTPUseTLS: input.SMTPUseTLS,
|
|
})
|
|
if err != nil {
|
|
return store.Mailbox{}, err
|
|
}
|
|
|
|
if syncErr := s.SyncMailbox(ctx, mailbox.ID); syncErr != nil {
|
|
s.logger.Warn("initial mailbox sync failed", zap.String("mailboxId", mailbox.ID), zap.Error(syncErr))
|
|
}
|
|
|
|
return mailbox, nil
|
|
}
|
|
|
|
func (s *Service) QueueOutgoingMail(ctx context.Context, input QueueOutgoingMailInput) (store.OutgoingMail, error) {
|
|
if input.WorkspaceSlug == "" || input.MailboxID == "" {
|
|
return store.OutgoingMail{}, errors.New("workspace and mailbox are required")
|
|
}
|
|
if len(input.To) == 0 {
|
|
return store.OutgoingMail{}, errors.New("at least one recipient is required")
|
|
}
|
|
|
|
status := "queued"
|
|
if input.ScheduledFor != nil && input.ScheduledFor.After(time.Now().UTC()) {
|
|
status = "scheduled"
|
|
}
|
|
|
|
item, err := s.store.CreateOutgoingMail(store.CreateOutgoingMailInput{
|
|
WorkspaceSlug: input.WorkspaceSlug,
|
|
MailboxID: input.MailboxID,
|
|
To: input.To,
|
|
Cc: input.Cc,
|
|
Bcc: input.Bcc,
|
|
Subject: input.Subject,
|
|
TextBody: input.TextBody,
|
|
HTMLBody: input.HTMLBody,
|
|
Status: status,
|
|
ScheduledFor: input.ScheduledFor,
|
|
})
|
|
if err != nil {
|
|
return store.OutgoingMail{}, err
|
|
}
|
|
|
|
if status == "queued" {
|
|
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
|
|
updated, getErr := s.store.GetOutgoingMailByID(item.ID)
|
|
if getErr == nil {
|
|
return updated, err
|
|
}
|
|
return item, err
|
|
}
|
|
return s.store.GetOutgoingMailByID(item.ID)
|
|
}
|
|
|
|
return item, nil
|
|
}
|
|
|
|
func (s *Service) SendOutgoingMail(ctx context.Context, outgoingMailID string) error {
|
|
item, err := s.store.GetOutgoingMailByID(outgoingMailID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
connection, err := s.mailboxConnection(item.MailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := sendSMTPMessage(connection, connection.SMTPPasswordCiphertext, item); err != nil {
|
|
message := err.Error()
|
|
_, _ = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
|
|
Status: "failed",
|
|
Error: &message,
|
|
})
|
|
return err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
empty := ""
|
|
_, err = s.store.UpdateOutgoingMailStatus(outgoingMailID, store.UpdateOutgoingMailStatusInput{
|
|
Status: "sent",
|
|
SentAt: &now,
|
|
Error: &empty,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *Service) SyncMailbox(ctx context.Context, mailboxID string) error {
|
|
if _, err := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{SyncStatus: "syncing"}); err != nil {
|
|
return err
|
|
}
|
|
|
|
connection, err := s.mailboxConnection(mailboxID)
|
|
if err != nil {
|
|
s.markMailboxError(mailboxID, err)
|
|
return err
|
|
}
|
|
|
|
messages, err := fetchInboxMessages(connection, connection.IMAPPasswordCiphertext)
|
|
if err != nil {
|
|
s.markMailboxError(mailboxID, err)
|
|
return err
|
|
}
|
|
if err := s.store.UpsertMailMessages(mailboxID, messages); err != nil {
|
|
s.markMailboxError(mailboxID, err)
|
|
return err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
empty := ""
|
|
_, err = s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
|
|
SyncStatus: "ready",
|
|
SyncError: &empty,
|
|
LastSyncedAt: &now,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *Service) runDueOutgoingLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(15 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
due := s.store.ListDueOutgoingMails(time.Now().UTC(), 20)
|
|
for _, item := range due {
|
|
if err := s.SendOutgoingMail(ctx, item.ID); err != nil {
|
|
s.logger.Warn("send outgoing mail", zap.String("outgoingMailId", item.ID), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) runMailboxSyncLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(2 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
for _, mailbox := range s.store.ListAllMailboxes() {
|
|
if err := s.SyncMailbox(ctx, mailbox.ID); err != nil {
|
|
s.logger.Warn("sync mailbox", zap.String("mailboxId", mailbox.ID), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) markMailboxError(mailboxID string, err error) {
|
|
message := err.Error()
|
|
_, updateErr := s.store.UpdateMailboxSyncStatus(mailboxID, store.UpdateMailboxSyncStatusInput{
|
|
SyncStatus: "error",
|
|
SyncError: &message,
|
|
})
|
|
if updateErr != nil {
|
|
s.logger.Warn("update mailbox sync error", zap.String("mailboxId", mailboxID), zap.Error(updateErr))
|
|
}
|
|
}
|
|
|
|
func (s *Service) mailboxConnection(mailboxID string) (store.MailboxConnection, error) {
|
|
connection, err := s.store.GetMailboxConnection(mailboxID)
|
|
if err != nil {
|
|
return store.MailboxConnection{}, err
|
|
}
|
|
imapPassword, err := s.decrypt(connection.IMAPPasswordCiphertext)
|
|
if err != nil {
|
|
return store.MailboxConnection{}, err
|
|
}
|
|
smtpPassword, err := s.decrypt(connection.SMTPPasswordCiphertext)
|
|
if err != nil {
|
|
return store.MailboxConnection{}, err
|
|
}
|
|
connection.IMAPPasswordCiphertext = imapPassword
|
|
connection.SMTPPasswordCiphertext = smtpPassword
|
|
return connection, nil
|
|
}
|
|
|
|
func newAEAD(secretSeed string) (cipher.AEAD, error) {
|
|
if secretSeed == "" {
|
|
secretSeed = "productier-local-mail-key"
|
|
}
|
|
|
|
key, err := decodeSecretSeed(secretSeed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(block)
|
|
}
|
|
|
|
func decodeSecretSeed(secretSeed string) ([]byte, error) {
|
|
if raw, err := base64.StdEncoding.DecodeString(secretSeed); err == nil && len(raw) == 32 {
|
|
return raw, nil
|
|
}
|
|
sum := sha256.Sum256([]byte(secretSeed))
|
|
return sum[:], nil
|
|
}
|
|
|
|
func (s *Service) encrypt(plain string) (string, error) {
|
|
nonce := make([]byte, s.aead.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
sealed := s.aead.Seal(nonce, nonce, []byte(plain), nil)
|
|
return base64.StdEncoding.EncodeToString(sealed), nil
|
|
}
|
|
|
|
func (s *Service) decrypt(ciphertext string) (string, error) {
|
|
raw, err := base64.StdEncoding.DecodeString(ciphertext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(raw) < s.aead.NonceSize() {
|
|
return "", errors.New("ciphertext too short")
|
|
}
|
|
nonce := raw[:s.aead.NonceSize()]
|
|
payload := raw[s.aead.NonceSize():]
|
|
plain, err := s.aead.Open(nil, nonce, payload, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
func (input *ConnectMailboxInput) normalize() {
|
|
input.WorkspaceSlug = strings.TrimSpace(input.WorkspaceSlug)
|
|
input.Label = strings.TrimSpace(input.Label)
|
|
input.Email = strings.TrimSpace(strings.ToLower(input.Email))
|
|
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
|
input.IMAPHost = strings.TrimSpace(input.IMAPHost)
|
|
input.SMTPHost = strings.TrimSpace(input.SMTPHost)
|
|
if input.IMAPPort == 0 {
|
|
input.IMAPPort = 993
|
|
}
|
|
if input.SMTPPort == 0 {
|
|
input.SMTPPort = 587
|
|
}
|
|
if input.IMAPUsername == "" {
|
|
input.IMAPUsername = input.Email
|
|
}
|
|
if input.SMTPUsername == "" {
|
|
input.SMTPUsername = input.IMAPUsername
|
|
}
|
|
if input.SMTPPassword == "" {
|
|
input.SMTPPassword = input.IMAPPassword
|
|
}
|
|
if input.Label == "" {
|
|
input.Label = input.Email
|
|
}
|
|
}
|
|
|
|
func (input ConnectMailboxInput) validate() error {
|
|
if input.WorkspaceSlug == "" || input.Email == "" {
|
|
return errors.New("workspace and email are required")
|
|
}
|
|
if input.IMAPHost == "" || input.SMTPHost == "" {
|
|
return errors.New("imap and smtp hosts are required")
|
|
}
|
|
if input.IMAPUsername == "" || input.IMAPPassword == "" {
|
|
return errors.New("imap credentials are required")
|
|
}
|
|
if input.SMTPUsername == "" || input.SMTPPassword == "" {
|
|
return errors.New("smtp credentials are required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) verifyIMAP(ctx context.Context, input ConnectMailboxInput) error {
|
|
client, err := dialAndLoginIMAP(input.IMAPHost, input.IMAPPort, input.IMAPUseTLS, input.IMAPUsername, input.IMAPPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Logout()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
_, err = client.Select("INBOX", true)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) verifySMTP(input ConnectMailboxInput) error {
|
|
client, err := dialSMTPClient(input.SMTPHost, input.SMTPPort, input.SMTPUseTLS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = client.Quit()
|
|
_ = client.Close()
|
|
}()
|
|
|
|
return smtpAuthenticate(client, input.SMTPHost, input.SMTPUsername, input.SMTPPassword)
|
|
}
|
|
|
|
func fetchInboxMessages(connection store.MailboxConnection, password string) ([]store.InboundMailMessage, error) {
|
|
client, err := dialAndLoginIMAP(connection.IMAPHost, connection.IMAPPort, connection.IMAPUseTLS, connection.IMAPUsername, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer client.Logout()
|
|
|
|
mbox, err := client.Select("INBOX", true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mbox.Messages == 0 {
|
|
return []store.InboundMailMessage{}, nil
|
|
}
|
|
|
|
uids, err := client.UidSearch(&imap.SearchCriteria{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(uids) == 0 {
|
|
return []store.InboundMailMessage{}, nil
|
|
}
|
|
sort.Slice(uids, func(i int, j int) bool { return uids[i] < uids[j] })
|
|
if len(uids) > 50 {
|
|
uids = uids[len(uids)-50:]
|
|
}
|
|
|
|
seqset := new(imap.SeqSet)
|
|
seqset.AddNum(uids...)
|
|
|
|
section := &imap.BodySectionName{}
|
|
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags, imap.FetchEnvelope, section.FetchItem()}
|
|
ch := make(chan *imap.Message, len(uids))
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- client.UidFetch(seqset, items, ch)
|
|
}()
|
|
|
|
result := make([]store.InboundMailMessage, 0, len(uids))
|
|
for msg := range ch {
|
|
parsed, err := parseIMAPMessage(connection.WorkspaceSlug, connection.ID, section, msg)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, parsed)
|
|
}
|
|
|
|
if err := <-done; err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(result, func(i int, j int) bool { return result[i].ReceivedAt.After(result[j].ReceivedAt) })
|
|
return result, nil
|
|
}
|
|
|
|
func parseIMAPMessage(workspaceSlug string, mailboxID string, section *imap.BodySectionName, msg *imap.Message) (store.InboundMailMessage, error) {
|
|
if msg == nil {
|
|
return store.InboundMailMessage{}, errors.New("nil message")
|
|
}
|
|
|
|
receivedAt := time.Now().UTC()
|
|
if msg.Envelope != nil && !msg.Envelope.Date.IsZero() {
|
|
receivedAt = msg.Envelope.Date.UTC()
|
|
}
|
|
|
|
parsed := store.InboundMailMessage{
|
|
WorkspaceSlug: workspaceSlug,
|
|
MailboxID: mailboxID,
|
|
RemoteUID: int64(msg.Uid),
|
|
Folder: "INBOX",
|
|
ReceivedAt: receivedAt,
|
|
IsRead: hasFlag(msg.Flags, imap.SeenFlag),
|
|
From: addressFromEnvelope(msg.Envelope),
|
|
To: addressesFromEnvelope(msg.Envelope, "to"),
|
|
Cc: addressesFromEnvelope(msg.Envelope, "cc"),
|
|
}
|
|
|
|
if msg.Envelope != nil {
|
|
parsed.Subject = msg.Envelope.Subject
|
|
}
|
|
|
|
body := msg.GetBody(section)
|
|
if body == nil {
|
|
parsed.Snippet = truncatePlaintext(parsed.Subject, 180)
|
|
return parsed, nil
|
|
}
|
|
|
|
reader, err := gomail.CreateReader(body)
|
|
if err != nil && !message.IsUnknownCharset(err) {
|
|
return store.InboundMailMessage{}, err
|
|
}
|
|
|
|
if headerMessageID, headerErr := reader.Header.MessageID(); headerErr == nil {
|
|
parsed.MessageID = headerMessageID
|
|
}
|
|
if subject, headerErr := reader.Header.Subject(); headerErr == nil && subject != "" {
|
|
parsed.Subject = subject
|
|
}
|
|
if from, headerErr := reader.Header.AddressList("From"); headerErr == nil && len(from) > 0 {
|
|
parsed.From = mailAddress(from[0])
|
|
}
|
|
if to, headerErr := reader.Header.AddressList("To"); headerErr == nil {
|
|
parsed.To = toStoreAddresses(to)
|
|
}
|
|
if cc, headerErr := reader.Header.AddressList("Cc"); headerErr == nil {
|
|
parsed.Cc = toStoreAddresses(cc)
|
|
}
|
|
if date, headerErr := reader.Header.Date(); headerErr == nil && !date.IsZero() {
|
|
parsed.ReceivedAt = date.UTC()
|
|
}
|
|
|
|
for {
|
|
part, partErr := reader.NextPart()
|
|
if errors.Is(partErr, io.EOF) {
|
|
break
|
|
}
|
|
if partErr != nil && !message.IsUnknownCharset(partErr) {
|
|
return store.InboundMailMessage{}, partErr
|
|
}
|
|
if part == nil {
|
|
break
|
|
}
|
|
|
|
contentType := ""
|
|
switch header := part.Header.(type) {
|
|
case *gomail.InlineHeader:
|
|
contentType = header.Get("Content-Type")
|
|
default:
|
|
contentType = part.Header.Get("Content-Type")
|
|
}
|
|
|
|
payload, readErr := io.ReadAll(io.LimitReader(part.Body, 1<<20))
|
|
if readErr != nil {
|
|
return store.InboundMailMessage{}, readErr
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(strings.ToLower(contentType), "text/plain"):
|
|
if parsed.TextBody == "" {
|
|
parsed.TextBody = string(payload)
|
|
}
|
|
case strings.HasPrefix(strings.ToLower(contentType), "text/html"):
|
|
if parsed.HTMLBody == "" {
|
|
parsed.HTMLBody = string(payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
if parsed.TextBody == "" && parsed.HTMLBody != "" {
|
|
parsed.TextBody = htmlToText(parsed.HTMLBody)
|
|
}
|
|
parsed.Snippet = truncatePlaintext(firstNonEmpty(parsed.TextBody, parsed.Subject), 240)
|
|
return parsed, nil
|
|
}
|
|
|
|
func sendSMTPMessage(connection store.MailboxConnection, password string, outgoing store.OutgoingMail) error {
|
|
messageBytes, err := buildOutgoingMessage(connection, outgoing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, err := dialSMTPClient(connection.SMTPHost, connection.SMTPPort, connection.SMTPUseTLS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = client.Quit()
|
|
_ = client.Close()
|
|
}()
|
|
|
|
if err := smtpAuthenticate(client, connection.SMTPHost, connection.SMTPUsername, password); err != nil {
|
|
return err
|
|
}
|
|
if err := client.Mail(connection.Email); err != nil {
|
|
return err
|
|
}
|
|
for _, recipient := range uniqueRecipients(outgoing) {
|
|
if err := client.Rcpt(recipient); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
writer, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := writer.Write(messageBytes); err != nil {
|
|
_ = writer.Close()
|
|
return err
|
|
}
|
|
return writer.Close()
|
|
}
|
|
|
|
func buildOutgoingMessage(connection store.MailboxConnection, outgoing store.OutgoingMail) ([]byte, error) {
|
|
var (
|
|
header gomail.Header
|
|
buffer bytes.Buffer
|
|
)
|
|
|
|
fromName := strings.TrimSpace(connection.DisplayName)
|
|
header.SetAddressList("From", []*gomail.Address{{Name: fromName, Address: connection.Email}})
|
|
header.SetAddressList("To", toMailAddresses(outgoing.To))
|
|
header.SetAddressList("Cc", toMailAddresses(outgoing.Cc))
|
|
header.SetAddressList("Bcc", toMailAddresses(outgoing.Bcc))
|
|
header.SetDate(time.Now().UTC())
|
|
header.SetSubject(firstNonEmpty(outgoing.Subject, "(no subject)"))
|
|
_ = header.GenerateMessageIDWithHostname(sanitizeHostname(connection.SMTPHost))
|
|
|
|
switch {
|
|
case outgoing.TextBody != "" && outgoing.HTMLBody != "":
|
|
writer, err := gomail.CreateInlineWriter(&buffer, header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var textHeader gomail.InlineHeader
|
|
textHeader.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
|
|
textPart, err := writer.CreatePart(textHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.WriteString(textPart, outgoing.TextBody); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := textPart.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var htmlHeader gomail.InlineHeader
|
|
htmlHeader.SetContentType("text/html", map[string]string{"charset": "utf-8"})
|
|
htmlPart, err := writer.CreatePart(htmlHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.WriteString(htmlPart, outgoing.HTMLBody); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := htmlPart.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
case outgoing.HTMLBody != "":
|
|
header.SetContentType("text/html", map[string]string{"charset": "utf-8"})
|
|
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.WriteString(writer, outgoing.HTMLBody); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
header.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
|
|
writer, err := gomail.CreateSingleInlineWriter(&buffer, header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.WriteString(writer, outgoing.TextBody); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
func dialAndLoginIMAP(host string, port int, useTLS bool, username string, password string) (*imapclient.Client, error) {
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
var (
|
|
client *imapclient.Client
|
|
err error
|
|
)
|
|
if useTLS {
|
|
client, err = imapclient.DialTLS(addr, &tls.Config{ServerName: host})
|
|
} else {
|
|
client, err = imapclient.Dial(addr)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := client.Login(username, password); err != nil {
|
|
_ = client.Logout()
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func dialSMTPClient(host string, port int, useTLS bool) (*smtp.Client, error) {
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
if useTLS && port == 465 {
|
|
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return smtp.NewClient(conn, host)
|
|
}
|
|
|
|
client, err := smtp.Dial(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if useTLS {
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
|
_ = client.Close()
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func smtpAuthenticate(client *smtp.Client, host string, username string, password string) error {
|
|
if username == "" || password == "" {
|
|
return nil
|
|
}
|
|
if ok, _ := client.Extension("AUTH"); !ok {
|
|
return nil
|
|
}
|
|
return client.Auth(smtp.PlainAuth("", username, password, host))
|
|
}
|
|
|
|
func hasFlag(flags []string, target string) bool {
|
|
for _, flag := range flags {
|
|
if flag == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func addressFromEnvelope(envelope *imap.Envelope) store.MailAddress {
|
|
if envelope == nil || len(envelope.From) == 0 {
|
|
return store.MailAddress{}
|
|
}
|
|
address := envelope.From[0]
|
|
return store.MailAddress{
|
|
Name: address.PersonalName,
|
|
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
|
|
}
|
|
}
|
|
|
|
func addressesFromEnvelope(envelope *imap.Envelope, field string) []store.MailAddress {
|
|
if envelope == nil {
|
|
return []store.MailAddress{}
|
|
}
|
|
var source []*imap.Address
|
|
switch field {
|
|
case "to":
|
|
source = envelope.To
|
|
case "cc":
|
|
source = envelope.Cc
|
|
}
|
|
items := make([]store.MailAddress, 0, len(source))
|
|
for _, address := range source {
|
|
items = append(items, store.MailAddress{
|
|
Name: address.PersonalName,
|
|
Email: strings.Trim(strings.Join([]string{address.MailboxName, address.HostName}, "@"), "@"),
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func toStoreAddresses(addrs []*gomail.Address) []store.MailAddress {
|
|
items := make([]store.MailAddress, 0, len(addrs))
|
|
for _, addr := range addrs {
|
|
items = append(items, store.MailAddress{Name: addr.Name, Email: addr.Address})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func toMailAddresses(addrs []store.MailAddress) []*gomail.Address {
|
|
items := make([]*gomail.Address, 0, len(addrs))
|
|
for _, addr := range addrs {
|
|
if addr.Email == "" {
|
|
continue
|
|
}
|
|
items = append(items, &gomail.Address{Name: addr.Name, Address: addr.Email})
|
|
}
|
|
return items
|
|
}
|
|
|
|
func uniqueRecipients(outgoing store.OutgoingMail) []string {
|
|
set := make(map[string]struct{})
|
|
items := make([]string, 0, len(outgoing.To)+len(outgoing.Cc)+len(outgoing.Bcc))
|
|
for _, group := range [][]store.MailAddress{outgoing.To, outgoing.Cc, outgoing.Bcc} {
|
|
for _, addr := range group {
|
|
email := strings.TrimSpace(strings.ToLower(addr.Email))
|
|
if email == "" {
|
|
continue
|
|
}
|
|
if _, exists := set[email]; exists {
|
|
continue
|
|
}
|
|
set[email] = struct{}{}
|
|
items = append(items, email)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func htmlToText(value string) string {
|
|
return strings.TrimSpace(htmlTagPattern.ReplaceAllString(value, " "))
|
|
}
|
|
|
|
func truncatePlaintext(value string, limit int) string {
|
|
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
|
if len(value) <= limit {
|
|
return value
|
|
}
|
|
return strings.TrimSpace(value[:limit]) + "…"
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sanitizeHostname(host string) string {
|
|
parsedHost := strings.TrimSpace(host)
|
|
if parsedHost == "" {
|
|
return "localhost"
|
|
}
|
|
if strings.Contains(parsedHost, ":") {
|
|
if h, _, err := strings.Cut(parsedHost, ":"); err && h != "" {
|
|
return h
|
|
}
|
|
}
|
|
return parsedHost
|
|
}
|
|
|
|
func mailAddress(addr *stdmail.Address) store.MailAddress {
|
|
if addr == nil {
|
|
return store.MailAddress{}
|
|
}
|
|
return store.MailAddress{Name: addr.Name, Email: addr.Address}
|
|
}
|