diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 1af3cc1..99246ee 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -63,6 +63,7 @@ func main() { statusHandler := handlers.NewStatusHandler(checkpointModel) stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg) accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg) + supportHandler := handlers.NewSupportHandler(cfg) authenticate := middleware.Authenticate(userModel) requireSub := middleware.RequireSubscription(userModel) @@ -101,6 +102,10 @@ func main() { mux.Handle("GET "+b+"/user/account", authenticate(http.HandlerFunc(accountHandler.GetAccount))) + // Support (auth required; avoids anonymous spam) + mux.Handle("POST "+b+"/support", + authenticate(http.HandlerFunc(supportHandler.Submit))) + // Authenticated + subscribed routes — addresses mux.Handle("POST "+b+"/addresses", authAndSub(http.HandlerFunc(addressHandler.Create))) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d066b11..7969085 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1,4 +1,4 @@ -//loads environment-based configuration. +// loads environment-based configuration. package config import ( @@ -9,65 +9,67 @@ import ( ) const ( - defaultPort = 3001 - defaultDBPort = 5432 - defaultPollIntervalMS = 60000 - minPollIntervalMS = 1000 + defaultPort = 3001 + defaultDBPort = 5432 + defaultPollIntervalMS = 60000 + minPollIntervalMS = 1000 defaultDigestIntervalHours = 24 ) type Config struct { - Port int - APIBasePath string - DatabaseURL string - DBHost string - DBPort int - DBUser string - DBPassword string - DBName string - FirebaseProjectID string - EthRPCURL string - PollIntervalMS int - NodeEnv string - ResendAPIKey string - EmailFrom string - DigestIntervalHours int - StripeSecretKey string - StripeWebhookSecret string - StripePriceIDPremium string - StripePriceIDPro string - StripePriceIDPremiumAnnual string - StripePriceIDProAnnual string - StripePublishableKey string - FrontendURL string + Port int + APIBasePath string + DatabaseURL string + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + FirebaseProjectID string + EthRPCURL string + PollIntervalMS int + NodeEnv string + ResendAPIKey string + EmailFrom string + SupportInboxEmail string + DigestIntervalHours int + StripeSecretKey string + StripeWebhookSecret string + StripePriceIDPremium string + StripePriceIDPro string + StripePriceIDPremiumAnnual string + StripePriceIDProAnnual string + StripePublishableKey string + FrontendURL string } // Load reads configuration from environment variables and returns a Config. func Load() (*Config, error) { cfg := &Config{ - Port: getEnvInt("PORT", defaultPort), - APIBasePath: getEnv("API_BASE_PATH", "/v1"), - DatabaseURL: os.Getenv("DATABASE_URL"), - DBHost: getEnv("DB_HOST", "localhost"), - DBPort: getEnvInt("DB_PORT", defaultDBPort), - DBUser: os.Getenv("DB_USER"), - DBPassword: os.Getenv("DB_PASSWORD"), - DBName: os.Getenv("DB_NAME"), - FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), - EthRPCURL: os.Getenv("ETH_RPC_URL"), - PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS), - NodeEnv: getEnv("NODE_ENV", "development"), - ResendAPIKey: os.Getenv("RESEND_API_KEY"), - EmailFrom: getEnv("EMAIL_FROM", "Koin Ping "), - DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours), - StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), - StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), - StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"), - StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"), - StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"), - StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"), - StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), - FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), + Port: getEnvInt("PORT", defaultPort), + APIBasePath: getEnv("API_BASE_PATH", "/v1"), + DatabaseURL: os.Getenv("DATABASE_URL"), + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnvInt("DB_PORT", defaultDBPort), + DBUser: os.Getenv("DB_USER"), + DBPassword: os.Getenv("DB_PASSWORD"), + DBName: os.Getenv("DB_NAME"), + FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), + EthRPCURL: os.Getenv("ETH_RPC_URL"), + PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS), + NodeEnv: getEnv("NODE_ENV", "development"), + ResendAPIKey: os.Getenv("RESEND_API_KEY"), + EmailFrom: getEnv("EMAIL_FROM", "Koin Ping "), + SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"), + DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours), + StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), + StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"), + StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"), + StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"), + StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"), + StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), } if cfg.PollIntervalMS < minPollIntervalMS { diff --git a/backend/internal/handlers/support.go b/backend/internal/handlers/support.go new file mode 100644 index 0000000..7edc413 --- /dev/null +++ b/backend/internal/handlers/support.go @@ -0,0 +1,111 @@ +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"}) +} diff --git a/backend/internal/notifications/email.go b/backend/internal/notifications/email.go index 9bfc19a..6a5b25d 100644 --- a/backend/internal/notifications/email.go +++ b/backend/internal/notifications/email.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "fmt" + "html" + "io" "log" "net/http" "time" @@ -156,3 +158,57 @@ func alertTypeLabel(alertType string) string { return "Alert" } } + +type resendSupportPayload struct { + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject"` + Text string `json:"text"` + HTML string `json:"html"` +} + +// SendSupportEmail delivers a plain-text support request via Resend (HTML copy is escaped). +func SendSupportEmail(apiKey, fromAddress, toAddress, subject, plainBody string) error { + if apiKey == "" { + return fmt.Errorf("RESEND_API_KEY not set") //nolint:err113 + } + + escaped := html.EscapeString(plainBody) + htmlBody := `
` +
+		escaped + `
` + + payload := resendSupportPayload{ + From: fromAddress, + To: toAddress, + Subject: subject, + Text: plainBody, + HTML: htmlBody, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal support email payload: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create support email request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := emailHTTPClient.Do(req) + if err != nil { + log.Printf("Failed to send support email: %v", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + log.Printf("Resend support email failed: HTTP %d body: %s", resp.StatusCode, string(snippet)) + return fmt.Errorf("resend API failed: HTTP %d: %s", resp.StatusCode, string(snippet)) //nolint:err113 + } + + return nil +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 362c2e6..79b7a80 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory"; import Account from "./pages/user_account/Account"; import Terms from "./pages/terms/Terms"; import Privacy from "./pages/privacy/Privacy"; +import Support from "./pages/support/Support"; export default function App() { const { currentUser, isSubscribed } = useAuth(); @@ -38,6 +39,7 @@ export default function App() { } /> } /> } /> + } /> } /> @@ -60,6 +62,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/support.js b/frontend/src/api/support.js new file mode 100644 index 0000000..cfe03e7 --- /dev/null +++ b/frontend/src/api/support.js @@ -0,0 +1,23 @@ +import { getAuthHeaders } from "./authHeaders"; +import { API_BASE } from "./config"; + +export async function submitSupportRequest({ email, description }) { + const headers = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/support`, { + method: "POST", + headers, + body: JSON.stringify({ email, description }), + }); + if (!res.ok) { + let message = "Failed to send support request"; + try { + const data = await res.json(); + if (data.message) message = data.message; + else if (data.error && res.status) message = `${data.error} (${res.status})`; + } catch { + message = `Request failed (${res.status}). Is the API running on port 3001?`; + } + throw new Error(message); + } + return res.json(); +} diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index a260e41..be7249e 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -27,14 +27,14 @@ export default function Footer() { > Privacy - Contact Support - + ); diff --git a/frontend/src/pages/support/Support.css b/frontend/src/pages/support/Support.css new file mode 100644 index 0000000..5196d53 --- /dev/null +++ b/frontend/src/pages/support/Support.css @@ -0,0 +1,31 @@ +.support-page__title { + font-size: 2rem; + font-weight: 200; + margin-bottom: 0.75rem; +} + +.support-page__intro { + font-size: 1.1rem; + font-weight: 200; + color: var(--color-text-muted); + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.support-page__alert { + margin-bottom: 1rem; +} + +.support-form__description { + display: block; +} + +.support-form__textarea { + min-height: 10rem; + resize: vertical; + margin-top: 0.25rem; +} + +.support-form__submit { + margin-top: 0.5rem; +} diff --git a/frontend/src/pages/support/Support.jsx b/frontend/src/pages/support/Support.jsx new file mode 100644 index 0000000..479c9ba --- /dev/null +++ b/frontend/src/pages/support/Support.jsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "../../contexts/AuthContext"; +import Input from "../../components/Input"; +import Button from "../../components/Button"; +import { submitSupportRequest } from "../../api/support"; +import "./Support.css"; + +const EMAIL_MAX = 320; +const DESCRIPTION_MAX = 8000; +const DESCRIPTION_PATTERN = /^[a-zA-Z0-9\s]+$/; + +function validateEmail(value) { + const v = value.trim(); + if (!v) return "Email is required."; + if (v.length > EMAIL_MAX) return "Email is too long."; + const ok = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/.test( + v, + ); + if (!ok) return "Enter a valid email address."; + return null; +} + +function validateDescription(value) { + const t = value.trim(); + if (!t) return "Description of issue is required."; + if (t.length > DESCRIPTION_MAX) return "Description is too long."; + if (!DESCRIPTION_PATTERN.test(t)) { + return "Use only letters, numbers, and spaces (no special characters)."; + } + return null; +} + +export default function Support() { + const { currentUser } = useAuth(); + const [email, setEmail] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [sending, setSending] = useState(false); + + useEffect(() => { + if (currentUser?.email) { + setEmail(currentUser.email); + } + }, [currentUser?.email]); + + async function handleSubmit(e) { + e.preventDefault(); + setError(null); + setSuccess(false); + + const emailErr = validateEmail(email); + if (emailErr) { + setError(emailErr); + return; + } + const descErr = validateDescription(description); + if (descErr) { + setError(descErr); + return; + } + + setSending(true); + try { + await submitSupportRequest({ + email: email.trim(), + description: description.trim(), + }); + setSuccess(true); + setDescription(""); + } catch (err) { + setError(err.message || "Something went wrong."); + } finally { + setSending(false); + } + } + + return ( +
+

Contact support

+

+ Describe your issue below. We will reply to the email address you + provide. +

+ + {error &&
{error}
} + {success && ( +
+ Your message was sent. Thank you. +
+ )} + +
+ +