Files
alva/felsokning/supabase/functions/send-contact-email/index.ts
T
Claude 301c477e25 Merge main: Guidad Felsökning flyttar ur roten till felsokning/
Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.

För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:

  felsokning/app        webbklienten (Vite, egen package.json och
                        eslint-/vitest-konfiguration)
  felsokning/services   plattformstjänsten och AI-orkestern
  felsokning/infra      Terraform och databasschemat
  felsokning/docs       vision, moduler, drift
  felsokning/supabase   edge-funktion och migrationer

Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.

Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.

CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.

Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-04 12:22:49 +00:00

158 lines
4.6 KiB
TypeScript

import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
interface ContactRequest {
name: string;
email: string;
company: string;
message: string;
}
// HTML-escape any user-supplied string before embedding in email HTML
const esc = (s: string): string =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
// In-memory rate limit per client IP (best-effort; resets on cold start).
// Key: ip, Value: array of request timestamps (ms).
const rateBuckets = new Map<string, number[]>();
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const RATE_MAX = 5; // max 5 messages/hour per IP
function getClientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("cf-connecting-ip") || req.headers.get("x-real-ip") || "unknown";
}
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const bucket = (rateBuckets.get(ip) || []).filter((t) => now - t < RATE_WINDOW_MS);
if (bucket.length >= RATE_MAX) {
rateBuckets.set(ip, bucket);
return false;
}
bucket.push(now);
rateBuckets.set(ip, bucket);
return true;
}
const handler = async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
}
const ip = getClientIp(req);
if (!checkRateLimit(ip)) {
return new Response(
JSON.stringify({ error: "För många förfrågningar. Försök igen senare." }),
{
status: 429,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
try {
const { name, email, company, message }: ContactRequest = await req.json();
// Validate required fields
if (!name || !email || !message) {
throw new Error("Missing required fields");
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format");
}
// Validate field lengths
if (
name.length > 100 ||
email.length > 255 ||
(company && company.length > 100) ||
message.length > 2000
) {
throw new Error("Field length exceeded");
}
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
const recipientEmail = Deno.env.get("CONTACT_EMAIL") || "kontakt@example.com";
if (!RESEND_API_KEY) {
throw new Error("RESEND_API_KEY not configured");
}
// Escape all user-supplied values before embedding in HTML
const safeName = esc(name);
const safeEmail = esc(email);
const safeCompany = company ? esc(company) : "";
const safeMessage = esc(message).replace(/\n/g, "<br />");
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${RESEND_API_KEY}`,
},
body: JSON.stringify({
from: "Lennart Svensson Konditorivaror <noreply@lennartsvensson.se>",
to: [recipientEmail],
reply_to: email,
subject: `Kontaktförfrågan från ${safeName}`,
html: `
<h2>Ny kontaktförfrågan</h2>
<p><strong>Namn:</strong> ${safeName}</p>
<p><strong>E-post:</strong> ${safeEmail}</p>
${safeCompany ? `<p><strong>Företag:</strong> ${safeCompany}</p>` : ""}
<hr />
<p><strong>Meddelande:</strong></p>
<p>${safeMessage}</p>
`,
}),
});
if (!res.ok) {
const error = await res.text();
console.error("Resend API error:", error);
throw new Error("Failed to send email");
}
const data = await res.json();
console.log("Contact email sent successfully:", data);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error: any) {
console.error("Error in send-contact-email function:", error);
return new Response(
JSON.stringify({ error: "Kunde inte skicka meddelandet." }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
};
serve(handler);