Files
gochat/internal/handler/api/v1/email_channel_handler.go
T

445 lines
14 KiB
Go

package v1
// EmailChannelHandler handles Email (SMTP/IMAP) channel-specific configuration CRUD.
// Reference: Chatwoot app/controllers/api/v1/accounts/channels/email_controller.rb
//
// Email channel in gochat maps to:
// - POST /api/v1/accounts/:id/channels/email_channel → create Email inbox
// - GET /api/v1/accounts/:id/channels/email_channel/:em_id → get Email channel
// - PATCH /api/v1/accounts/:id/channels/email_channel/:em_id → update Email channel
// - DELETE /api/v1/accounts/:id/channels/email_channel/:em_id → delete Email channel
// - GET /api/v1/accounts/:id/channels/email_channel → list Email channels
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
)
// EmailChannelHandler handles Email channel management.
// Follows LINEChannelHandler pattern: uses top-level ChannelEmailService for CRUD,
// generic InboxService for inbox lifecycle.
type EmailChannelHandler struct {
emailChannelSvc *service.ChannelEmailService
inboxSvc *service.InboxService
emailRepo *repository.ChannelEmailRepo
}
// NewEmailChannelHandler creates a new Email channel handler.
func NewEmailChannelHandler(
emailChannelSvc *service.ChannelEmailService,
inboxSvc *service.InboxService,
emailRepo *repository.ChannelEmailRepo,
) *EmailChannelHandler {
return &EmailChannelHandler{
emailChannelSvc: emailChannelSvc,
inboxSvc: inboxSvc,
emailRepo: emailRepo,
}
}
// CreateEmailChannelRequest is the DTO for creating an Email channel.
type CreateEmailChannelRequest struct {
Email string `json:"email" validate:"required"`
MailboxName string `json:"mailbox_name"`
// IMAP configuration
IMAPEnabled bool `json:"imap_enabled"`
IMAPAddress string `json:"imap_address"`
IMAPPort int `json:"imap_port"`
IMAPLogin string `json:"imap_login"`
IMAPPassword string `json:"imap_password"`
IMAPSSLMode string `json:"imap_ssl_mode"`
IMAPFolder string `json:"imap_folder"`
// SMTP configuration
SMTPEnabled bool `json:"smtp_enabled"`
SMTPAddress string `json:"smtp_address"`
SMTPPort int `json:"smtp_port"`
SMTPLogin string `json:"smtp_login"`
SMTPPassword string `json:"smtp_password"`
SMTPSSLMode string `json:"smtp_ssl_mode"`
// Behavioral configuration
InboxName string `json:"inbox_name"`
}
// Create adds a new Email channel and creates the associated inbox.
// POST /api/v1/accounts/:id/channels/email_channel
func (h *EmailChannelHandler) Create(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
var req CreateEmailChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
applogger.L().Errorf("Failed to bind Email channel create request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
ctx := c.Request.Context()
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, accountID); err != nil {
if renderInboxLimitExceeded(c, err) {
return
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
return
}
// Create the channel record first (without InboxID)
ch := &channelmodel.ChannelEmail{
Email: req.Email,
MailboxName: req.MailboxName,
Domain: extractDomain(req.Email),
// IMAP config
IMAPEnabled: req.IMAPEnabled,
IMAPAddress: req.IMAPAddress,
IMAPPort: req.IMAPPort,
IMAPLogin: req.IMAPLogin,
IMAPPassword: req.IMAPPassword,
IMAPSSLMode: req.IMAPSSLMode,
IMAPFolder: req.IMAPFolder,
// SMTP config
SMTPEnabled: req.SMTPEnabled,
SMTPAddress: req.SMTPAddress,
SMTPPort: req.SMTPPort,
SMTPLogin: req.SMTPLogin,
SMTPPassword: req.SMTPPassword,
SMTPSSLMode: req.SMTPSSLMode,
AccountID: accountID,
}
if err := h.emailChannelSvc.Create(ctx, ch); err != nil {
applogger.L().Errorf("Failed to create Email channel: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create Email channel"})
return
}
// Create inbox for the Email channel
inboxName := req.InboxName
if inboxName == "" {
inboxName = req.MailboxName
}
if inboxName == "" {
inboxName = req.Email
}
inboxReq := service.CreateInboxRequest{
Name: inboxName,
ChannelType: "email",
Enabled: true,
}
inboxReq.Channel = emailChannelConfig(req)
inbox, err := h.inboxSvc.Create(ctx, accountID, inboxReq)
if err != nil {
applogger.L().Errorf("Failed to create inbox for Email channel: %v", err)
// Rollback channel creation
if delErr := h.emailChannelSvc.Delete(ctx, ch.ID); delErr != nil {
applogger.L().Warnf("Failed to rollback Email channel after inbox creation failure: %v", delErr)
}
if renderInboxLimitExceeded(c, err) {
return
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create inbox"})
return
}
// Update channel with InboxID
ch.InboxID = inbox.ID
if err := h.emailChannelSvc.Update(ctx, ch); err != nil {
applogger.L().Errorf("Failed to update Email channel with inbox_id: %v", err)
}
inbox, err = h.inboxSvc.BindChannel(ctx, accountID, inbox.ID, ch.ID, emailChannelConfig(req))
if err != nil {
applogger.L().Warnf("Failed to bind Email inbox channel config: %v", err)
}
c.JSON(http.StatusOK, serializeInbox(inbox))
}
// Get retrieves an Email channel by ID.
// GET /api/v1/accounts/:id/channels/email_channel/:em_id
func (h *EmailChannelHandler) Get(c *gin.Context) {
emID, err := parseUintParam(c, "em_id")
if err != nil {
applogger.L().Errorf("Invalid em_id: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid em_id"})
return
}
ch, err := h.emailChannelSvc.GetByID(c.Request.Context(), emID)
if err != nil {
applogger.L().Errorf("Failed to get Email channel: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Email channel not found"})
return
}
c.JSON(http.StatusOK, h.serializeInboxForEmailChannel(c, ch))
}
// UpdateEmailChannelRequest is the DTO for updating an Email channel.
type UpdateEmailChannelRequest struct {
Email *string `json:"email"`
MailboxName *string `json:"mailbox_name"`
IMAPEnabled *bool `json:"imap_enabled"`
IMAPAddress *string `json:"imap_address"`
IMAPPort *int `json:"imap_port"`
IMAPLogin *string `json:"imap_login"`
IMAPPassword *string `json:"imap_password"`
IMAPSSLMode *string `json:"imap_ssl_mode"`
IMAPFolder *string `json:"imap_folder"`
SMTPEnabled *bool `json:"smtp_enabled"`
SMTPAddress *string `json:"smtp_address"`
SMTPPort *int `json:"smtp_port"`
SMTPLogin *string `json:"smtp_login"`
SMTPPassword *string `json:"smtp_password"`
SMTPSSLMode *string `json:"smtp_ssl_mode"`
InboxName *string `json:"inbox_name"`
}
// Update updates an Email channel configuration.
// PATCH /api/v1/accounts/:id/channels/email_channel/:em_id
func (h *EmailChannelHandler) Update(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
emID, err := parseUintParam(c, "em_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid em_id"})
return
}
ctx := c.Request.Context()
ch, err := h.emailChannelSvc.GetByID(ctx, emID)
if err != nil {
applogger.L().Errorf("Failed to get Email channel for update: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Email channel not found"})
return
}
// Verify account ownership
if ch.AccountID != accountID {
c.JSON(http.StatusForbidden, gin.H{"error": "Email channel does not belong to this account"})
return
}
var req UpdateEmailChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
// Apply updates
if req.Email != nil {
ch.Email = *req.Email
ch.Domain = extractDomain(*req.Email)
}
if req.MailboxName != nil {
ch.MailboxName = *req.MailboxName
}
if req.IMAPEnabled != nil {
ch.IMAPEnabled = *req.IMAPEnabled
}
if req.IMAPAddress != nil {
ch.IMAPAddress = *req.IMAPAddress
}
if req.IMAPPort != nil {
ch.IMAPPort = *req.IMAPPort
}
if req.IMAPLogin != nil {
ch.IMAPLogin = *req.IMAPLogin
}
if req.IMAPPassword != nil {
ch.IMAPPassword = *req.IMAPPassword
}
if req.IMAPSSLMode != nil {
ch.IMAPSSLMode = *req.IMAPSSLMode
}
if req.IMAPFolder != nil {
ch.IMAPFolder = *req.IMAPFolder
}
if req.SMTPEnabled != nil {
ch.SMTPEnabled = *req.SMTPEnabled
}
if req.SMTPAddress != nil {
ch.SMTPAddress = *req.SMTPAddress
}
if req.SMTPPort != nil {
ch.SMTPPort = *req.SMTPPort
}
if req.SMTPLogin != nil {
ch.SMTPLogin = *req.SMTPLogin
}
if req.SMTPPassword != nil {
ch.SMTPPassword = *req.SMTPPassword
}
if req.SMTPSSLMode != nil {
ch.SMTPSSLMode = *req.SMTPSSLMode
}
if err := h.emailChannelSvc.Update(ctx, ch); err != nil {
applogger.L().Errorf("Failed to update Email channel: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update Email channel"})
return
}
// Update inbox name if provided
if req.InboxName != nil && ch.InboxID > 0 {
updateReq := service.UpdateInboxRequest{
Name: *req.InboxName,
}
if _, inboxErr := h.inboxSvc.Update(ctx, accountID, ch.InboxID, updateReq); inboxErr != nil {
applogger.L().Warnf("Failed to update Email inbox name: %v", inboxErr)
}
}
inbox, bindErr := h.inboxSvc.BindChannel(ctx, accountID, ch.InboxID, ch.ID, emailUpdateChannelConfig(req, ch))
if bindErr != nil {
applogger.L().Warnf("Failed to update Email inbox channel config: %v", bindErr)
c.JSON(http.StatusOK, h.serializeInboxForEmailChannel(c, ch))
return
}
c.JSON(http.StatusOK, serializeInbox(inbox))
}
// Delete removes an Email channel and its associated inbox.
// DELETE /api/v1/accounts/:id/channels/email_channel/:em_id
func (h *EmailChannelHandler) Delete(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
emID, err := parseUintParam(c, "em_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid em_id"})
return
}
ctx := c.Request.Context()
ch, err := h.emailChannelSvc.GetByID(ctx, emID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Email channel not found"})
return
}
// Verify account ownership
if ch.AccountID != accountID {
c.JSON(http.StatusForbidden, gin.H{"error": "Email channel does not belong to this account"})
return
}
// Delete the channel record
if err := h.emailChannelSvc.Delete(ctx, emID); err != nil {
applogger.L().Errorf("Failed to delete Email channel: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete Email channel"})
return
}
// Delete the associated inbox
if ch.InboxID > 0 {
if delErr := h.inboxSvc.DeleteByAccount(ctx, accountID, ch.InboxID); delErr != nil {
applogger.L().Warnf("Failed to delete inbox for Email channel: %v", delErr)
}
}
c.Status(http.StatusOK)
}
// List lists all Email channels for an account.
// GET /api/v1/accounts/:id/channels/email_channel
func (h *EmailChannelHandler) List(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
return
}
ctx := c.Request.Context()
channels, err := h.emailChannelSvc.ListByAccount(ctx, accountID)
if err != nil {
applogger.L().Errorf("Failed to list Email channels: %v", err)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Email channels"})
return
}
payload := make([]map[string]any, 0, len(channels))
for i := range channels {
payload = append(payload, h.serializeInboxForEmailChannel(c, &channels[i]))
}
c.JSON(http.StatusOK, gin.H{"payload": payload})
}
func (h *EmailChannelHandler) serializeInboxForEmailChannel(c *gin.Context, ch *channelmodel.ChannelEmail) map[string]any {
if ch != nil && ch.InboxID > 0 {
if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil {
return serializeInbox(inbox)
}
}
return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "email": ch.Email}
}
func emailChannelConfig(req CreateEmailChannelRequest) map[string]any {
return map[string]any{
"email": req.Email,
"mailbox_name": req.MailboxName,
"domain": extractDomain(req.Email),
"imap_enabled": req.IMAPEnabled,
"imap_address": req.IMAPAddress,
"imap_port": req.IMAPPort,
"imap_login": req.IMAPLogin,
"imap_password": req.IMAPPassword,
"imap_ssl_mode": req.IMAPSSLMode,
"imap_folder": req.IMAPFolder,
"smtp_enabled": req.SMTPEnabled,
"smtp_address": req.SMTPAddress,
"smtp_port": req.SMTPPort,
"smtp_login": req.SMTPLogin,
"smtp_password": req.SMTPPassword,
"smtp_ssl_mode": req.SMTPSSLMode,
}
}
func emailUpdateChannelConfig(_ UpdateEmailChannelRequest, ch *channelmodel.ChannelEmail) map[string]any {
return map[string]any{
"email": ch.Email,
"mailbox_name": ch.MailboxName,
"domain": ch.Domain,
"imap_enabled": ch.IMAPEnabled,
"imap_address": ch.IMAPAddress,
"imap_port": ch.IMAPPort,
"imap_login": ch.IMAPLogin,
"imap_password": ch.IMAPPassword,
"imap_ssl_mode": ch.IMAPSSLMode,
"imap_folder": ch.IMAPFolder,
"smtp_enabled": ch.SMTPEnabled,
"smtp_address": ch.SMTPAddress,
"smtp_port": ch.SMTPPort,
"smtp_login": ch.SMTPLogin,
"smtp_password": ch.SMTPPassword,
"smtp_ssl_mode": ch.SMTPSSLMode,
}
}
// extractDomain extracts the domain portion from an email address.
func extractDomain(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}