Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
To send a contact form email with PHP's built-in mail() function, validate the visitor's input, remove line breaks from anything that goes into a mail header, send from an address on your own domain, put the visitor's address in Reply-To, pass your address with -f as the fifth argument, and check what mail() returns. That's the whole recipe. No Composer, no PHPMailer, no SMTP password stored in your code. Below is the version I wrote for the contact form on my portfolio, step by step, with a way to test it without sending a real email.
I tested every snippet on PHP 8.4 with php-cli, and I ran the full form on PHP's built-in server with a fake sendmail so I could read the exact email PHP produced.
mail() hands your message to the server's local mail program (usually sendmail or a compatible one). On most Linux shared hosting that program is already set up, so mail() works with zero configuration. For a contact form that sends a plain-text notification to you, that's all you need.
It's the wrong tool when you need HTML newsletters, attachments, bulk sending, or delivery reports. It also won't help if your host disables mail() or your messages keep landing in spam no matter what you fix. In those cases, use SMTP through a library like PHPMailer or Symfony Mailer, or a transactional email service.
I keep the sending code in its own file so I can test it from the command line:
<?php
declare(strict_types=1);
const MAIL_TO = 'you@example.com'; // your inbox, never taken from the form
const MAIL_FROM = 'no-reply@example.com'; // a mailbox on YOUR domain
/** Replace characters that could start a new mail header. */
function header_safe(string $value): string
{
return trim(str_replace(["\r", "\n", "\0"], ' ', $value));
}
/** Encode a header value so Bangla, emoji etc. survive (RFC 2047). */
function encode_header(string $value): string
{
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
function send_contact_mail(string $name, string $email, string $subject, string $message): bool
{
$replyTo = filter_var(header_safe($email), FILTER_VALIDATE_EMAIL);
if ($replyTo === false) {
return false; // not one clean address, so refuse to send
}
$name = header_safe($name);
$subject = header_safe($subject);
$body = "Name: {$name}\nEmail: {$replyTo}\n\n"
. str_replace("\r\n", "\n", $message) . "\n";
$headers = [
'From' => 'Website contact form <' . MAIL_FROM . '>',
'Reply-To' => $replyTo,
'MIME-Version' => '1.0',
'Content-Type' => 'text/plain; charset=UTF-8',
'Content-Transfer-Encoding' => '8bit',
];
// The 5th argument sets the envelope sender (Return-Path). Many hosts
// need it for SPF to pass. Only ever put your own constant here.
return mail(MAIL_TO, encode_header('Contact: ' . $subject), $body, $headers, '-f' . MAIL_FROM);
}
Each choice in that function is there for a reason:
From. That's spoofing: Gmail and other providers check SPF and DMARC, see that your server isn't allowed to send for gmail.com, and send the message to spam or reject it. The visitor's address goes in Reply-To, so pressing Reply in your inbox still answers them.header_safe() blocks header injection. Mail headers are separated by line breaks. If a bot sends an "email" like x@example.com\r\nBcc: victim@example.org and you paste it into a header, it has just added its own Bcc line. Replacing \r, \n and \0 stops that, and FILTER_VALIDATE_EMAIL then rejects whatever is left.mail() accepts an array of headers and joins them correctly, so you don't have to glue strings together with "\r\n".=?UTF-8?B?...?= format (RFC 2047) lets a Bangla subject like "ওয়েবসাইট project" arrive intact.-f parameter sets the envelope sender, the address bounces go to. Many shared hosts need it to match your domain for SPF to pass. Only ever put your own constant there, never user input, because this argument goes to the command line of the mail program.PHP runs whatever program the sendmail_path setting names, so for testing I pointed it at a tiny shell script that saves the message to a file instead of sending it:
#!/bin/sh
# fake-sendmail.sh: saves the arguments and the raw message
{ echo "ARGS: $*"; cat; } > /tmp/post01-mail.eml
Then I called the function with a normal message and a malicious one, running php -d sendmail_path=./fake-sendmail.sh test_mailer.php:
<?php
// Run: php -d sendmail_path=./fake-sendmail.sh test_mailer.php
require __DIR__ . '/mailer.php';
$ok = send_contact_mail('Mahir Faysal', 'mahir@example.com', 'ওয়েবসাইট project', "Hi!\r\nCan we talk?");
var_dump($ok);
echo file_get_contents('/tmp/post01-mail.eml');
// A bot tries to add a Bcc header through the email field
$evil = "x@example.com\r\nBcc: victim@example.org";
var_dump(send_contact_mail('Bot', $evil, 'hello', 'spam'));
// A newline in the name or subject just becomes a space
echo header_safe("Rafi\r\nBcc: victim@example.org"), "\n";
// Output:
// bool(true)
// ARGS: -fno-reply@example.com
// To: you@example.com
// Subject: =?UTF-8?B?Q29udGFjdDog4KaT4Kav4Ka84KeH4Kas4Ka44Ka+4KaH4KafIHByb2plY3Q=?=
// From: Website contact form <no-reply@example.com>
// Reply-To: mahir@example.com
// MIME-Version: 1.0
// Content-Type: text/plain; charset=UTF-8
// Content-Transfer-Encoding: 8bit
//
// Name: Mahir Faysal
// Email: mahir@example.com
//
// Hi!
// Can we talk?
//
// bool(false)
// Rafi Bcc: victim@example.org
The real message has the right headers, the Bangla subject is encoded, and Windows line endings in the message are normalized. The injection attempt returns false and nothing is sent. This trick is also handy for checking your headers before you deploy.
Here is a complete, single-file form that uses send_contact_mail(). The validation is kept short on purpose. For the full version with length limits, allow-lists and a CSRF token, see my PHP form validation and sanitization guide.
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/mailer.php';
function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$errors = [];
$old = ['name' => '', 'email' => '', 'subject' => '', 'message' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
foreach ($old as $key => $_) {
$old[$key] = is_string($_POST[$key] ?? null) ? trim($_POST[$key]) : '';
}
$honeypot = trim((string) ($_POST['website'] ?? ''));
if ($old['name'] === '') $errors['name'] = 'Please enter your name.';
if (filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false)
$errors['email'] = 'Please enter a valid email.';
if ($old['subject'] === '') $errors['subject'] = 'Please add a subject.';
if (strlen($old['message']) < 10) $errors['message'] = 'Please write a little more.';
if ($errors === []) {
// Bots fill the hidden field: pretend it worked, send nothing.
$sent = $honeypot !== '' || send_contact_mail($old['name'], $old['email'], $old['subject'], $old['message']);
if ($sent) {
$_SESSION['flash'] = 'Thanks! Your message was sent.';
header('Location: ' . $_SERVER['PHP_SELF'], true, 303);
exit;
}
$errors['form'] = 'Sorry, the message could not be sent. Please try again later.';
}
}
$flash = $_SESSION['flash'] ?? '';
unset($_SESSION['flash']);
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Contact</title></head>
<body>
<?php if ($flash !== ''): ?><p><?= e($flash) ?></p><?php endif; ?>
<?php foreach ($errors as $msg): ?><p><?= e($msg) ?></p><?php endforeach; ?>
<form method="post">
<label>Name <input name="name" type="text" value="<?= e($old['name']) ?>" required></label>
<label>Email <input name="email" type="email" value="<?= e($old['email']) ?>" required></label>
<label>Subject <input name="subject" type="text" value="<?= e($old['subject']) ?>" required></label>
<label>Message <textarea name="message" required><?= e($old['message']) ?></textarea></label>
<!-- Honeypot: hidden from people, filled in by many bots -->
<div hidden><label>Website <input name="website" type="text" tabindex="-1" autocomplete="off"></label></div>
<button type="submit">Send</button>
</form>
</body>
</html>
What the page does:
website input is hidden from people, but many bots fill in every field. When it has a value, the page pretends the message was sent and quietly drops it, so the bot learns nothing.mail() returns false, the visitor sees an error instead of a fake success. I tested that by pointing sendmail_path at /bin/false.htmlspecialchars(). Sending <b>Rafi" as the name came back as harmless text.On my own site, the message is also saved to MySQL before the email goes out, so nothing is lost if the email fails. If you want to do the same, use PDO prepared statements for the insert.
mail() returning true only means the local mail program accepted the message. It doesn't mean it was delivered. When nothing shows up, check these in order:
no-reply@yourdomain.com. Some hosts require the mailbox to really exist in the control panel.mail() entirely. Their support can tell you which.No. It's still part of PHP 8.4. It's simply low-level: it doesn't do SMTP authentication, HTML templates or attachments for you.
Not if your host supports mail() and you're sending plain text to yourself. Switch to PHPMailer with SMTP when you need authentication, attachments, or better deliverability than your host's mail server gives you.
Be careful. Anyone can type someone else's address into your form, so an auto-reply lets bots use your server to send email to strangers. If you add one, keep its text fixed and never include the visitor's message in it.
It stops lazy bots. For smarter ones, add a simple question that a person answers easily. I wrote about that in my simple math captcha in PHP post.
A safe PHP contact form with mail() fits in two small files: a fixed recipient, a From address on your own domain, the visitor in Reply-To, newlines stripped from header values, an encoded subject and the -f envelope sender. Check the return value, redirect after success, and test with a fake sendmail before you go live. The PHP manual page for mail() lists every parameter if you want to go further.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.
A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.