Skip to content

Commit 17bd278

Browse files
committed
Backend service
1 parent b0011e6 commit 17bd278

7 files changed

Lines changed: 291 additions & 3 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ dmypy.json
168168
# Cython debug symbols
169169
cython_debug/
170170

171+
# Node.js
172+
node_modules/
173+
171174
# PyCharm
172175
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173176
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,46 @@ You can run the workflow manually and decide whether to continue to production u
105105
## Notes
106106

107107
- Form submissions are currently local/static stubs. Connect real form endpoints (Formspree, Getform, custom backend) before production.
108-
- Member portal is currently a prototype UI and requires secure backend authentication for production use.
108+
- Member portal is currently a prototype UI and requires secure backend authentication for production use.
109+
110+
## Contact Form API (Backend)
111+
112+
The Message Us form is now wired to a backend endpoint: `/api/contact`.
113+
114+
### Files
115+
116+
- `api/server.js`: Express + Nodemailer API.
117+
- `api/.env.example`: Required environment variables.
118+
- `api/package.json`: API dependencies and scripts.
119+
120+
### Local Run
121+
122+
1. Install API dependencies:
123+
- `cd api`
124+
- `npm install`
125+
2. Create `.env` from `.env.example` and fill SMTP credentials.
126+
3. Start API:
127+
- `npm start`
128+
4. Serve website and API behind the same host/reverse proxy, or use an absolute API URL in the form `action`.
129+
130+
### Production Notes
131+
132+
- GitHub Pages cannot run server-side code directly.
133+
- Deploy the `api` folder to a backend host (Render, Railway, Fly.io, Azure, etc.).
134+
- If backend is on another domain, update CORS `ALLOWED_ORIGINS` in API config.
135+
- Point form `action` to your deployed API URL (for example: `https://your-api-domain.com/api/contact`).
136+
137+
### Environment-based API Routing
138+
139+
The contact form supports separate API base URLs by environment:
140+
141+
- `data-api-dev`: used on `localhost` or `127.0.0.1`
142+
- `data-api-prod`: used on all other hosts
143+
144+
Current form configuration in `contact.html`:
145+
146+
- `data-api-dev="http://127.0.0.1:8787"`
147+
- `data-api-prod="https://api.ziongospelministry.org"`
148+
- `action="/api/contact"`
149+
150+
This means the same HTML works in both environments without manual form action edits.

api/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
PORT=8787
2+
3+
# Comma-separated origins allowed to call this API from the browser.
4+
ALLOWED_ORIGINS=https://ziongospelministry.org,http://127.0.0.1:4177,http://localhost:4177
5+
6+
# SMTP settings for outgoing mail.
7+
SMTP_HOST=smtp.gmail.com
8+
SMTP_PORT=587
9+
SMTP_SECURE=false
10+
SMTP_USER=your-smtp-user
11+
SMTP_PASS=your-smtp-password-or-app-password
12+
13+
# Mail routing.
14+
CONTACT_TO=vinodraj.j@gmail.com
15+
FROM_EMAIL=no-reply@ziongospelministry.org

api/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "zion-contact-api",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "Contact form API for Zion Gospel Ministry website",
6+
"main": "server.js",
7+
"scripts": {
8+
"start": "node server.js",
9+
"dev": "node server.js"
10+
},
11+
"dependencies": {
12+
"cors": "^2.8.5",
13+
"dotenv": "^16.4.5",
14+
"express": "^4.21.2",
15+
"nodemailer": "^6.9.16"
16+
}
17+
}

