feat(crm): email contact export completions

This commit is contained in:
2026-06-05 04:29:24 +08:00
parent 0f65935684
commit c2766fe642
4 changed files with 153 additions and 0 deletions
+1
View File
@@ -307,6 +307,7 @@ func Bootstrap(env string) (*App, error) {
noteRepo := repository.NewNoteRepo(db)
contactNoteService := service.NewContactNoteService(contactRepo, contactNoteRepo)
contactService := service.NewContactService(contactRepo, contactInboxService, noteRepo)
contactService.SetContactExportMailer(service.NewEnvContactExportMailer())
// G4: Company service (depends on companyRepo, contactRepo, conversationRepo for nested queries)
companyService := service.NewCompanyService(companyRepo, contactRepo, conversationRepo)
+112
View File
@@ -0,0 +1,112 @@
package service
import (
"context"
"fmt"
"net/mail"
"net/smtp"
"os"
"strconv"
"strings"
"github.com/gochat/gochat/internal/model"
)
const contactExportCompleteSubject = "Your contact's export file is available to download."
// ContactExportMailer delivers the administrator notification emitted by
// Chatwoot's Account::ContactsExportJob after the CSV artifact is attached.
type ContactExportMailer interface {
SendContactExportComplete(ctx context.Context, account *model.Account, user *model.User, export *model.ContactExport) error
}
type SMTPContactExportMailer struct {
Address string
Port int
Username string
Password string
From string
FrontendURL string
}
func NewEnvContactExportMailer() *SMTPContactExportMailer {
return &SMTPContactExportMailer{
Address: strings.TrimSpace(os.Getenv("SMTP_ADDRESS")),
Port: envInt("SMTP_PORT", 587),
Username: firstEnv("SMTP_USERNAME", "SMTP_LOGIN"),
Password: os.Getenv("SMTP_PASSWORD"),
From: firstEnv("MAILER_SENDER_EMAIL", "SMTP_FROM"),
FrontendURL: strings.TrimRight(os.Getenv("FRONTEND_URL"), "/"),
}
}
func (m *SMTPContactExportMailer) SendContactExportComplete(ctx context.Context, account *model.Account, user *model.User, export *model.ContactExport) error {
_ = ctx
if m == nil || strings.TrimSpace(m.Address) == "" || user == nil || strings.TrimSpace(user.Email) == "" || export == nil {
return nil
}
fromHeader := strings.TrimSpace(m.From)
if fromHeader == "" {
fromHeader = "Chatwoot <accounts@chatwoot.com>"
}
fromAddress := fromHeader
if parsed, err := mail.ParseAddress(fromHeader); err == nil {
fromAddress = parsed.Address
}
to := strings.TrimSpace(user.Email)
body := contactExportEmailBody(account, export, m.FrontendURL)
message := smtpMessage(fromHeader, to, contactExportCompleteSubject, body)
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(m.Address), m.Port)
var auth smtp.Auth
if strings.TrimSpace(m.Username) != "" {
auth = smtp.PlainAuth("", strings.TrimSpace(m.Username), m.Password, strings.TrimSpace(m.Address))
}
return smtp.SendMail(addr, auth, fromAddress, []string{to}, []byte(message))
}
func contactExportEmailBody(account *model.Account, export *model.ContactExport, frontendURL string) string {
accountName := "your account"
if account != nil && strings.TrimSpace(account.Name) != "" {
accountName = account.Name
}
fileURL := export.FileURL
if frontendURL != "" && strings.HasPrefix(fileURL, "/") {
fileURL = frontendURL + fileURL
}
return fmt.Sprintf("The contact export for %s is ready.\n\nDownload: %s\n", accountName, fileURL)
}
func smtpMessage(from, to, subject, body string) string {
return strings.Join([]string{
"From: " + from,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
body,
}, "\r\n")
}
func firstEnv(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func envInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
+19
View File
@@ -30,6 +30,7 @@ type ContactService struct {
noteRepo *repository.NoteRepo
searchIndexer SearchIndexer
searchReader ContactSearchReader
exportMailer ContactExportMailer
}
// NewContactService creates a new Contact service.
@@ -45,6 +46,10 @@ func (s *ContactService) SetSearchReader(reader ContactSearchReader) {
s.searchReader = reader
}
func (s *ContactService) SetContactExportMailer(mailer ContactExportMailer) {
s.exportMailer = mailer
}
func (s *ContactService) indexContact(ctx context.Context, contact *model.Contact) {
if s.searchIndexer != nil {
logSearchIndexError("contact", contact.ID, s.searchIndexer.IndexContact(ctx, contact))
@@ -469,9 +474,23 @@ func (s *ContactService) ExportContacts(ctx context.Context, accountID, userID u
if err := s.createContactExportNotification(ctx, export); err != nil {
applogger.L().Warnf("contact export notification failed: %v", err)
}
if err := s.sendContactExportEmail(ctx, &account, export); err != nil {
applogger.L().Warnf("contact export email failed: %v", err)
}
return export, nil
}
func (s *ContactService) sendContactExportEmail(ctx context.Context, account *model.Account, export *model.ContactExport) error {
if s.exportMailer == nil || export == nil || export.UserID == nil || *export.UserID == 0 {
return nil
}
var user model.User
if err := s.repo.DB().WithContext(ctx).Where("id = ?", *export.UserID).First(&user).Error; err != nil {
return err
}
return s.exportMailer.SendContactExportComplete(ctx, account, &user, export)
}
func (s *ContactService) GenerateContactExportCSV(ctx context.Context, accountID uint, req ContactExportRequest) ([]byte, int, error) {
params := contactExportFilterParams(req)
contacts, err := s.repo.FindForExport(ctx, accountID, params)
@@ -29,6 +29,21 @@ func (m *mockContactSearchReader) SearchContacts(ctx context.Context, accountID
return m.results, m.total, nil
}
type fakeContactExportMailer struct {
called bool
toEmail string
fileURL string
subject string
}
func (m *fakeContactExportMailer) SendContactExportComplete(ctx context.Context, account *model.Account, user *model.User, export *model.ContactExport) error {
m.called = true
m.toEmail = user.Email
m.fileURL = export.FileURL
m.subject = contactExportCompleteSubject
return nil
}
// ========== ListActive ==========
func TestContactService_ListActive_ReturnsContactsWithActivity(t *testing.T) {
@@ -224,6 +239,8 @@ func TestContactService_ExportContacts_PersistsArtifactAndNotification(t *testin
db, _, svc := setupContactService(t)
account := createTestAccount(t, db)
user := createTestUser(t, db, account.ID)
mailer := &fakeContactExportMailer{}
svc.SetContactExportMailer(mailer)
contact := &model.Contact{AccountID: account.ID, Name: "Export Alice", Email: "alice@example.com", PhoneNumber: "+111"}
require.NoError(t, db.Create(contact).Error)
tag := &model.Tag{AccountID: account.ID, Name: "vip"}
@@ -245,6 +262,10 @@ func TestContactService_ExportContacts_PersistsArtifactAndNotification(t *testin
assert.Equal(t, "ContactExport", notification.PrimaryActorType)
assert.Equal(t, export.ID, notification.PrimaryActorID)
assert.True(t, notification.EmailEnabled)
assert.True(t, mailer.called)
assert.Equal(t, user.Email, mailer.toEmail)
assert.Equal(t, export.FileURL, mailer.fileURL)
assert.Equal(t, contactExportCompleteSubject, mailer.subject)
}
func TestContactService_ExportContacts_FiltersByLabelAndColumns(t *testing.T) {