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.
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/terms/Terms.css b/frontend/src/pages/terms/Terms.css
index b586b09..07cf348 100644
--- a/frontend/src/pages/terms/Terms.css
+++ b/frontend/src/pages/terms/Terms.css
@@ -22,3 +22,8 @@
.tos-para:last-child {
margin-bottom: 0;
}
+
+.tos-para strong {
+ font-weight: 700;
+ color: var(--color-text);
+}
diff --git a/frontend/src/pages/terms/Terms.jsx b/frontend/src/pages/terms/Terms.jsx
index bb31b7a..a4a52d7 100644
--- a/frontend/src/pages/terms/Terms.jsx
+++ b/frontend/src/pages/terms/Terms.jsx
@@ -28,7 +28,7 @@ export default function Terms() {
TO ACCESS OR USE THE WEB SITE OR APPS.
- Communications. You agree that by providing your contact information,
+ Communications. You agree that by providing your contact information,
you consent to receiving communication, in connection with your
Membership subscription. This may include communication about your
account, features, and services via e-email, push notification, phone,
@@ -38,7 +38,7 @@ export default function Terms() {
charges applied by your cell phone carrier may apply.
- User Eligibility. The Website should only be accessed and used by
+ User Eligibility. The Website should only be accessed and used by
individuals who agree to be bound by these Terms of Use and who are at
least 18 years of age. The Websites may be accessible worldwide;
however, the Websites are intended for use only in the USA and Canada.
@@ -48,7 +48,7 @@ export default function Terms() {
Websites.
- Intellectual property rights. The Web Site and the information, computer
+ Intellectual property rights. The Web Site and the information, computer
code, and related functionality appearing, featured or otherwise
displayed on the Websites are owned by Koin Ping, its affiliates, and
their respective licensors or other third parties and protected under
@@ -56,7 +56,7 @@ export default function Terms() {
countries sand international treaty provisions.
- Limited license. Koin Ping grants to you a limited, non-exclusive,
+ Limited license. Koin Ping grants to you a limited, non-exclusive,
non-transferable license to use the Web Site in strict accordance with
these Terms of Service and Use and the instructions provided by us on
the Web Site. The materials provided on the Web Site, including,
@@ -77,7 +77,7 @@ export default function Terms() {
publicity and communications regulations and statutes.
- Restrictions on Use. As a condition of your use of the Websites, you
+ Restrictions on Use. As a condition of your use of the Websites, you
warrant that you will not use the Websites for any purpose that is
unlawful or prohibited by these terms, conditions and notices. You may
not use the Websites in any way that could damage, disable, overburden
@@ -87,7 +87,7 @@ export default function Terms() {
available or provided for through the Web Site.
- Revocation of privileges. You agree that your use of the Websites may
+ Revocation of privileges. You agree that your use of the Websites may
be suspended or terminated immediately upon receipt of any notice which
alleges that you have used the Websites in violation of these Terms of
Use and/or for any purpose that violates any local, state, federal or
@@ -102,7 +102,7 @@ export default function Terms() {
protect our rights.
- No Warranties. Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
+ No Warranties. Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
THE WEB SITE, THE SUITABILITY OF THE INFORMATION CONTAINED ON OR
RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
THROUGH THE WEB SITE. ALL INFORMATION AND USE OF THE WEB SITE ARE
@@ -122,7 +122,7 @@ export default function Terms() {
REPRESENTATIONS OR STATEMENTS OTHER THAN IN THIS AGREEMENT.
- Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
+ Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
LIABLE FOR ANY DAMAGES, INCLUDING, WITHOUT LIMITATION, DIRECT,
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE