mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 12:33:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user