Files
Tomas Dvorak 3cb40adb23 first commit
2026-04-10 12:04:09 +02:00

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