From c2766fe6427852972df6f83ee6c26f4866fdb13d Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 04:29:24 +0800 Subject: [PATCH] feat(crm): email contact export completions --- internal/app/bootstrap.go | 1 + internal/service/contact_export_mailer.go | 112 ++++++++++++++++++++ internal/service/contact_service.go | 19 ++++ internal/service/contact_service_g3_test.go | 21 ++++ 4 files changed, 153 insertions(+) create mode 100644 internal/service/contact_export_mailer.go diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 7e65f266..272ecf52 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -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) diff --git a/internal/service/contact_export_mailer.go b/internal/service/contact_export_mailer.go new file mode 100644 index 00000000..ecc1962f --- /dev/null +++ b/internal/service/contact_export_mailer.go @@ -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 " + } + 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 +} diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index fbfba87c..612fade9 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -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) diff --git a/internal/service/contact_service_g3_test.go b/internal/service/contact_service_g3_test.go index a114243a..06390f72 100644 --- a/internal/service/contact_service_g3_test.go +++ b/internal/service/contact_service_g3_test.go @@ -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) {