From ed61d9057b9b48e123843ef4da942e6606887d79 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Thu, 22 Oct 2026 19:00:38 -0400 Subject: [PATCH] Contact form sends through the server's mail (PHP endpoint) instead of mailto Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++ REQUIREMENTS.md | 13 +++-- public/api/contact.php | 103 ++++++++++++++++++++++++++++++++++++++++ src/pages/contact.astro | 82 ++++++++++++++++++-------------- 4 files changed, 162 insertions(+), 41 deletions(-) create mode 100644 public/api/contact.php diff --git a/README.md b/README.md index b55a483..62263d4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ docker run --rm -v "$PWD":/site -w /site node:22-bookworm-slim sh -c "npm ci && sudo rsync -a --delete --chown=user:user --chmod=D755,F644 dist/ /home/user/web/quorlasocial.com/public_html/ ``` +## Contact form + +`/contact` sends through `public/api/contact.php` using the server's own mail to support@quorlasocial.com (details in +REQUIREMENTS.md §6). After deploying, submit the form once on the live site and check the support@ inbox. + ## Keep these working Google's OAuth verification and TikTok's app review point at these URLs. Don't remove or move them: diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 10e73c5..e8e1af3 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -86,11 +86,14 @@ Footer: product links, legal links, contact email, © Metatroncube Software Solu ## 6. Contact form -The site is static, so the form needs somewhere to send. Pick one and note it in the README: -1. A form service (Formspree, Web3Forms, Basin) posting to support@quorlasocial.com. Simplest. Or -2. A small endpoint in the Quorla app later. -Until then, the "Request access" buttons stay as `mailto:support@quorlasocial.com?subject=Quorla%20access`. -Add a honeypot field against spam. Don't collect anything beyond what's in §4. +Built. `/contact` posts to **`public/api/contact.php`** (copied as-is into `dist/api/`). HestiaCP runs it with PHP and +sends through the server's own mail (exim, DKIM-signed for quorlasocial.com) to **support@quorlasocial.com**, with +the visitor's email as Reply-To. No passwords or API keys in code. +- Spam protection: honeypot field `website_fax`, minimum 3 s on the page (hidden field `t`), 5 requests per IP per + hour, origin check (only quorlasocial.com / www). +- Fields: name, agency, email, clients, deployment, message. Don't add more without updating `/privacy`. +- If you change field names in `contact.astro`, change them in `contact.php` too. +- The PHP only runs on the server. `npm run dev` shows the form, but submitting locally fails; test it on the live site. ## 7. Done means diff --git a/public/api/contact.php b/public/api/contact.php new file mode 100644 index 0000000..5547f9a --- /dev/null +++ b/public/api/contact.php @@ -0,0 +1,103 @@ + false, 'error' => 'Use the form on quorlasocial.com/contact.']); +} + +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +if ($origin !== '' && !in_array($origin, ALLOWED_ORIGINS, true)) { + reply(403, ['ok' => false, 'error' => 'Not allowed.']); +} + +// Honeypot and timing: pretend success so bots don't learn anything. +if (field('website_fax', 200) !== '') { + reply(200, ['ok' => true]); +} +$started = (int)($_POST['t'] ?? 0); +if ($started > 0 && (time() - intdiv($started, 1000)) < MIN_SECONDS_ON_PAGE) { + reply(200, ['ok' => true]); +} + +// Rate limit per IP (small file in the system temp folder). +$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; +$rlFile = sys_get_temp_dir() . '/quorla-contact-' . hash('sha256', $ip); +$now = time(); +$hits = []; +if (is_file($rlFile)) { + $hits = array_filter(array_map('intval', explode(',', (string)file_get_contents($rlFile))), fn($t) => $t > $now - 3600); +} +if (count($hits) >= MAX_PER_HOUR) { + reply(429, ['ok' => false, 'error' => 'Too many requests. Please email support@quorlasocial.com instead.']); +} + +$name = oneLine(field('name', 100)); +$agency = oneLine(field('agency', 150)); +$email = oneLine(field('email', 200)); +$clients = oneLine(field('clients', 40)); +$deployment = oneLine(field('deployment', 40)); +$message = field('message', 5000); + +if ($name === '' || $agency === '' || $clients === '') { + reply(400, ['ok' => false, 'error' => 'Please fill in your name, agency and number of clients.']); +} +if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + reply(400, ['ok' => false, 'error' => 'Please enter a valid work email.']); +} + +$subject = 'Quorla access request: ' . $agency . ' (' . $name . ')'; +$body = "New access request from quorlasocial.com/contact\n\n" + . "Name: $name\n" + . "Agency: $agency\n" + . "Work email: $email\n" + . "Client accounts: $clients\n" + . "Preferred deployment: " . ($deployment !== '' ? $deployment : 'not given') . "\n\n" + . "Notes:\n" . ($message !== '' ? $message : '(none)') . "\n\n" + . "---\nSent " . gmdate('Y-m-d H:i') . " UTC from IP $ip. Reply to this email to answer them directly.\n"; + +$headers = [ + 'From: Quorla website <' . FROM . '>', + 'Reply-To: ' . $name . ' <' . $email . '>', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + 'X-Mailer: quorlasocial.com contact form', +]; + +$sent = mail(TO, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers), '-f' . FROM); +if (!$sent) { + reply(500, ['ok' => false, 'error' => 'We could not send your request. Please email support@quorlasocial.com.']); +} + +$hits[] = $now; +@file_put_contents($rlFile, implode(',', $hits), LOCK_EX); +reply(200, ['ok' => true]); diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 96f9126..f70cd04 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -34,6 +34,7 @@ import Base from "../layouts/Base.astro"; +
@@ -86,7 +87,9 @@ import Base from "../layouts/Base.astro";
Optional: Helps us tailor your workspace setup.
- @@ -94,9 +97,9 @@ import Base from "../layouts/Base.astro"; @@ -146,7 +149,10 @@ import Base from "../layouts/Base.astro"; @@ -212,6 +206,22 @@ import Base from "../layouts/Base.astro"; gap: 16px; } + .form-error { + background: #fdeeec; + border: 1px solid #f3c5bf; + color: #8f2a20; + border-radius: 8px; + padding: 12px 14px; + font-size: 14px; + margin-bottom: 14px; + } + + .form-error a { + color: inherit; + font-weight: 600; + text-decoration: underline; + } + .w-full { width: 100%; }