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}
}
