Sigurni kontaktni obrasci: izvorna PHP 8.3 provjera valjanosti e-pošte s pametnim predmemoriranjem
Obrazac za kontakt može biti savršeno valjan HTML, a ipak prikupljati adrese koje ne mogu primiti odgovor. Provjera preglednika otkriva očite tipografske pogreške, ali ne ispituje domene, MX zapise, signale pružatelja usluge ni praktični rizik isporuke. Rezultat je poznat: neodgovoreni upiti, uzaludan naknadni rad i neuredne prijave putem obrasca.
Ovaj vodič izrađuje sažetu izvornu aplikaciju Native PHP 8.3 koja lokalno provjerava adresu, koristi uslugu Email Validator, predmemorira uspješne procjene i nastavlja prihvaćati legitimne poruke kada udaljena usluga privremeno nije dostupna. Prihvaćene prijave pohranjuju se u SQLite, dok se nesigurne prijave označavaju za kasniji pregled umjesto da se tiho odbace.
Pribavite pristup prije pisanja integracijskog koda
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Email Validator.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token vezan uz uslugu.
Ova usluga zahtijeva autentikaciju. Token se šalje putem parametra upita token={serviceToken}. Ponovno generiranje tokena opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom i odmah ažurirajte svaku pokrenutu instancu.
Točan zahtjev je GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Uz token zahtijeva parametar upita email. Izvršite jedan minimalni zahtjev prije izrade funkcionalnosti:
curl --get 'https://ai.mihajlo.mk/api/email-validator/v1/check-email' \
--data-urlencode '[email protected]' \
--data-urlencode 'token=YOUR_SERVICE_TOKEN'
Usporedite vraćeni JSON sa službenom dokumentacijom. Posebno zabilježite dokumentirane vrijednosti i ljestvicu koje koriste status, score i recommendation. Isporučeni ugovor navodi ova polja, ali ne opravdava nagađanje o njihovim enum vrijednostima ili rasponu bodova.
Pohranite vjerodajnicu i izričitu politiku aplikacije u konfiguraciju podržanu varijablama okruženja. Nemojte uvrstiti ovu datoteku u repozitorij:
EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_ALLOWED_STATUSES=REPLACE_WITH_DOCUMENTED_ACCEPTED_STATUS
EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS=REPLACE_WITH_DOCUMENTED_ACCEPTED_RECOMMENDATION
EMAIL_VALIDATOR_MIN_SCORE=REPLACE_WITH_A_THRESHOLD_IN_THE_DOCUMENTED_SCALE
EMAIL_VALIDATOR_CACHE_KEY=REPLACE_WITH_A_LONG_RANDOM_SECRET
EMAIL_VALIDATOR_CACHE_TTL=21600
APP_DB_PATH=/srv/contact-form/var/app.sqlite
Važno je da vrijednosti politike ostanu podesive. To aplikaciji omogućuje korištenje odgovora usluge bez ugrađivanja nedokumentiranih pretpostavki o značenju određene riječi ili broja.
Arhitektura i politika kvara
Put zahtjeva namjerno ostaje sinkron jer posjetitelju treba neposredna odluka. PHP prvo obavlja jeftinu lokalnu provjeru, zatim provjerava predmemoriju i tek tada poziva uslugu. Uspješan odgovor mapira se u tipizirani domenski objekt prije nego što ga vidi bilo koji kod politike.
- Procjena odobrena politikom pohranjuje poruku kao
verified. - Odbijanje prema politici vraća neutralnu pogrešku provjere i ne pohranjuje ništa.
- Transportna pogreška, neispravan odgovor, iscrpljeno operativno stanje ili drugi nesiguran rezultat pohranjuje poruku kao
deferred. - Predmemoriraju se samo strukturno valjani odgovori usluge.
Odgođeni put je namjeran. Odbijanje svakog upita tijekom mrežnog incidenta čini obrazac za kontakt potpuno ovisnim o dostupnosti drugog sustava. Prihvaćanje nesigurnih poruka čuva trud korisnika, a pohranjeno stanje omogućuje operateru da ih kasnije ponovno provjeri. Ne šaljite automatske odgovore na odgođene adrese dok ne budu pregledane.
Projekt koristi ovu strukturu:
contact-form/
├── composer.json
├── database/schema.sql
├── public/index.php
├── src/
│ ├── CurlTransport.php
│ ├── EmailAssessment.php
│ ├── EmailPolicy.php
│ └── EmailValidator.php
├── tests/EmailValidatorTest.php
└── var/
Postavite PHP, SQLite i PHPUnit
PHP 8.3, cURL, PDO SQLite, JSON, Composer i SQLite klijent naredbenog retka preduvjeti su. PHPUnit 11 podržava PHP 8.3 i jedina je razvojna ovisnost:
{
"require": {
"php": ">=8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
CREATE TABLE email_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE contacts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
validation_state TEXT NOT NULL,
api_metadata TEXT,
created_at TEXT NOT NULL
);
composer install
mkdir -p var
chmod 700 var
sqlite3 var/app.sqlite < database/schema.sql
composer dump-autoload
Baza podataka i njezin nadređeni direktorij moraju biti upisivi korisniku PHP-FPM-a, ali ne smiju se nalaziti unutar public/.
Mapirajte API odgovor na granici
Aplikacija koristi svih pet ugovorenih područja odgovora: status, score, recommendation, checks i quota. Nepoznata dodatna polja zanemaruju se. Nedostajuća ili pogrešno tipizirana obavezna polja pretvaraju odgovor u kontrolirani kvar umjesto da slabo tipizirani podaci procure u poslovnu logiku.
<?php
// src/EmailAssessment.php
declare(strict_types=1);
namespace App;
final readonly class EmailAssessment
{
public function __construct(
public string $status,
public float $score,
public string $recommendation,
public array $checks,
public array $quota,
) {}
public static function fromArray(array $data): self
{
foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
if (!array_key_exists($field, $data)) {
throw new \UnexpectedValueException("Missing API field: {$field}");
}
}
if (!is_string($data['status']) ||
!is_string($data['recommendation']) ||
!is_numeric($data['score']) ||
!is_array($data['checks']) ||
!is_array($data['quota'])) {
throw new \UnexpectedValueException('Invalid Email Validator response types');
}
$score = (float) $data['score'];
if (!is_finite($score)) {
throw new \UnexpectedValueException('Non-finite score');
}
return new self(
trim($data['status']),
$score,
trim($data['recommendation']),
$data['checks'],
$data['quota'],
);
}
public function toArray(): array
{
return [
'status' => $this->status,
'score' => $this->score,
'recommendation' => $this->recommendation,
'checks' => $this->checks,
'quota' => $this->quota,
];
}
}
Izradite ograničeni cURL transport svjestan ponavljanja
Transport ponavlja samo mrežne kvarove, HTTP 429 i kvarove na strani poslužitelja. Pogreške autentikacije i drugi odgovori 4xx ne ponavljaju se jer ponavljanje neće popraviti nevaljan token ili zahtjev. Pauza između pokušaja kratka je i ograničena kako jedna prijava obrasca ne bi neograničeno zauzimala PHP radnik.
<?php
// src/CurlTransport.php
declare(strict_types=1);
namespace App;
final class CurlTransport
{
public function get(string $endpoint, array $query): array
{
$url = $endpoint . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$lastError = 'request failed';
for ($attempt = 0; $attempt < 3; $attempt++) {
$headers = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 3000,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'contact-form/1.0',
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $curl,
string $line
) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$body = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
if ($body !== false && $status >= 200 && $status < 300) {
$decoded = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new \UnexpectedValueException('Expected a JSON object');
}
return $decoded;
}
$retryable = $body === false || $status === 429 || $status >= 500;
$lastError = $body === false ? $curlError : "HTTP {$status}";
if (!$retryable || $attempt === 2) {
throw new \RuntimeException("Email validation failed: {$lastError}");
}
$retryAfter = isset($headers['retry-after']) &&
ctype_digit($headers['retry-after'])
? min(2, (int) $headers['retry-after'])
: 0;
$delayMicroseconds = $retryAfter > 0
? $retryAfter * 1_000_000
: (100_000 * (2 ** $attempt)) + random_int(0, 50_000);
usleep($delayMicroseconds);
}
throw new \RuntimeException("Email validation failed: {$lastError}");
}
}
Odgovor 429 može označavati kratkotrajno ograničenje brzine ili iscrpljenu kvotu. Transport poštuje mali numerički Retry-After, ali ograničenje od tri pokušaja sprječava da pretjerana vrijednost poslužitelja blokira zahtjev. Trajni problemi s kvotom završavaju na odgođenom putu i trebali bi aktivirati operativno upozorenje.
Dodajte predmemoriranje svjesno privatnosti i domensku politiku
Adrese e-pošte osobni su podaci. Ključevi predmemorije stoga koriste HMAC s ključem umjesto pohrane adrese ili lako reverzibilnog običnog sažetka u tablici predmemorije. Uspješne procjene dobivaju ograničeni TTL; transportne pogreške nikada se ne predmemoriraju.
<?php
// src/EmailValidator.php
declare(strict_types=1);
namespace App;
final class EmailValidator
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/email-validator/v1/check-email';
public function __construct(
private \PDO $db,
private CurlTransport $transport,
private string $token,
private string $cacheSecret,
private int $ttl,
) {}
public function check(string $email): EmailAssessment
{
$normalized = strtolower(trim($email));
$key = hash_hmac('sha256', $normalized, $this->cacheSecret);
$query = $this->db->prepare(
'SELECT payload FROM email_cache
WHERE cache_key = :key AND expires_at > :now'
);
$query->execute(['key' => $key, 'now' => time()]);
$cached = $query->fetchColumn();
if (is_string($cached)) {
try {
return EmailAssessment::fromArray(
json_decode($cached, true, 64, JSON_THROW_ON_ERROR)
);
} catch (\Throwable) {
$delete = $this->db->prepare(
'DELETE FROM email_cache WHERE cache_key = :key'
);
$delete->execute(['key' => $key]);
}
}
$payload = $this->transport->get(self::ENDPOINT, [
'email' => $normalized,
'token' => $this->token,
]);
$assessment = EmailAssessment::fromArray($payload);
$write = $this->db->prepare(
'INSERT INTO email_cache(cache_key, payload, expires_at)
VALUES(:key, :payload, :expires)
ON CONFLICT(cache_key) DO UPDATE SET
payload = excluded.payload,
expires_at = excluded.expires_at'
);
$write->execute([
'key' => $key,
'payload' => json_encode(
$assessment->toArray(),
JSON_THROW_ON_ERROR
),
'expires' => time() + $this->ttl,
]);
return $assessment;
}
}
<?php
// src/EmailPolicy.php
declare(strict_types=1);
namespace App;
final readonly class EmailPolicy
{
public function __construct(
private array $allowedStatuses,
private array $allowedRecommendations,
private float $minimumScore,
) {}
public function decide(EmailAssessment $result): string
{
if ($result->quota === []) {
return 'deferred';
}
if (!in_array($result->status, $this->allowedStatuses, true) ||
!in_array(
$result->recommendation,
$this->allowedRecommendations,
true
) ||
$result->score < $this->minimumScore ||
$this->containsExplicitFailure($result->checks)) {
return 'rejected';
}
return 'verified';
}
private function containsExplicitFailure(array $checks): bool
{
foreach ($checks as $value) {
if ($value === false) {
return true;
}
if (is_array($value) && $this->containsExplicitFailure($value)) {
return true;
}
}
return false;
}
}
Ova je politika namjerno stroga: bilo koji izričiti Booleov neuspjeh u checks odbija adresu. Ako dokumentirani odgovor uključuje informativna Booleova polja koja opravdano mogu biti false, zamijenite rekurzivno pravilo imenovanim popisom dopuštenih dokumentiranih ključeva provjere.
Povežite validator s obrascem za kontakt
Prednji kontroler provodi CSRF zaštitu, ograničenja duljine unosa, lokalnu provjeru sintakse i pripremljene izjave baze podataka. Posjetiteljima nikada ne prikazuje pojedinosti pružatelja usluge i nikada ne zapisuje token u zapisnike.
<?php
// public/index.php
declare(strict_types=1);
use App\CurlTransport;
use App\EmailPolicy;
use App\EmailValidator;
require dirname(__DIR__) . '/vendor/autoload.php';
session_start();
$required = static function (string $name): string {
$value = getenv($name);
if ($value === false || trim($value) === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return trim($value);
};
$db = new PDO('sqlite:' . $required('APP_DB_PATH'), null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$validator = new EmailValidator(
$db,
new CurlTransport(),
$required('EMAIL_VALIDATOR_TOKEN'),
$required('EMAIL_VALIDATOR_CACHE_KEY'),
(int) $required('EMAIL_VALIDATOR_CACHE_TTL'),
);
$policy = new EmailPolicy(
array_map('trim', explode(',', $required('EMAIL_VALIDATOR_ALLOWED_STATUSES'))),
array_map(
'trim',
explode(',', $required('EMAIL_VALIDATOR_ALLOWED_RECOMMENDATIONS'))
),
(float) $required('EMAIL_VALIDATOR_MIN_SCORE'),
);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$message = '';
$httpStatus = 200;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string) ($_POST['csrf'] ?? '');
$name = trim((string) ($_POST['name'] ?? ''));
$email = strtolower(trim((string) ($_POST['email'] ?? '')));
$body = trim((string) ($_POST['message'] ?? ''));
if (!hash_equals($_SESSION['csrf'], $token)) {
$httpStatus = 403;
$message = 'Please reload the form and try again.';
} elseif (
$name === '' || strlen($name) > 120 ||
strlen($email) > 254 ||
!filter_var($email, FILTER_VALIDATE_EMAIL) ||
$body === '' || strlen($body) > 5000
) {
$httpStatus = 422;
$message = 'Please check the submitted fields.';
} else {
$state = 'deferred';
$metadata = null;
try {
$assessment = $validator->check($email);
$state = $policy->decide($assessment);
$metadata = json_encode(
$assessment->toArray(),
JSON_THROW_ON_ERROR
);
} catch (Throwable $error) {
error_log(json_encode([
'event' => 'email_validation_unavailable',
'exception' => $error::class,
], JSON_THROW_ON_ERROR));
}
if ($state === 'rejected') {
$httpStatus = 422;
$message = 'Please provide another email address.';
} else {
$insert = $db->prepare(
'INSERT INTO contacts
(id, name, email, message, validation_state,
api_metadata, created_at)
VALUES
(:id, :name, :email, :message, :state,
:metadata, :created)'
);
$insert->execute([
'id' => bin2hex(random_bytes(16)),
'name' => $name,
'email' => $email,
'message' => $body,
'state' => $state,
'metadata' => $metadata,
'created' => gmdate(DATE_ATOM),
]);
http_response_code(202);
echo '<p>Thank you. Your message has been received.</p>';
exit;
}
}
}
http_response_code($httpStatus);
$escape = static fn(string $value): string =>
htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<?php if ($message !== ''): ?>
<p><?= $escape($message) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= $escape($_SESSION['csrf']) ?>">
<label>Name <input name="name" maxlength="120" required></label>
<label>Email
<input name="email" type="email" maxlength="254" required>
</label>
<label>Message
<textarea name="message" maxlength="5000" required></textarea>
</label>
<button type="submit">Send</button>
</form>
Deterministički testirajte predmemoriranje i putove kvara
Testovi ne smiju ovisiti o dostupnosti mreže ni trošiti kvotu. Ako transport nije finalan ili se u većoj bazi koda izdvoji sučelje, moguće je koristiti deterministički lažni objekt. Ovaj test provjerava da predmemorirani rezultat sprječava drugi HTTP zahtjev:
<?php
// tests/EmailValidatorTest.php
declare(strict_types=1);
namespace Tests;
use App\CurlTransport;
use App\EmailValidator;
use PHPUnit\Framework\TestCase;
final class FakeTransport extends CurlTransport
{
public int $calls = 0;
public function __construct(private array $response) {}
public function get(string $endpoint, array $query): array
{
$this->calls++;
return $this->response;
}
}
final class EmailValidatorTest extends TestCase
{
public function testValidResponseIsMappedAndCached(): void
{
$db = new \PDO('sqlite::memory:');
$db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$db->exec(
'CREATE TABLE email_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL
)'
);
$fake = new FakeTransport([
'status' => 'documented-status',
'score' => 80,
'recommendation' => 'documented-recommendation',
'checks' => ['syntax' => true, 'mx' => true],
'quota' => ['remaining' => 10],
]);
$validator = new EmailValidator(
$db,
$fake,
'test-token',
'test-cache-secret',
3600,
);
$first = $validator->check('[email protected]');
$second = $validator->check('[email protected]');
self::assertSame('documented-status', $first->status);
self::assertSame($first->toArray(), $second->toArray());
self::assertSame(1, $fake->calls);
}
public function testMalformedResponseIsRejected(): void
{
$this->expectException(\UnexpectedValueException::class);
\App\EmailAssessment::fromArray(['status' => 'incomplete']);
}
}
vendor/bin/phpunit --testdox
Dodajte fokusirane testove za HTTP 401 bez ponavljanja, ponovljeni 429, oštećeni JSON predmemorije, izričitu false provjeru, prazan objekt kvote, granice rezultata, odbijanje CSRF-a i odgođeni put baze podataka. Nizovi odgovora lažnog objekta namjerno su testni elementi, a ne tvrdnje o enum vrijednostima u produkciji.
Sigurnost, vidljivost i implementacija
Prekinite TLS na pouzdanom proxyju, ograničite veličinu tijela zahtjeva i dodajte ograničavanje obrasca po IP adresi ili sesiji. API token, HMAC tajnu predmemorije, bazu podataka i datoteke okruženja držite izvan korijena dokumenta. Nikada ne stavljajte puni URL zahtjeva u zapisnike jer njegov niz upita sadrži token.
Korisni strukturirani događaji zapisnika uključuju latenciju provjere, HTTP status, broj ponavljanja, pogodak ili promašaj predmemorije, konačno stanje i klasu iznimke. Ako je korelacija potrebna, adresu sažmite istim HMAC-om s ključem; nemojte zapisivati sirovu adresu. Upozorite na trajne stope odgođenih prijava, kvarove autentikacije i odgovore 429 povezane s kvotom.
Tijekom implementacije izradite shemu prije preusmjeravanja prometa, umetnite varijable okruženja putem upravitelja procesa ili spremišta tajni, provjerite odlazni HTTPS pristup i potvrdite da PHP korisnik može pisati samo u namijenjene SQLite datoteke. Upotrijebite način WAL ili prijeđite na poslužiteljsku bazu podataka kada istodobni volumen pisanja preraste SQLite. Rotirajte token usluge ažuriranjem svih instanci odmah nakon ponovnog generiranja jer je prethodni token opozvan.
Česti kvarovi i konačna provjera
- Svaki zahtjev je odgođen: provjerite DNS, odlazni HTTPS, cURL certifikate, vremenska ograničenja, kvotu i strukturirane zapisnike pogrešaka.
- Autentikacija ne uspijeva: potvrdite da je token vezan uz uslugu aktivan i poslan putem parametra upita
token. - Valjane adrese se odbijaju: provjerite konfigurirani status, preporuku, ljestvicu rezultata i dokumentirano značenje Booleovih provjera.
- Predmemorija nikada nije pogođena: potvrdite normalizaciju, dosljednost HMAC tajne među instancama, upisivu pohranu i pozitivan TTL.
- SQLite prijavljuje pogreške zaključavanja: skratite transakcije, omogućite WAL gdje je prikladno ili premjestite dijeljeno stanje u bazu podataka dizajniranu za istodobne zapisivače.
- Pošaljite neispravno oblikovanu adresu i potvrdite da nije izrađen API zahtjev ni red kontakta.
- Pošaljite dokumentirani prihvatljivi slučaj i potvrdite red
verified. - Ponovite ga i potvrdite pogodak predmemorije bez drugog udaljenog poziva.
- Isprobajte dokumentirani rizični slučaj i potvrdite neutralni odgovor 422.
- Onemogućite odlazni pristup i potvrdite da je poruka zadržana kao
deferred. - Simulirajte neispravne odgovore JSON, 401, 429 i 5xx u automatiziranim testovima.
- Potvrdite da zapisnici ne sadrže ni token usluge ni sirove adrese e-pošte.
Važna lekcija za produkciju nije samo pozvati krajnju točku za provjeru e-pošte. Potrebno je oko nje izgraditi granicu: izričitu politiku, obrambeno mapiranje, ograničena ponavljanja, predmemoriranje svjesno privatnosti, vidljiva stanja kvara i zamjenski put koji poštuje trud posjetitelja. S tim dijelovima na mjestu, mali obrazac za kontakt postaje znatno pouzdaniji bez nepotrebnog usložnjavanja.