Contact form sends through the server's mail (PHP endpoint) instead of mailto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-22 19:00:38 -04:00
parent 39ca45f44c
commit ed61d9057b
4 changed files with 162 additions and 41 deletions

View File

@ -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:

View File

@ -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

103
public/api/contact.php Normal file
View File

@ -0,0 +1,103 @@
<?php
// Contact form endpoint for quorlasocial.com.
// Astro copies this file as-is to dist/api/contact.php; HestiaCP runs it with PHP and sends through the
// server's own mail system (exim), so the message is DKIM-signed for quorlasocial.com. No passwords in code.
declare(strict_types=1);
const TO = 'support@quorlasocial.com';
const FROM = 'support@quorlasocial.com';
const ALLOWED_ORIGINS = ['https://quorlasocial.com', 'https://www.quorlasocial.com'];
const MAX_PER_HOUR = 5; // per visitor IP
const MIN_SECONDS_ON_PAGE = 3; // bots submit instantly
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function reply(int $status, array $body): void {
http_response_code($status);
echo json_encode($body);
exit;
}
function field(string $key, int $max): string {
$v = trim((string)($_POST[$key] ?? ''));
$v = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v) ?? '';
return mb_substr($v, 0, $max);
}
function oneLine(string $v): string {
return trim(preg_replace('/[\r\n]+/', ' ', $v) ?? '');
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
reply(405, ['ok' => 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]);

View File

@ -34,6 +34,7 @@ import Base from "../layouts/Base.astro";
<label for="agency_website_fax">Do not fill this out if you are human</label>
<input type="text" id="agency_website_fax" name="website_fax" tabindex="-1" autocomplete="off" />
</div>
<input type="hidden" id="formStarted" name="t" value="" />
<div class="form-row-2">
<div class="form-group">
@ -86,7 +87,9 @@ import Base from "../layouts/Base.astro";
<div class="form-help">Optional: Helps us tailor your workspace setup.</div>
</div>
<button type="submit" class="btn btn-coral btn-lg w-full">
<div id="formError" class="form-error" role="alert" hidden></div>
<button type="submit" id="formSubmit" class="btn btn-coral btn-lg w-full">
Submit Request for Invitation
</button>
</form>
@ -94,9 +97,9 @@ import Base from "../layouts/Base.astro";
<div id="formSuccessMessage" class="success-box" hidden>
<div class="success-icon">✓</div>
<div>
<h3 style="font-size: 18px; margin-bottom: 4px; color: #1a7f45;">Ready to send your details</h3>
<h3 style="font-size: 18px; margin-bottom: 4px; color: #1a7f45;">Thanks, we've got your request</h3>
<p style="margin: 0; font-size: 14px; color: #2e4a36;">
Clicking submit opened your mail client with your application details addressed to <strong>support@quorlasocial.com</strong>. If your email didn't launch automatically, <a id="directMailLink" href="mailto:support@quorlasocial.com" style="text-decoration: underline; font-weight: 600;">click here to send directly</a>.
It went straight to our team at <strong>support@quorlasocial.com</strong>. We'll reply to your work email.
</p>
</div>
</div>
@ -146,7 +149,10 @@ import Base from "../layouts/Base.astro";
<script>
const form = document.getElementById("accessForm") as HTMLFormElement | null;
const successBox = document.getElementById("formSuccessMessage");
const directMailLink = document.getElementById("directMailLink") as HTMLAnchorElement | null;
const errorBox = document.getElementById("formError");
const submitBtn = document.getElementById("formSubmit") as HTMLButtonElement | null;
const started = document.getElementById("formStarted") as HTMLInputElement | null;
if (started) started.value = String(Date.now());
if (form) {
// Check if URL has ?plan=
@ -160,40 +166,28 @@ import Base from "../layouts/Base.astro";
}
}
form.addEventListener("submit", (e) => {
const showError = (text: string) => {
if (!errorBox) return;
const link = document.createElement("a");
link.href = "mailto:support@quorlasocial.com";
link.textContent = "support@quorlasocial.com";
errorBox.replaceChildren(`${text} You can also email `, link, ".");
errorBox.hidden = false;
};
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Check honeypot
const hp = (document.getElementById("agency_website_fax") as HTMLInputElement)?.value;
if (hp) {
// Bot detected, silently exit
return;
if (errorBox) errorBox.hidden = true;
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = "Sending…"; }
try {
const res = await fetch("/api/contact.php", { method: "POST", body: new FormData(form) });
const data = await res.json().catch(() => ({ ok: false }));
if (!res.ok || !data.ok) throw new Error(data.error || "We couldn't send your request.");
if (successBox) { successBox.hidden = false; form.style.display = "none"; }
} catch (err) {
showError(err instanceof Error ? err.message : "We couldn't send your request.");
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "Submit Request for Invitation"; }
}
const name = (document.getElementById("contactName") as HTMLInputElement)?.value || "";
const agency = (document.getElementById("agencyName") as HTMLInputElement)?.value || "";
const email = (document.getElementById("workEmail") as HTMLInputElement)?.value || "";
const clients = (document.getElementById("clientCount") as HTMLSelectElement)?.value || "";
const deployment = (document.getElementById("deployModel") as HTMLSelectElement)?.value || "";
const message = (document.getElementById("agencyMessage") as HTMLTextAreaElement)?.value || "";
const subject = encodeURIComponent(`Quorla Access Request: ${agency} (${name})`);
const body = encodeURIComponent(
`Name: ${name}\nAgency: ${agency}\nWork Email: ${email}\nNumber of Clients: ${clients}\nDeployment Model: ${deployment}\n\nNotes & Current Challenges:\n${message}\n`
);
const mailtoUrl = `mailto:support@quorlasocial.com?subject=${subject}&body=${body}`;
if (directMailLink) {
directMailLink.href = mailtoUrl;
}
if (successBox) {
successBox.hidden = false;
form.style.display = "none";
}
window.location.href = mailtoUrl;
});
}
</script>
@ -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%;
}