package handlers import ( "encoding/json" "log" "net/http" "net/mail" "os" "regexp" "strings" "time" "github.com/kjannette/koin-ping/backend/internal/config" "github.com/kjannette/koin-ping/backend/internal/notifications" ) const ( maxSupportDescriptionLen = 8000 maxSupportEmailLen = 320 ) var descriptionAllowedRE = regexp.MustCompile(`^[a-zA-Z0-9\s]+$`) type supportRequestBody struct { Email string `json:"email"` Description string `json:"description"` } // SupportHandler accepts authenticated support form submissions and emails the inbox. type SupportHandler struct { cfg *config.Config } func NewSupportHandler(cfg *config.Config) *SupportHandler { return &SupportHandler{cfg: cfg} } // Submit handles POST /support. func (h *SupportHandler) Submit(w http.ResponseWriter, r *http.Request) { var body supportRequestBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "INVALID_JSON", "Request body must be JSON") return } email := strings.TrimSpace(body.Email) description := strings.TrimSpace(body.Description) if len(email) > maxSupportEmailLen { writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Email is too long") return } parsed, err := mail.ParseAddress(email) if err != nil || parsed.Address == "" { writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Invalid email address") return } canonicalEmail := parsed.Address if description == "" { writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is required") return } if len(description) > maxSupportDescriptionLen { writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is too long") return } if !descriptionAllowedRE.MatchString(description) { writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description may only contain letters, numbers, and whitespace") return } subject := time.Now().UTC().Format(time.RFC3339) + " - new support issue - koinp.ing" plainBody := "Contact email: " + canonicalEmail + "\n\nIssue description:\n" + description // Local dev: skip Resend when SUPPORT_DEV_SKIP_EMAIL=1 (see backend logs for payload). if strings.EqualFold(h.cfg.NodeEnv, "development") && os.Getenv("SUPPORT_DEV_SKIP_EMAIL") == "1" { log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL: skipping Resend; to=%s subject=%s", h.cfg.SupportInboxEmail, subject) log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL body:\n%s", plainBody) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) return } if h.cfg.ResendAPIKey == "" { writeError(w, http.StatusServiceUnavailable, "EMAIL_UNAVAILABLE", "Support email is not configured") return } if err := notifications.SendSupportEmail( h.cfg.ResendAPIKey, h.cfg.EmailFrom, h.cfg.SupportInboxEmail, subject, plainBody, ); err != nil { log.Printf("support Submit: send email: %v", err) msg := "Could not send email. Confirm RESEND_API_KEY and that EMAIL_FROM uses a domain verified in Resend." if strings.EqualFold(h.cfg.NodeEnv, "development") { msg = "Email send failed (development): " + err.Error() } writeError(w, http.StatusInternalServerError, "SEND_FAILED", msg) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }