Skip to content

Commit 6625ab6

Browse files
committedJan 15, 2025·
Contact page.
Signed-off-by: Marko Anastasov <marko@operately.com>
1 parent e90d310 commit 6625ab6

10 files changed

+1965
-127
lines changed
 

‎.dev.vars.example

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
SENDGRID_API_KEY=your_sendgrid_api_key_here
2+
CONTACT_FORM_TO_EMAIL=your_email@example.com
3+
# PUBLIC_ prefix is necessary for the environment variables to be available in JSX files
4+
PUBLIC_BLOG_URL="https://blog.operately.com"
5+
PUBLIC_DISCORD_URL="https://discord.com/invite/2ngnragJYV"
6+
PUBLIC_GITHUB_URL="https://github.com/operately/operately"
7+
PUBLIC_LINKEDIN_URL="https://www.linkedin.com/company/92810497/"
8+
PUBLIC_X_URL="https://x.com/operately"
9+
PUBLIC_YOUTUBE_URL="https://www.youtube.com/@operately"
10+
PUBLIC_CLOUD_WAITLIST_URL="https://docs.google.com/forms/d/e/1FAIpQLSebV6j1nIvyjvyLptZ95mHXoj42XrnBmd5znVnUzU_6ATAJgw/viewform"
11+
PUBLIC_BOOK_A_DEMO_URL=""

‎.gitignore

+7
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ yarn-error.log*
1414
pnpm-debug.log*
1515

1616
# environment variables
17+
.dev.vars
18+
.env
19+
.env.*
20+
!.env.example
1721
.env.production
1822

1923
# macOS-specific files
2024
.DS_Store
2125

2226
# jetbrains setting folder
2327
.idea/
28+
29+
# Added .wrangler directory to .gitignore
30+
.wrangler

‎docs/README.md

+21
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ Eg. if you create a React component that uses built-in Astro components and
4545
call it in a page or another Astro component, the code may not even build
4646
due to Astro components not rendering on the client[^1].
4747

48+
## Dev Server
49+
50+
The development setup uses two servers running concurrently:
51+
52+
1. **Astro Dev Server** (port 4321)
53+
54+
- Main development server
55+
- Provides live reload and instant updates (Hot Module Replacement)
56+
- Serves the website content
57+
- Enables development features like the Astro dev overlay
58+
59+
2. **Wrangler** (port 8788)
60+
- Handles Cloudflare Functions (API endpoints)
61+
- Simulates the production Cloudflare Pages environment
62+
63+
The `npm run dev` command sets this up by running both servers concurrently. Development is done through `localhost:4321`, with API requests automatically routed to the Wrangler server on port 8788.
64+
65+
### Environment Variables
66+
67+
The development setup uses `.dev.vars` instead of `.env` for environment variables. This is because we're using Wrangler to simulate the Cloudflare Pages environment.
68+
4869
## Deployment
4970

5071
The website is hosted on CloudFlare Pages. Deployment is fully automated.

‎functions/api/contact.ts

+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// runs on cloudflare workers
2+
3+
interface Env {
4+
SENDGRID_API_KEY: string;
5+
CONTACT_FORM_TO_EMAIL: string;
6+
}
7+
8+
interface ContactFormData {
9+
email: string;
10+
message: string;
11+
"help-type": string;
12+
"help-type-label": string;
13+
"org-url"?: string;
14+
website?: string; // honeypot field
15+
"form-start-time"?: string; // timestamp field
16+
}
17+
18+
function isSpam(formData: ContactFormData): {
19+
isSpam: boolean;
20+
reason?: string;
21+
} {
22+
// Check honeypot field
23+
if (formData.website && formData.website.length > 0) {
24+
return { isSpam: true, reason: "Honeypot field was filled" };
25+
}
26+
27+
// Check submission timing (minimum 3 seconds)
28+
const startTime = parseInt(formData["form-start-time"] || "0");
29+
const submissionTime = Date.now();
30+
const timeDiff = submissionTime - startTime;
31+
if (timeDiff < 3000) {
32+
// 3 seconds
33+
return { isSpam: true, reason: "Form submitted too quickly" };
34+
}
35+
36+
// Check for suspicious patterns in message
37+
const suspiciousPatterns = [
38+
/<[^>]*>/, // HTML tags
39+
/\[url=.*?\].*?\[\/url\]/, // BBCode
40+
/(http|https):\/\/[^\s]*/g, // URLs
41+
/\b(viagra|casino|porn|xxx|sex)\b/i, // Common spam keywords
42+
];
43+
44+
for (const pattern of suspiciousPatterns) {
45+
if (pattern.test(formData.message)) {
46+
return { isSpam: true, reason: "Message contains suspicious content" };
47+
}
48+
}
49+
50+
// Check for excessive URLs (more than 2)
51+
const urlCount = (formData.message.match(/(http|https):\/\/[^\s]*/g) || [])
52+
.length;
53+
if (urlCount > 2) {
54+
return { isSpam: true, reason: "Too many URLs in message" };
55+
}
56+
57+
return { isSpam: false };
58+
}
59+
60+
export const onRequestPost: PagesFunction<Env> = async (context) => {
61+
try {
62+
// Check required environment variables
63+
if (!context.env.SENDGRID_API_KEY || !context.env.CONTACT_FORM_TO_EMAIL) {
64+
console.error(
65+
"Contact form error: Missing required environment variables"
66+
);
67+
return new Response(
68+
JSON.stringify({ error: "Server configuration error" }),
69+
{
70+
status: 500,
71+
headers: {
72+
"Content-Type": "application/json",
73+
"Access-Control-Allow-Origin": "*",
74+
"Access-Control-Allow-Methods": "POST, OPTIONS",
75+
"Access-Control-Allow-Headers": "Content-Type",
76+
},
77+
}
78+
);
79+
}
80+
81+
const formData = await context.request.formData();
82+
const data: ContactFormData = {
83+
email: formData.get("email") as string,
84+
message: formData.get("message") as string,
85+
"help-type": formData.get("help-type") as string,
86+
"help-type-label": formData.get("help-type-label") as string,
87+
"org-url": formData.get("org-url") as string,
88+
website: formData.get("website") as string,
89+
"form-start-time": formData.get("form-start-time") as string,
90+
};
91+
92+
// Validate required fields
93+
if (!data.email || !data.message || !data["help-type"]) {
94+
console.warn("Contact form rejected: Missing required fields");
95+
return new Response(
96+
JSON.stringify({ error: "Missing required fields" }),
97+
{
98+
status: 400,
99+
headers: {
100+
"Content-Type": "application/json",
101+
"Access-Control-Allow-Origin": "*",
102+
"Access-Control-Allow-Methods": "POST, OPTIONS",
103+
"Access-Control-Allow-Headers": "Content-Type",
104+
},
105+
}
106+
);
107+
}
108+
109+
// Validate email format
110+
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
111+
if (!emailRegex.test(data.email)) {
112+
console.warn("Contact form rejected: Invalid email format");
113+
return new Response(
114+
JSON.stringify({ error: "Please enter a valid email address" }),
115+
{
116+
status: 400,
117+
headers: {
118+
"Content-Type": "application/json",
119+
"Access-Control-Allow-Origin": "*",
120+
"Access-Control-Allow-Methods": "POST, OPTIONS",
121+
"Access-Control-Allow-Headers": "Content-Type",
122+
},
123+
}
124+
);
125+
}
126+
127+
// Check for spam
128+
const spamCheck = isSpam(data);
129+
if (spamCheck.isSpam) {
130+
console.warn("Contact form rejected: Spam detected -", spamCheck.reason);
131+
return new Response(
132+
JSON.stringify({ error: "Message flagged as spam" }),
133+
{
134+
status: 400,
135+
headers: {
136+
"Content-Type": "application/json",
137+
"Access-Control-Allow-Origin": "*",
138+
"Access-Control-Allow-Methods": "POST, OPTIONS",
139+
"Access-Control-Allow-Headers": "Content-Type",
140+
},
141+
}
142+
);
143+
}
144+
145+
const emailData = {
146+
personalizations: [
147+
{
148+
to: [{ email: context.env.CONTACT_FORM_TO_EMAIL }],
149+
subject: `Contact Form: ${
150+
data["help-type-label"] || data["help-type"]
151+
}`,
152+
},
153+
],
154+
from: { email: "noreply@operately.com", name: "Operately Contact Form" },
155+
reply_to: { email: data.email },
156+
content: [
157+
{
158+
type: "text/plain",
159+
value: `Type: ${data["help-type-label"] || data["help-type"]}
160+
From: ${data.email}
161+
Organization URL: ${data["org-url"] || "Not provided"}
162+
163+
Message:
164+
${data.message}`,
165+
},
166+
],
167+
};
168+
169+
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
170+
method: "POST",
171+
headers: {
172+
Authorization: `Bearer ${context.env.SENDGRID_API_KEY}`,
173+
"Content-Type": "application/json",
174+
},
175+
body: JSON.stringify(emailData),
176+
});
177+
178+
if (!response.ok) {
179+
console.error("Contact form error: Failed to send email via SendGrid");
180+
throw new Error("Failed to send email");
181+
}
182+
183+
return new Response(JSON.stringify({ success: true }), {
184+
headers: {
185+
"Content-Type": "application/json",
186+
"Access-Control-Allow-Origin": "*",
187+
"Access-Control-Allow-Methods": "POST, OPTIONS",
188+
"Access-Control-Allow-Headers": "Content-Type",
189+
},
190+
});
191+
} catch (error) {
192+
console.error(
193+
"Contact form error:",
194+
error instanceof Error ? error.message : "Unknown error"
195+
);
196+
return new Response(
197+
JSON.stringify({ error: "Failed to process request" }),
198+
{
199+
status: 500,
200+
headers: {
201+
"Content-Type": "application/json",
202+
"Access-Control-Allow-Origin": "*",
203+
"Access-Control-Allow-Methods": "POST, OPTIONS",
204+
"Access-Control-Allow-Headers": "Content-Type",
205+
},
206+
}
207+
);
208+
}
209+
};

0 commit comments

Comments
 (0)
Please sign in to comment.