feat(conversations): align transcript delivery

This commit is contained in:
2026-06-07 12:08:53 +08:00
parent 83878eb606
commit bec65a86e4
12 changed files with 461 additions and 24 deletions
+47 -2
View File
@@ -525,10 +525,24 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
if len(recipients) == 0 {
return ActionDeliveryResult{DeliveryType: "email_transcript"}, fmt.Errorf("send_email_transcript action requires 'email' param")
}
account, err := s.loadTranscriptAccount(ctx, accountID)
if err != nil {
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
}
if !account.EmailTranscriptEnabled() {
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ","), ResponseBody: "email_transcript_disabled"}, nil
}
if s.worker != nil {
for _, recipient := range recipients {
_, err := s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{
allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now())
if err != nil {
return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err
}
if !allowed {
break
}
_, err = s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{
AccountID: accountID,
ConversationID: conversationID,
Recipient: recipient,
@@ -547,6 +561,13 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
aggregate := ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}
for _, recipient := range recipients {
allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now())
if err != nil {
return aggregate, err
}
if !allowed {
break
}
result, err := s.transcriptDeliverer.DeliverTranscript(ctx, AutomationTranscriptRequest{
AccountID: accountID,
ConversationID: conversationID,
@@ -847,6 +868,30 @@ func extractTranscriptRecipients(params map[string]interface{}) []string {
return recipients
}
func (s *ActionService) loadTranscriptAccount(ctx context.Context, accountID uint) (*model.Account, error) {
var account model.Account
if err := s.db.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
return nil, err
}
return &account, nil
}
func (s *ActionService) consumeTranscriptEmailQuota(ctx context.Context, account *model.Account, now time.Time) (bool, error) {
if account == nil {
return false, fmt.Errorf("account is required")
}
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
return false, nil
}
if err := account.IncrementEmailSentCount(now); err != nil {
return false, err
}
if err := s.db.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
return false, err
}
return true, nil
}
func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string) (map[string]interface{}, error) {
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).
@@ -937,7 +982,7 @@ func (s *ActionService) buildTranscriptEmail(ctx context.Context, accountID, con
var messages []model.Message
if err := s.db.DB().WithContext(ctx).
Where("conversation_id = ? AND account_id = ? AND private = ?", conversationID, accountID, false).
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversationID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
Order("id ASC").
Find(&messages).Error; err != nil {
return "", "", err
@@ -13,6 +13,7 @@ import (
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
"gorm.io/datatypes"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -123,6 +124,9 @@ func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T)
if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "transcript body", ContentType: "text", MessageType: "incoming"}).Error; err != nil {
t.Fatalf("seed message: %v", err)
}
_ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "private body", ContentType: "text", MessageType: "incoming", Private: true}).Error
_ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "activity body", ContentType: "text", MessageType: "activity"}).Error
_ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "template body", ContentType: "text", MessageType: "template"}).Error
result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "send_email_transcript",
@@ -140,11 +144,70 @@ func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T)
if !strings.Contains(transcript.requests[0].Subject, "Conversation Transcript") || !strings.Contains(transcript.requests[0].Body, "transcript body") {
t.Fatalf("expected transcript subject/body to be populated: %#v", transcript.requests[0])
}
if strings.Contains(transcript.requests[0].Body, "private body") || strings.Contains(transcript.requests[0].Body, "activity body") || strings.Contains(transcript.requests[0].Body, "template body") {
t.Fatalf("transcript body should include only public incoming/outgoing chat messages: %q", transcript.requests[0].Body)
}
var account model.Account
if err := db.First(&account, accountID).Error; err != nil || account.EmailsSentToday(time.Now()) != 2 {
t.Fatalf("expected two transcript sends to increment account counter, account=%#v err=%v", account, err)
}
if result.DeliveryType != "email_transcript" || result.Target != "first@example.com,second@example.com" || result.Attempts != 2 {
t.Fatalf("unexpected transcript action result metadata: %#v", result)
}
}
func TestActionService_SendEmailTranscript_DisabledNoops(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, _ := seedTestAccount(db, t)
if err := db.Model(&model.Account{}).Where("id = ?", accountID).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error; err != nil {
t.Fatalf("disable transcripts: %v", err)
}
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}}
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript)
defer restore()
result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "send_email_transcript",
ActionParams: map[string]interface{}{"email": "first@example.com"},
}, ActionSourceAutomation, 99)
if err != nil {
t.Fatalf("disabled transcript action should no-op: %v", err)
}
if len(transcript.requests) != 0 || result.ResponseBody != "email_transcript_disabled" {
t.Fatalf("expected disabled transcript no-op, result=%#v requests=%#v", result, transcript.requests)
}
}
func TestActionService_SendEmailTranscript_StopsAtRateLimit(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, _ := seedTestAccount(db, t)
if err := db.Model(&model.Account{}).Where("id = ?", accountID).Updates(map[string]any{"limits": datatypes.JSON(`{"emails":1}`)}).Error; err != nil {
t.Fatalf("set email limit: %v", err)
}
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
conversationID := seedTestConversation(db, t, accountID, inboxID, contactID)
transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}}
restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript)
defer restore()
_, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{
ActionName: "send_email_transcript",
ActionParams: map[string]interface{}{"email": "first@example.com,second@example.com"},
}, ActionSourceAutomation, 99)
if err != nil {
t.Fatalf("rate-limited transcript action should stop without error: %v", err)
}
if len(transcript.requests) != 1 || transcript.requests[0].Recipient != "first@example.com" {
t.Fatalf("expected only first recipient before rate limit, got %#v", transcript.requests)
}
}
func TestActionService_SendWebhookEvent_QueuesDurableDelivery(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
@@ -721,6 +721,14 @@ func (h *ConversationHandler) Transcript(c *gin.Context) {
return
}
if svcErr := h.conversationSvc.SendTranscript(c.Request.Context(), accountID, conversation.ID, req.Email); svcErr != nil {
if errors.Is(svcErr, service.ErrEmailTranscriptDisabled) {
c.JSON(http.StatusPaymentRequired, gin.H{"error": "Email transcript is not available on your plan"})
return
}
if errors.Is(svcErr, service.ErrEmailRateLimited) {
c.Status(http.StatusTooManyRequests)
return
}
handleServiceError(c, svcErr)
return
}
@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -395,6 +396,46 @@ func (s *ConversationHandlerTestSuite) TestTranscript_InvalidEmail() {
assert.Empty(s.T(), w.Body.String())
}
func (s *ConversationHandlerTestSuite) TestTranscript_PaymentRequiredWhenDisabled() {
s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{
"limits": datatypes.JSON(`{"email_transcript_enabled":false}`),
"custom_attributes": datatypes.JSON(`{}`),
}).Error)
s.T().Cleanup(func() {
_ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error
})
bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusPaymentRequired, w.Code)
var resp map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), "Email transcript is not available on your plan", resp["error"])
}
func (s *ConversationHandlerTestSuite) TestTranscript_TooManyRequestsWhenRateLimited() {
s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{
"limits": datatypes.JSON(`{"emails":1}`),
"custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`),
}).Error)
s.T().Cleanup(func() {
_ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error
})
bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusTooManyRequests, w.Code)
assert.Empty(s.T(), w.Body.String())
}
func (s *ConversationHandlerTestSuite) TestTranscript_ConversationNotFound() {
body := map[string]string{"email": "test@example.com"}
bodyBytes, _ := json.Marshal(body)
+1
View File
@@ -20,6 +20,7 @@ type Account struct {
CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"`
FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags
AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days
Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails
AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents]
InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes]
CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"`
+133
View File
@@ -0,0 +1,133 @@
package model
import (
"encoding/json"
"strconv"
"strings"
"time"
"gorm.io/datatypes"
)
const accountOutboundEmailCountKey = "_outbound_email_count"
// LimitsMap returns the Chatwoot-style account limits JSON object.
func (a *Account) LimitsMap() map[string]any {
return jsonMap(a.Limits)
}
// EmailTranscriptEnabled mirrors Account#email_transcript_enabled?. Community
// accounts allow transcripts by default; enterprise tests may disable it via
// limits or custom attributes.
func (a *Account) EmailTranscriptEnabled() bool {
if a == nil {
return true
}
for _, attrs := range []map[string]any{a.LimitsMap(), a.CustomAttributesMap()} {
if value, ok := firstPresent(attrs, "email_transcript_enabled", "email_transcript"); ok {
return truthy(value, true)
}
}
return true
}
// EmailRateLimit returns the configured daily transcript/outbound email limit.
// A zero value means unlimited, matching non-cloud Chatwoot behavior.
func (a *Account) EmailRateLimit() int {
if a == nil {
return 0
}
for _, attrs := range []map[string]any{a.LimitsMap(), a.CustomAttributesMap()} {
if value, ok := firstPresent(attrs, "emails", "email_rate_limit", "outbound_email_limit"); ok {
if n := intValue(value); n > 0 {
return n
}
}
}
return 0
}
func (a *Account) EmailsSentToday(now time.Time) int {
attrs := a.CustomAttributesMap()
value, ok := attrs[accountOutboundEmailCountKey]
if !ok {
return intValue(attrs["emails_sent_today"])
}
record, ok := value.(map[string]any)
if !ok || strings.TrimSpace(stringValue(record["date"])) != now.Format("2006-01-02") {
return 0
}
return intValue(record["count"])
}
func (a *Account) IncrementEmailSentCount(now time.Time) error {
attrs := a.CustomAttributesMap()
count := a.EmailsSentToday(now) + 1
attrs[accountOutboundEmailCountKey] = map[string]any{
"date": now.Format("2006-01-02"),
"count": count,
}
return a.SetCustomAttributesMap(attrs)
}
func jsonMap(raw datatypes.JSON) map[string]any {
attrs := map[string]any{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &attrs)
}
return attrs
}
func firstPresent(attrs map[string]any, keys ...string) (any, bool) {
for _, key := range keys {
if value, ok := attrs[key]; ok {
return value, true
}
}
return nil, false
}
func truthy(value any, defaultValue bool) bool {
switch v := value.(type) {
case bool:
return v
case string:
s := strings.TrimSpace(strings.ToLower(v))
if s == "" {
return defaultValue
}
return s == "true" || s == "1" || s == "yes" || s == "enabled"
case float64:
return v != 0
case int:
return v != 0
default:
return defaultValue
}
}
func intValue(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
n, _ := v.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(v))
return n
default:
return 0
}
}
func stringValue(value any) string {
if s, ok := value.(string); ok {
return s
}
return ""
}
+83 -18
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -22,20 +23,21 @@ import (
// ConversationService implements business logic for Conversation operations.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb
type ConversationService struct {
repo *repository.ConversationRepo
msgRepo *repository.MessageRepo
dispatcher *channel.Dispatcher
inboxMemberSvc *InboxMemberService
accountUserRepo *repository.AccountUserRepo
teamRepo *repository.TeamRepo
teamMemberRepo *repository.TeamMemberRepo
searchIndexer SearchIndexer
appliedSlaSvc *AppliedSlaService
repo *repository.ConversationRepo
msgRepo *repository.MessageRepo
dispatcher *channel.Dispatcher
inboxMemberSvc *InboxMemberService
accountUserRepo *repository.AccountUserRepo
teamRepo *repository.TeamRepo
teamMemberRepo *repository.TeamMemberRepo
searchIndexer SearchIndexer
appliedSlaSvc *AppliedSlaService
transcriptMailer automation.AutomationTranscriptDeliverer
}
// NewConversationService creates a new Conversation service.
func NewConversationService(repo *repository.ConversationRepo, msgRepo *repository.MessageRepo, dispatcher *channel.Dispatcher, inboxMemberSvc *InboxMemberService, accountUserRepo *repository.AccountUserRepo, teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo) *ConversationService {
return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo}
return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer()}
}
func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) {
@@ -46,6 +48,10 @@ func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaServ
s.appliedSlaSvc = appliedSlaSvc
}
func (s *ConversationService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) {
s.transcriptMailer = deliverer
}
func (s *ConversationService) DB() *gorm.DB {
if s == nil || s.repo == nil {
return nil
@@ -1454,24 +1460,81 @@ func (s *ConversationService) MarkUnread(ctx context.Context, accountID, id uint
// SendTranscript sends a conversation transcript via email.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#transcript
func (s *ConversationService) SendTranscript(ctx context.Context, accountID, conversationID uint, email string) error {
recipient := strings.TrimSpace(email)
if recipient == "" {
return errors.New("email address is required for transcript")
}
conversation, err := s.repo.FindByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return err
}
// In a full implementation, this would generate the transcript and send via an email service.
// For now, we log the transcript request for future integration.
applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d",
conversation.ID, conversation.AccountID, email, conversation.ContactID)
// Validate email is not empty
if strings.TrimSpace(email) == "" {
return errors.New("email address is required for transcript")
var account model.Account
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
return err
}
now := time.Now()
if !account.EmailTranscriptEnabled() {
return ErrEmailTranscriptDisabled
}
if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit {
return ErrEmailRateLimited
}
subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversation)
if err != nil {
return err
}
if s.transcriptMailer != nil {
_, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{
AccountID: accountID,
ConversationID: conversation.ID,
Recipient: recipient,
Subject: subject,
Body: body,
})
if err != nil {
return err
}
}
if err := account.IncrementEmailSentCount(now); err != nil {
return err
}
if err := s.repo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil {
return err
}
applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d",
conversation.ID, conversation.AccountID, recipient, conversation.ContactID)
return nil
}
func (s *ConversationService) buildTranscriptEmail(ctx context.Context, accountID uint, conversation *model.Conversation) (string, string, error) {
var messages []model.Message
if err := s.repo.DB().WithContext(ctx).
Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}).
Order("id ASC").
Find(&messages).Error; err != nil {
return "", "", err
}
displayID := conversation.ID
if conversation.DisplayID != nil && *conversation.DisplayID > 0 {
displayID = *conversation.DisplayID
}
subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID)
var body strings.Builder
body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID))
for _, message := range messages {
if strings.TrimSpace(message.Content) == "" {
continue
}
body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content))
}
return subject, body.String(), nil
}
// UpdateCustomAttributes updates the custom attributes of a conversation.
// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#custom_attributes
func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accountID, id uint, attrs datatypes.JSON) (*model.Conversation, error) {
@@ -1503,6 +1566,8 @@ type UnreadCountsPayload struct {
}
var ErrConversationUnreadCountsFeatureNotEnabled = errors.New("Conversation unread counts feature not enabled for this account")
var ErrEmailTranscriptDisabled = errors.New("Email transcript is not available on your plan")
var ErrEmailRateLimited = errors.New("email transcript rate limit exceeded")
// GetUnreadCounts returns unread conversation counts grouped by inbox, label, and team.
// Reference: Chatwoot app/services/conversations/unread_counts/counter.rb
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -120,6 +121,24 @@ func (l *captureConversationEventsListener) OnEvent(ctx context.Context, event *
return nil
}
type recordingConversationTranscriptDeliverer struct {
requests []automation.AutomationTranscriptRequest
result automation.ActionDeliveryResult
err error
}
func (d *recordingConversationTranscriptDeliverer) DeliverTranscript(ctx context.Context, req automation.AutomationTranscriptRequest) (automation.ActionDeliveryResult, error) {
d.requests = append(d.requests, req)
result := d.result
if result.DeliveryType == "" {
result.DeliveryType = "email_transcript"
}
if result.Target == "" {
result.Target = req.Recipient
}
return result, d.err
}
func createConversationServiceTestSlaPolicy(t *testing.T, db *gorm.DB, accountID uint) *model.SlaPolicy {
t.Helper()
policy := &model.SlaPolicy{
@@ -659,15 +678,35 @@ func TestConversationService_UpdateLastSeen_ThrottlesRecentNoUnreadTimestamps(t
func TestConversationService_SendTranscript(t *testing.T) {
svc, db := setupConversationService(t)
deliverer := &recordingConversationTranscriptDeliverer{}
svc.SetTranscriptDeliverer(deliverer)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
require.NoError(t, db.Model(conv).Update("display_id", 42).Error)
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible incoming", ContentType: "text", MessageType: string(model.MessageTypeIncoming)}).Error)
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible outgoing", ContentType: "text", MessageType: string(model.MessageTypeOutgoing)}).Error)
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden private", ContentType: "text", MessageType: string(model.MessageTypeIncoming), Private: true}).Error)
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden activity", ContentType: "text", MessageType: string(model.MessageTypeActivity)}).Error)
require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden template", ContentType: "text", MessageType: string(model.MessageTypeTemplate)}).Error)
err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com")
assert.NoError(t, err)
require.Len(t, deliverer.requests, 1)
assert.Equal(t, "test@example.com", deliverer.requests[0].Recipient)
assert.Equal(t, "[#42] Conversation Transcript", deliverer.requests[0].Subject)
assert.Contains(t, deliverer.requests[0].Body, "visible incoming")
assert.Contains(t, deliverer.requests[0].Body, "visible outgoing")
assert.NotContains(t, deliverer.requests[0].Body, "hidden private")
assert.NotContains(t, deliverer.requests[0].Body, "hidden activity")
assert.NotContains(t, deliverer.requests[0].Body, "hidden template")
var updated model.Account
require.NoError(t, db.First(&updated, account.ID).Error)
assert.Equal(t, 1, updated.EmailsSentToday(time.Now()))
}
func TestConversationService_SendTranscript_EmptyEmail(t *testing.T) {
@@ -705,6 +744,41 @@ func TestConversationService_SendTranscript_ConversationNotFound(t *testing.T) {
assert.Error(t, err)
}
func TestConversationService_SendTranscript_Disabled(t *testing.T) {
svc, db := setupConversationService(t)
deliverer := &recordingConversationTranscriptDeliverer{}
svc.SetTranscriptDeliverer(deliverer)
account := createConversationServiceTestAccount(t, db)
require.NoError(t, db.Model(account).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com")
assert.ErrorIs(t, err, ErrEmailTranscriptDisabled)
assert.Empty(t, deliverer.requests)
}
func TestConversationService_SendTranscript_RateLimited(t *testing.T) {
svc, db := setupConversationService(t)
deliverer := &recordingConversationTranscriptDeliverer{}
svc.SetTranscriptDeliverer(deliverer)
account := createConversationServiceTestAccount(t, db)
require.NoError(t, db.Model(account).Updates(map[string]any{
"limits": datatypes.JSON(`{"emails":1}`),
"custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`),
}).Error)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com")
assert.ErrorIs(t, err, ErrEmailRateLimited)
assert.Empty(t, deliverer.requests)
}
// ========== UpdateCustomAttributes Tests ==========
func TestConversationService_UpdateCustomAttributes(t *testing.T) {