Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
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, nil, inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
// 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, nil, inboxSerializationAdmin(c)))
|
||||
}
|
||||
|
||||
// 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, nil, inboxSerializationAdmin(c))
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user