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