feat(crm): email contact export completions
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user