package httpapi import ( "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "productier/apps/backend/internal/mailruntime" "productier/apps/backend/internal/store" ) type connectMailboxRequest struct { WorkspaceSlug string `json:"workspaceSlug" binding:"required"` Label string `json:"label"` Email string `json:"email" binding:"required,email"` DisplayName string `json:"displayName"` IMAPHost string `json:"imapHost" binding:"required"` IMAPPort int `json:"imapPort"` IMAPUsername string `json:"imapUsername"` IMAPPassword string `json:"imapPassword" binding:"required"` IMAPUseTLS bool `json:"imapUseTls"` SMTPHost string `json:"smtpHost" binding:"required"` SMTPPort int `json:"smtpPort"` SMTPUsername string `json:"smtpUsername"` SMTPPassword string `json:"smtpPassword"` SMTPUseTLS bool `json:"smtpUseTls"` } type createOutgoingMailRequest struct { WorkspaceSlug string `json:"workspaceSlug" binding:"required"` MailboxID string `json:"mailboxId" binding:"required"` To []store.MailAddress `json:"to" binding:"required"` Cc []store.MailAddress `json:"cc"` Bcc []store.MailAddress `json:"bcc"` Subject string `json:"subject"` TextBody string `json:"textBody"` HTMLBody string `json:"htmlBody"` ScheduledFor *time.Time `json:"scheduledFor"` } type createTaskFromMailRequest struct { BoardGroupID string `json:"boardGroupId" binding:"required"` Title string `json:"title"` DueAt *time.Time `json:"dueAt"` Color string `json:"color"` } func (s *Server) registerMailRoutes(group *gin.RouterGroup) { group.GET("/mailboxes", func(c *gin.Context) { workspaceSlug := c.Query("workspaceSlug") if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok { return } c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailboxes(workspaceSlug)}) }) group.POST("/mailboxes", func(c *gin.Context) { var input connectMailboxRequest if err := c.ShouldBindJSON(&input); err != nil { s.writeStatusError(c, http.StatusBadRequest, err.Error()) return } if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok { return } mailbox, err := s.mail.ConnectMailbox(c.Request.Context(), mailruntime.ConnectMailboxInput{ WorkspaceSlug: input.WorkspaceSlug, Label: input.Label, Email: input.Email, DisplayName: input.DisplayName, IMAPHost: input.IMAPHost, IMAPPort: input.IMAPPort, IMAPUsername: input.IMAPUsername, IMAPPassword: input.IMAPPassword, IMAPUseTLS: input.IMAPUseTLS, SMTPHost: input.SMTPHost, SMTPPort: input.SMTPPort, SMTPUsername: input.SMTPUsername, SMTPPassword: input.SMTPPassword, SMTPUseTLS: input.SMTPUseTLS, }) if err != nil { s.writeStatusError(c, http.StatusBadRequest, err.Error()) return } c.JSON(http.StatusCreated, gin.H{"data": mailbox}) }) group.POST("/mailboxes/:mailboxId/sync", func(c *gin.Context) { mailbox, err := s.store.GetMailboxByID(c.Param("mailboxId")) if err != nil { s.writeStatusError(c, http.StatusNotFound, err.Error()) return } if _, ok := s.requireWorkspaceMember(c, mailbox.WorkspaceSlug); !ok { return } if err := s.mail.SyncMailbox(c.Request.Context(), mailbox.ID); err != nil { s.writeStatusError(c, http.StatusBadGateway, err.Error()) return } updated, err := s.store.GetMailboxByID(mailbox.ID) if err != nil { s.writeStatusError(c, http.StatusNotFound, err.Error()) return } c.JSON(http.StatusOK, gin.H{"data": updated}) }) group.GET("/mail/messages", func(c *gin.Context) { workspaceSlug := c.Query("workspaceSlug") if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok { return } c.JSON(http.StatusOK, gin.H{"data": s.store.ListMailMessages(workspaceSlug, c.Query("mailboxId"))}) }) group.GET("/mail/outgoing", func(c *gin.Context) { workspaceSlug := c.Query("workspaceSlug") if _, ok := s.requireWorkspaceMember(c, workspaceSlug); !ok { return } c.JSON(http.StatusOK, gin.H{"data": s.store.ListOutgoingMails(workspaceSlug, c.Query("mailboxId"))}) }) group.POST("/mail/outgoing", func(c *gin.Context) { var input createOutgoingMailRequest if err := c.ShouldBindJSON(&input); err != nil { s.writeStatusError(c, http.StatusBadRequest, err.Error()) return } if _, ok := s.requireWorkspaceMember(c, input.WorkspaceSlug); !ok { return } mailbox, err := s.store.GetMailboxByID(input.MailboxID) if err != nil { s.writeStatusError(c, http.StatusNotFound, err.Error()) return } if mailbox.WorkspaceSlug != input.WorkspaceSlug { s.writeStatusError(c, http.StatusForbidden, "mailbox does not belong to workspace") return } item, err := s.mail.QueueOutgoingMail(c.Request.Context(), mailruntime.QueueOutgoingMailInput{ WorkspaceSlug: input.WorkspaceSlug, MailboxID: input.MailboxID, To: input.To, Cc: input.Cc, Bcc: input.Bcc, Subject: input.Subject, TextBody: input.TextBody, HTMLBody: input.HTMLBody, ScheduledFor: input.ScheduledFor, }) if err != nil { s.writeStatusError(c, http.StatusBadGateway, err.Error()) return } c.JSON(http.StatusCreated, gin.H{"data": item}) }) group.POST("/mail/messages/:messageId/create-task", func(c *gin.Context) { message, err := s.store.GetMailMessageByID(c.Param("messageId")) if err != nil { s.writeStatusError(c, http.StatusNotFound, err.Error()) return } if _, ok := s.requireWorkspaceMember(c, message.WorkspaceSlug); !ok { return } if message.LinkedTaskID != nil { s.writeStatusError(c, http.StatusConflict, "message already linked to a task") return } var input createTaskFromMailRequest if err := c.ShouldBindJSON(&input); err != nil { s.writeStatusError(c, http.StatusBadRequest, err.Error()) return } description := mailTaskDescription(message) task := s.store.CreateTask(store.CreateTaskInput{ WorkspaceSlug: message.WorkspaceSlug, BoardGroupID: input.BoardGroupID, Title: firstNonBlank(strings.TrimSpace(input.Title), strings.TrimSpace(message.Subject), "Follow up on email"), Description: description, DueAt: input.DueAt, Color: withFallback(input.Color, "blue"), }) if _, err := s.store.LinkMailMessageTask(message.ID, task.ID); err != nil { s.writeStatusError(c, http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusCreated, gin.H{"data": task}) }) } func mailTaskDescription(message store.MailMessage) string { var builder strings.Builder if message.From.Email != "" { builder.WriteString(fmt.Sprintf("From: %s <%s>\n\n", firstNonBlank(message.From.Name, "Sender"), message.From.Email)) } body := firstNonBlank(strings.TrimSpace(message.TextBody), strings.TrimSpace(message.Snippet)) builder.WriteString(body) return strings.TrimSpace(builder.String()) } func firstNonBlank(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func withFallback(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value }