chore: import clean Agent Desk snapshot (3/7)
This commit is contained in:
445
internal/services/email_request_service.go
Normal file
445
internal/services/email_request_service.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/openidentity"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var EmailRequestService = newEmailRequestService()
|
||||
|
||||
func newEmailRequestService() *emailRequestService {
|
||||
return &emailRequestService{}
|
||||
}
|
||||
|
||||
type emailRequestService struct{}
|
||||
|
||||
// EmailRequestSourceAPI marks a message that entered through the API rather than
|
||||
// through a real inbound email, so nothing downstream mistakes it for one.
|
||||
const EmailRequestSourceAPI = "api_request"
|
||||
|
||||
// maxEmailRequestSubjectLength mirrors the conversation subject column width.
|
||||
const maxEmailRequestSubjectLength = 255
|
||||
|
||||
// EmailRequestInput is what an external system submits to open a support request
|
||||
// on a visitor's behalf.
|
||||
type EmailRequestInput struct {
|
||||
Email string
|
||||
Name string
|
||||
Subject string
|
||||
Message string
|
||||
}
|
||||
|
||||
// EmailRequestOutcome says where the submission actually landed, which is not
|
||||
// necessarily an email thread: a live chat takes precedence.
|
||||
type EmailRequestOutcome string
|
||||
|
||||
const (
|
||||
// EmailRequestOutcomeEmail means the request was filed as an email thread.
|
||||
EmailRequestOutcomeEmail EmailRequestOutcome = "email"
|
||||
// EmailRequestOutcomeChat means a chat was still live, so the submission was
|
||||
// appended to it and the visitor was nudged back to it.
|
||||
EmailRequestOutcomeChat EmailRequestOutcome = "chat"
|
||||
)
|
||||
|
||||
type EmailRequestResult struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
Outcome EmailRequestOutcome `json:"outcome"`
|
||||
// Verified reports whether the submitter presented the channel secret. When
|
||||
// false the contact details are self-reported and agents must be told so.
|
||||
Verified bool `json:"verified"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// HandleRequest files a support request supplied over the API and continues the
|
||||
// conversation over email from there.
|
||||
//
|
||||
// Two modes share one endpoint. Presenting the channel secret means the submitter
|
||||
// is trusted and the address becomes a verified contact; omitting it files the
|
||||
// request as self-reported. A secret that is present but wrong is an error rather
|
||||
// than a silent downgrade, because a typo must not quietly turn a verified
|
||||
// integration into an unverified one.
|
||||
func (s *emailRequestService) HandleRequest(ctx context.Context, channelID, secretHeader string, in EmailRequestInput) (*EmailRequestResult, error) {
|
||||
channel, err := s.resolveChannel(channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channelCfg, err := ChannelService.ParseEmailChannelConfig(channel.ConfigJSON)
|
||||
if err != nil || channelCfg == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.email.channelConfigInvalid")
|
||||
}
|
||||
|
||||
verified, err := verifyEmailRequestSecret(channelCfg, secretHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fromEmail := normalizeEmailAddress(in.Email)
|
||||
if err := validateEmailRequestAddress(fromEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromName := strings.TrimSpace(in.Name)
|
||||
if fromName == "" {
|
||||
fromName = localPartOf(fromEmail)
|
||||
}
|
||||
subject := normalizeEmailRequestSubject(in.Subject)
|
||||
bodyText := strings.TrimSpace(in.Message)
|
||||
if bodyText == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.email.requestMessageRequired")
|
||||
}
|
||||
|
||||
externalUser := openidentity.ExternalUser{
|
||||
ExternalSource: enums.ExternalSourceEmail,
|
||||
ExternalID: fromEmail,
|
||||
ExternalName: fromName,
|
||||
}
|
||||
|
||||
customerID, err := s.ensureCustomer(externalUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.markContactVerification(customerID, fromEmail, verified); err != nil {
|
||||
slog.Warn("failed to record email contact verification",
|
||||
"customerId", customerID, "email", fromEmail, "verified", verified, "error", err)
|
||||
}
|
||||
|
||||
content := bodyText
|
||||
if subject != "" {
|
||||
content = fmt.Sprintf("[%s]\n\n%s", subject, bodyText)
|
||||
}
|
||||
|
||||
// A chat the visitor is still using wins over their email. Mail that jumps
|
||||
// into an open chat gets answered where the visitor is no longer watching.
|
||||
if liveChat := s.findLiveChat(customerID, channel.ID); liveChat != nil {
|
||||
if err := s.postRequestMessage(liveChat, externalUser, content, subject, fromEmail, fromName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.sendChatNudge(liveChat, channel); err != nil {
|
||||
// The message is already in the chat, so a failed notice is not fatal.
|
||||
slog.Warn("failed to send chat continuation notice",
|
||||
"conversationId", liveChat.ID, "error", err)
|
||||
}
|
||||
return &EmailRequestResult{
|
||||
ConversationID: liveChat.ID,
|
||||
Outcome: EmailRequestOutcomeChat,
|
||||
Verified: verified,
|
||||
Subject: subject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
conversation, err := ConversationService.CreateNew(externalUser, channel.ID, channel.AIAgentID, subject)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create email conversation failed: %w", err)
|
||||
}
|
||||
if err := s.postRequestMessage(conversation, externalUser, content, subject, fromEmail, fromName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.sendAcknowledgement(conversation, channelCfg, channel); err != nil {
|
||||
// The request is already filed. Failing here would make the caller retry
|
||||
// and file the same request twice, which is worse than a missing receipt.
|
||||
slog.Warn("failed to send email acknowledgement",
|
||||
"conversationId", conversation.ID, "error", err)
|
||||
}
|
||||
|
||||
return &EmailRequestResult{
|
||||
ConversationID: conversation.ID,
|
||||
Outcome: EmailRequestOutcomeEmail,
|
||||
Verified: verified,
|
||||
Subject: subject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveChannel prefers an explicit channel id and otherwise falls back to the
|
||||
// first active email channel, mirroring the webhook path.
|
||||
func (s *emailRequestService) resolveChannel(channelID string) (*models.Channel, error) {
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
if channelID != "" {
|
||||
channel := ChannelService.Take(
|
||||
"channel_id = ? AND channel_type = ? AND status = ?",
|
||||
channelID, enums.ChannelTypeEmail, enums.StatusOk,
|
||||
)
|
||||
if channel == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.email.channelNotFound")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
channels := ChannelService.Find(sqls.NewCnd().
|
||||
Eq("channel_type", enums.ChannelTypeEmail).
|
||||
Eq("status", enums.StatusOk).
|
||||
Asc("id"))
|
||||
if len(channels) == 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.email.channelNotFound")
|
||||
}
|
||||
return &channels[0], nil
|
||||
}
|
||||
|
||||
// verifyEmailRequestSecret decides between the trusted and self-reported modes.
|
||||
func verifyEmailRequestSecret(cfg *dto.EmailChannelConfig, secretHeader string) (bool, error) {
|
||||
expected := strings.TrimSpace(cfg.WebhookSecret)
|
||||
if expected == "" {
|
||||
if current := config.GetCurrent(); current != nil {
|
||||
expected = strings.TrimSpace(current.Email.InboundSecret)
|
||||
}
|
||||
}
|
||||
provided := strings.TrimSpace(secretHeader)
|
||||
|
||||
if provided == "" {
|
||||
// Nothing was offered to check, so nothing is being trusted. This is the
|
||||
// documented guest mode, not a bypass.
|
||||
return false, nil
|
||||
}
|
||||
if expected == "" {
|
||||
// A secret was sent but the deployment never configured one to compare
|
||||
// against, so we cannot honestly call the submitter verified.
|
||||
return false, errorsx.InvalidParamI18n("error.email.requestSecretNotConfigured")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
|
||||
return false, errorsx.UnauthorizedI18n("error.auth.invalidSignature")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func normalizeEmailAddress(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func localPartOf(address string) string {
|
||||
if parts := strings.Split(address, "@"); len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// validateEmailRequestAddress stays deliberately permissive. The channel routes
|
||||
// by recipient address rather than by validating the sender, so a strict check
|
||||
// would reject legitimate addresses while proving nothing about ownership.
|
||||
func validateEmailRequestAddress(address string) error {
|
||||
if address == "" || !strings.Contains(address, "@") || len(address) > 200 {
|
||||
return errorsx.InvalidParamI18n("error.email.requestEmailInvalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmailRequestSubject(subject string) string {
|
||||
subject = strings.Join(strings.Fields(subject), " ")
|
||||
runes := []rune(subject)
|
||||
if len(runes) > maxEmailRequestSubjectLength {
|
||||
return strings.TrimSpace(string(runes[:maxEmailRequestSubjectLength]))
|
||||
}
|
||||
return subject
|
||||
}
|
||||
|
||||
func (s *emailRequestService) ensureCustomer(externalUser openidentity.ExternalUser) (int64, error) {
|
||||
var customerID int64
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
customerID = id
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return customerID, nil
|
||||
}
|
||||
|
||||
// findLiveChat returns the customer's most recent open conversation when it is
|
||||
// not on this email channel and still has recent activity: the case where the
|
||||
// visitor is watching a chat and an email should not steal the thread.
|
||||
func (s *emailRequestService) findLiveChat(customerID, emailChannelID int64) *models.Conversation {
|
||||
if customerID <= 0 {
|
||||
return nil
|
||||
}
|
||||
conversations := ConversationService.Find(sqls.NewCnd().
|
||||
Eq("customer_id", customerID).
|
||||
In("status", []enums.IMConversationStatus{
|
||||
enums.IMConversationStatusAIServing,
|
||||
enums.IMConversationStatusPending,
|
||||
enums.IMConversationStatusActive,
|
||||
}).
|
||||
Desc("last_active_at").
|
||||
Desc("id"))
|
||||
for i := range conversations {
|
||||
conversation := conversations[i]
|
||||
if conversation.ChannelID == emailChannelID {
|
||||
// Their thread is already on email; keep everything there.
|
||||
return nil
|
||||
}
|
||||
if isConversationLive(&conversation, emailChatLivenessWindow()) {
|
||||
return &conversation
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emailChatLivenessWindow is how long a chat still counts as being watched. A
|
||||
// chat the visitor abandoned is where an email should not be answered.
|
||||
func emailChatLivenessWindow() time.Duration {
|
||||
if current := config.GetCurrent(); current != nil {
|
||||
return current.Conversation.EmailChatLiveWindow()
|
||||
}
|
||||
return 30 * time.Minute
|
||||
}
|
||||
|
||||
func isConversationLive(conversation *models.Conversation, window time.Duration) bool {
|
||||
if conversation == nil || conversation.LastActiveAt.IsZero() || window <= 0 {
|
||||
return false
|
||||
}
|
||||
return time.Since(conversation.LastActiveAt) <= window
|
||||
}
|
||||
|
||||
func (s *emailRequestService) postRequestMessage(conversation *models.Conversation, externalUser openidentity.ExternalUser, content, subject, fromEmail, fromName string) error {
|
||||
payloadMap := map[string]any{
|
||||
"email_from": fromEmail,
|
||||
"email_from_name": fromName,
|
||||
"email_subject": subject,
|
||||
"email_source": EmailRequestSourceAPI,
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payloadMap)
|
||||
|
||||
_, err := MessageService.SendCustomerMessage(
|
||||
conversation.ID,
|
||||
fmt.Sprintf("mail_api_%s", strs.UUID()),
|
||||
enums.IMMessageTypeText,
|
||||
content,
|
||||
string(payloadBytes),
|
||||
externalUser,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send request message failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendAcknowledgement is the first thing the visitor sees. It goes out as an
|
||||
// ordinary channel message so it is queued, retried and delivered through
|
||||
// whichever provider is configured, SMTP included.
|
||||
func (s *emailRequestService) sendAcknowledgement(conversation *models.Conversation, cfg *dto.EmailChannelConfig, channel *models.Channel) error {
|
||||
text := acknowledgementText(cfg)
|
||||
return s.sendChannelNotice(conversation, channel, text, "email_acknowledgement")
|
||||
}
|
||||
|
||||
// sendChatNudge tells the visitor their mail arrived while a chat was open.
|
||||
func (s *emailRequestService) sendChatNudge(conversation *models.Conversation, channel *models.Channel) error {
|
||||
text := i18nx.Getf(i18nx.DefaultLocale, "email.request.chatContinuation")
|
||||
return s.sendChannelNotice(conversation, channel, text, "email_chat_nudge")
|
||||
}
|
||||
|
||||
// sendChannelNotice records the notice in the conversation and queues it for the
|
||||
// email channel. It is attributed to the channel's AI agent when there is one,
|
||||
// because no human has read the request yet and an agent message with no author
|
||||
// reads as a fault rather than as an acknowledgement.
|
||||
//
|
||||
// The queueing is done explicitly rather than left to the message service, which
|
||||
// only emails messages belonging to an email conversation. A notice about a chat
|
||||
// has to reach the visitor's inbox even though the chat lives on another channel.
|
||||
func (s *emailRequestService) sendChannelNotice(conversation *models.Conversation, channel *models.Channel, text, clientMsgPrefix string) error {
|
||||
if conversation == nil || channel == nil || strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
operator := &dto.AuthPrincipal{UserID: 0, Username: "system", Nickname: "system"}
|
||||
clientMsgID := fmt.Sprintf("%s_%s", clientMsgPrefix, strs.UUID())
|
||||
|
||||
var (
|
||||
message *models.Message
|
||||
err error
|
||||
)
|
||||
if channel.AIAgentID > 0 {
|
||||
message, err = MessageService.SendAIMessage(
|
||||
conversation.ID, channel.AIAgentID, clientMsgID,
|
||||
enums.IMMessageTypeText, text, "", operator,
|
||||
)
|
||||
} else {
|
||||
message, err = MessageService.SendAgentMessage(
|
||||
conversation.ID, 0, clientMsgID,
|
||||
enums.IMMessageTypeText, text, "", operator,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ChannelMessageOutboxService.EnqueueEmailMessageVia(channel, conversation, message)
|
||||
}
|
||||
|
||||
// acknowledgementText prefers the operator's wording for the channel and falls
|
||||
// back to a localized default, so an integrator can brand the first email.
|
||||
func acknowledgementText(cfg *dto.EmailChannelConfig) string {
|
||||
if cfg != nil {
|
||||
if configured := strings.TrimSpace(cfg.WelcomeMessage); configured != "" {
|
||||
return configured
|
||||
}
|
||||
}
|
||||
return i18nx.Getf(i18nx.DefaultLocale, "email.request.acknowledgement")
|
||||
}
|
||||
|
||||
// markContactVerification records whether we could verify the address.
|
||||
//
|
||||
// A verified contact means the submitting system vouched for it with the channel
|
||||
// secret; an unverified one is whatever the submitter typed. Agents have to be
|
||||
// able to tell those apart before acting on the address.
|
||||
func (s *emailRequestService) markContactVerification(customerID int64, address string, verified bool) error {
|
||||
if customerID <= 0 || address == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
remark := contactVerificationRemark(verified)
|
||||
|
||||
existing := CustomerContactService.FindOne(sqls.NewCnd().
|
||||
Eq("customer_id", customerID).
|
||||
Eq("contact_type", enums.ContactTypeEmail).
|
||||
Eq("contact_value", address))
|
||||
if existing != nil {
|
||||
updates := map[string]interface{}{
|
||||
"is_verified": verified,
|
||||
"remark": remark,
|
||||
"updated_at": now,
|
||||
}
|
||||
if verified {
|
||||
updates["verified_at"] = &now
|
||||
}
|
||||
return CustomerContactService.Updates(existing.ID, updates)
|
||||
}
|
||||
|
||||
contact := &models.CustomerContact{
|
||||
CustomerID: customerID,
|
||||
ContactType: enums.ContactTypeEmail,
|
||||
ContactValue: address,
|
||||
IsPrimary: true,
|
||||
IsVerified: verified,
|
||||
Source: EmailRequestSourceAPI,
|
||||
Status: enums.StatusOk,
|
||||
Remark: remark,
|
||||
AuditFields: utils.BuildAuditFields(nil),
|
||||
}
|
||||
if verified {
|
||||
contact.VerifiedAt = &now
|
||||
}
|
||||
return CustomerContactService.Create(contact)
|
||||
}
|
||||
|
||||
// contactVerificationRemark is the plain-language marker agents read in the
|
||||
// dashboard, so an unverified address cannot be mistaken for a proven one.
|
||||
func contactVerificationRemark(verified bool) string {
|
||||
if verified {
|
||||
return i18nx.Getf(i18nx.DefaultLocale, "email.contact.verifiedRemark")
|
||||
}
|
||||
return i18nx.Getf(i18nx.DefaultLocale, "email.contact.unverifiedRemark")
|
||||
}
|
||||
Reference in New Issue
Block a user