api/server.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
const express = require("express");
2+
const cors = require("cors");
3+
const nodemailer = require("nodemailer");
4+
require("dotenv").config();
5+
6+
const app = express();
7+
const port = Number(process.env.PORT || 8787);
8+
const defaultAllowedOrigins = [
9+
"https://ziongospelministry.org",
10+
"http://127.0.0.1:4177",
11+
"http://localhost:4177"
12+
];
13+
14+
const allowedOrigins = String(process.env.ALLOWED_ORIGINS || "")
15+
.split(",")
16+
.map((x) => x.trim())
17+
.filter(Boolean);
18+
19+
const origins = allowedOrigins.length ? allowedOrigins : defaultAllowedOrigins;
20+
21+
app.use(cors({
22+
origin(origin, callback) {
23+
if (!origin || origins.includes(origin)) {
24+
callback(null, true);
25+
return;
26+
}
27+
callback(new Error("Origin not allowed"));
28+
}
29+
}));
30+
31+
app.use(express.json({ limit: "100kb" }));
32+
33+
function buildTransporter() {
34+
const host = process.env.SMTP_HOST;
35+
const portNumber = Number(process.env.SMTP_PORT || 587);
36+
const secure = String(process.env.SMTP_SECURE || "false").toLowerCase() === "true";
37+
const user = process.env.SMTP_USER;
38+
const pass = process.env.SMTP_PASS;
39+
40+
if (!host || !user || !pass) {
41+
throw new Error("Missing SMTP configuration.");
42+
}
43+
44+
return nodemailer.createTransport({
45+
host,
46+
port: portNumber,
47+
secure,
48+
auth: { user, pass }
49+
});
50+
}
51+
52+
function validatePayload(body) {
53+
const payload = {
54+
name: String(body.name || "").trim(),
55+
email: String(body.email || "").trim(),
56+
subject: String(body.subject || "").trim(),
57+
message: String(body.message || "").trim()
58+
};
59+
60+
if (!payload.name || !payload.email || !payload.message) {
61+
return { error: "Name, email, and message are required." };
62+
}
63+
64+
if (!/^\S+@\S+\.\S+$/.test(payload.email)) {
65+
return { error: "Invalid email address." };
66+
}
67+
68+
if (payload.message.length > 8000) {
69+
return { error: "Message is too long." };
70+
}
71+
72+
return { payload };
73+
}
74+
75+
app.get("/api/health", (_req, res) => {
76+
res.status(200).json({ ok: true });
77+
});
78+
79+
app.post("/api/contact", async (req, res) => {
80+
const { payload, error } = validatePayload(req.body || {});
81+
if (error) {
82+
res.status(400).json({ ok: false, error });
83+
return;
84+
}
85+
86+
const to = process.env.CONTACT_TO || "vinodraj.j@gmail.com";
87+
const from = process.env.FROM_EMAIL || process.env.SMTP_USER;
88+
const subject = payload.subject || "New message from ziongospelministry.org";
89+
90+
const text = [
91+
`Name: ${payload.name}`,
92+
`Email: ${payload.email}`,
93+
"",
94+
payload.message
95+
].join("\n");
96+
97+
const html = `
98+
<h2>New Contact Message</h2>
99+
<p><strong>Name:</strong> ${payload.name}</p>
100+
<p><strong>Email:</strong> ${payload.email}</p>
101+
<p><strong>Message:</strong></p>
102+
<p>${payload.message.replace(/\n/g, "<br>")}</p>
103+
`;
104+
105+
try {
106+
const transporter = buildTransporter();
107+
await transporter.sendMail({
108+
to,
109+
from,
110+
replyTo: payload.email,
111+
subject,
112+
text,
113+
html
114+
});
115+
116+
res.status(200).json({ ok: true });
117+
} catch (err) {
118+
console.error("Email send failed:", err.message);
119+
res.status(500).json({ ok: false, error: "Unable to send message." });
120+
}
121+
});
122+
123+
app.use((_req, res) => {
124+
res.status(404).json({ ok: false, error: "Not found" });
125+
});
126+
127+
app.listen(port, () => {
128+
console.log(`Contact API running on port ${port}`);
129+
});

