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:
parent
39ca45f44c
commit
ed61d9057b
@ -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/
|
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
|
## Keep these working
|
||||||
|
|
||||||
Google's OAuth verification and TikTok's app review point at these URLs. Don't remove or move them:
|
Google's OAuth verification and TikTok's app review point at these URLs. Don't remove or move them:
|
||||||
|
|||||||
@ -86,11 +86,14 @@ Footer: product links, legal links, contact email, © Metatroncube Software Solu
|
|||||||
|
|
||||||
## 6. Contact form
|
## 6. Contact form
|
||||||
|
|
||||||
The site is static, so the form needs somewhere to send. Pick one and note it in the README:
|
Built. `/contact` posts to **`public/api/contact.php`** (copied as-is into `dist/api/`). HestiaCP runs it with PHP and
|
||||||
1. A form service (Formspree, Web3Forms, Basin) posting to support@quorlasocial.com. Simplest. Or
|
sends through the server's own mail (exim, DKIM-signed for quorlasocial.com) to **support@quorlasocial.com**, with
|
||||||
2. A small endpoint in the Quorla app later.
|
the visitor's email as Reply-To. No passwords or API keys in code.
|
||||||
Until then, the "Request access" buttons stay as `mailto:support@quorlasocial.com?subject=Quorla%20access`.
|
- Spam protection: honeypot field `website_fax`, minimum 3 s on the page (hidden field `t`), 5 requests per IP per
|
||||||
Add a honeypot field against spam. Don't collect anything beyond what's in §4.
|
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
|
## 7. Done means
|
||||||
|
|
||||||
|
|||||||
103
public/api/contact.php
Normal file
103
public/api/contact.php
Normal 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]);
|
||||||
@ -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>
|
<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" />
|
<input type="text" id="agency_website_fax" name="website_fax" tabindex="-1" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" id="formStarted" name="t" value="" />
|
||||||
|
|
||||||
<div class="form-row-2">
|
<div class="form-row-2">
|
||||||
<div class="form-group">
|
<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 class="form-help">Optional: Helps us tailor your workspace setup.</div>
|
||||||
</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
|
Submit Request for Invitation
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -94,9 +97,9 @@ import Base from "../layouts/Base.astro";
|
|||||||
<div id="formSuccessMessage" class="success-box" hidden>
|
<div id="formSuccessMessage" class="success-box" hidden>
|
||||||
<div class="success-icon">✓</div>
|
<div class="success-icon">✓</div>
|
||||||
<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;">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -146,7 +149,10 @@ import Base from "../layouts/Base.astro";
|
|||||||
<script>
|
<script>
|
||||||
const form = document.getElementById("accessForm") as HTMLFormElement | null;
|
const form = document.getElementById("accessForm") as HTMLFormElement | null;
|
||||||
const successBox = document.getElementById("formSuccessMessage");
|
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) {
|
if (form) {
|
||||||
// Check if URL has ?plan=
|
// 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();
|
e.preventDefault();
|
||||||
|
if (errorBox) errorBox.hidden = true;
|
||||||
// Check honeypot
|
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = "Sending…"; }
|
||||||
const hp = (document.getElementById("agency_website_fax") as HTMLInputElement)?.value;
|
try {
|
||||||
if (hp) {
|
const res = await fetch("/api/contact.php", { method: "POST", body: new FormData(form) });
|
||||||
// Bot detected, silently exit
|
const data = await res.json().catch(() => ({ ok: false }));
|
||||||
return;
|
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>
|
</script>
|
||||||
@ -212,6 +206,22 @@ import Base from "../layouts/Base.astro";
|
|||||||
gap: 16px;
|
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 {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user