446 lines
15 KiB
Go
446 lines
15 KiB
Go
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")
|
|
}
|