assets/js/site.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ function setFormMessage(form, msg) {
7575

7676
function initSimpleForms() {
7777
bySelAll("form[data-generic-form], form[data-newsletter-form], form[data-prayer-form], form[data-testimony-form], form[data-member-login]").forEach((form) => {
78+
if (form.hasAttribute("data-email-submit")) return;
79+
7880
form.addEventListener("submit", (e) => {
7981
e.preventDefault();
8082
setFormMessage(form, "Thank you. Your submission has been received.");
@@ -83,6 +85,69 @@ function initSimpleForms() {
8385
});
8486
}
8587

88+
function resolveApiEndpoint(form) {
89+
const action = String(form.getAttribute("action") || "").trim();
90+
if (/^https?:\/\//i.test(action)) return action;
91+
92+
const host = window.location.hostname;
93+
const isLocal = host === "localhost" || host === "127.0.0.1";
94+
const devBase = String(form.getAttribute("data-api-dev") || "").trim();
95+
const prodBase = String(form.getAttribute("data-api-prod") || "").trim();
96+
const envBase = isLocal ? devBase : prodBase;
97+
98+
if (!envBase) return action;
99+
return new URL(action, envBase.endsWith("/") ? envBase : `${envBase}/`).toString();
100+
}
101+
102+
function initEmailApiForms() {
103+
bySelAll("form[data-email-submit]").forEach((form) => {
104+
form.addEventListener("submit", async (e) => {
105+
e.preventDefault();
106+
107+
const submitBtn = bySel('button[type="submit"]', form);
108+
const originalBtnText = submitBtn ? submitBtn.textContent : "";
109+
if (submitBtn) {
110+
submitBtn.disabled = true;
111+
submitBtn.textContent = "Sending...";
112+
}
113+
114+
const fd = new FormData(form);
115+
const payload = {
116+
name: String(fd.get("name") || "").trim(),
117+
email: String(fd.get("email") || "").trim(),
118+
subject: String(fd.get("subject") || "").trim(),
119+
message: String(fd.get("message") || "").trim()
120+
};
121+
const endpoint = resolveApiEndpoint(form);
122+
123+
try {
124+
const res = await fetch(endpoint, {
125+
method: "POST",
126+
headers: {
127+
"Content-Type": "application/json",
128+
"Accept": "application/json"
129+
},
130+
body: JSON.stringify(payload)
131+
});
132+
133+
if (!res.ok) {
134+
throw new Error("Failed to submit message.");
135+
}
136+
137+
setFormMessage(form, "Thank you. Your message has been sent.");
138+
form.reset();
139+
} catch (_err) {
140+
setFormMessage(form, "Unable to send right now. Please try again shortly.");
141+
} finally {
142+
if (submitBtn) {
143+
submitBtn.disabled = false;
144+
submitBtn.textContent = originalBtnText;
145+
}
146+
}
147+
});
148+
});
149+
}
150+
86151
function renderHomeEvents(events) {
87152
const root = bySel("[data-home-events]");
88153
if (!root) return;
@@ -546,6 +611,7 @@ document.addEventListener("DOMContentLoaded", () => {
546611
initMenu();
547612
initReveal();
548613
initSimpleForms();
614+
initEmailApiForms();
549615
initMemoryTracker();
550616
bootData();
551617
registerServiceWorker();

contact.html

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,28 @@ <h3>Service Information</h3>
5050
<p>Youth Meeting: Saturday 5:00 PM</p>
5151
<h3>Location</h3>
5252
<p>Chennai, Tamil Nadu, India</p>
53-
<p><a href="https://share.google/jJkbszkjGaFDmpIht" target="_blank" rel="noopener">Open in Google Maps</a></p>
53+
<div style="border-radius: 12px; overflow: hidden; margin: 0.75rem 0;">
54+
<iframe
55+
title="Zion Gospel Ministry Location"
56+
src="https://www.google.com/maps?q=Zion+Gospel+Ministry,+Chennai,+Tamil+Nadu,+India&output=embed"
57+
width="100%"
58+
height="280"
59+
style="border:0;"
60+
loading="lazy"
61+
referrerpolicy="no-referrer-when-downgrade"
62+
allowfullscreen>
63+
</iframe>
64+
</div>
65+
<p>
66+
<a href="https://www.google.com/maps/dir/?api=1&destination=Zion+Gospel+Ministry,+Chennai,+Tamil+Nadu,+India&travelmode=driving" target="_blank" rel="noopener">
67+
Get Directions in Google Maps
68+
</a>
69+
</p>
5470
</article>
5571

5672
<article class="card">
5773
<h3>Message Us</h3>
58-
<form data-generic-form>
74+
<form data-generic-form data-email-submit data-api-dev="http://127.0.0.1:8787" data-api-prod="https://api.ziongospelministry.org" action="/api/contact" method="POST">
5975
<div class="form-grid">
6076
<label>Name<input name="name" required></label>
6177
<label>Email<input type="email" name="email" required></label>

0 commit comments

Comments
 (0)