See my original post: here.
I have one html page and four php files that allow users to sign up for an email list. One of the php scripts is a cronjob that deletes unverified rows older than 24 hours, and it is not included below for the sake of post length. I use PDO for my prepared statements. Everything has been tested live and is fully functional to the best of my knowledge. Any and all feedback is welcome. I've bulleted some questions below the snippets. :)
email.html --- Users sign up here
<form action="signup.php" method="POST" autocomplete="off">
<input type="text" autocomplete="off" placeholder="Email address" name="email" required>
<br/>
<input type="submit" autocomplete="off" value="Subscribe">
</form>
signup.php --- Filters and sends user input to the database
<?php
//1---DATABASE CONNECTION---
$dbHost = "HOST";
$dbName = "DATABASE";
$dbUser = "USER";
$dbPassword = "PASSWORD";
$port = "PORT";
$charset = 'utf8mb4';
$options = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
];
$dsn = "mysql:host=$dbHost;dbname=$dbName;charset=$charset;port=$port";
try {
$pdo = new \PDO($dsn, $dbUser, $dbPassword, $options);
} catch (\PDOException $e) {
throw new \PDOException($e->getMessage(), (int)$e->getCode());
}
//1---END---
//2---Add to table: IPv4 ADDRESS, EMAIL, DATETIME, and ACODE---
//prevent direct url access of .php from users, routes to starting page
if(($_SERVER['REQUEST_METHOD'] == 'POST') == NULL) {
header("Location: email.html");
exit (0);
}
//trim spaces on ends of user email input
$Temail = trim($_POST['email']); //(on mobile, auto-complete often leaves a space at the end)
//allow international characters
if(preg_match("/^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,3})$^/", $Temail)) {
//prevents invalid email addresses
header("Location: invalid.html");
exit (0);
}
//Check Email Domain MX Record
$email_host = strtolower(substr(strrchr($Temail, "@"), 1));
if (!checkdnsrr($email_host, "MX")) {
header("Location: invalid.html");
exit (0);
}
//Prevent users from inputting a specific domain...like mine
$notallowed = [
'mydomain.com',
];
if (!in_array($email_host, $notallowed) == NULL) {
header("Location: notallowed.html");
exit (0);
}
//checks database to make sure the email is not a duplicate
$stmt1 = $pdo->prepare("SELECT email FROM emailTable WHERE email = ?");
$stmt1->execute([$Temail]);
if($stmt1->fetch()) { //prevents adding a duplicate email
header("Location: duplicate.html");
exit (0);
}
//send verification email using seperate php file
include_once 'vEmail.php';
//check to see if email could be put together
if(include_once 'vEmail' == NULL) {
header("Location: failure.html");
exit (0);
}
//set date and time
date_default_timezone_set('America/Los_Angeles');
$dateTime = date('Ymd-His', strtotime('NOW')); // ('Ymd-His' format and LA timezone are preferred)
//variable to store ipv4 address
$euserIP4 = $_SERVER['REMOTE_ADDR'];
//add all data to the database
$stmt2 = $pdo->prepare("INSERT INTO emailTable (IP4, datetime, email, acode) VALUES (:IP4, :datetime, :email, :acode)");
$stmt2->execute(['IP4' => $euserIP4, 'datetime' => $dateTime, 'email' => $Temail, 'acode' => $Acode]);
header("Location: success.html");
exit (0);
//2---END---
?>
vEmail.php --- include_once
in signup.php, sends verification email
<?php
//generate verification code
$Acode = bin2hex(random_bytes(30));
//send verification email w/ code
$emailbody =
"<html>
<body>
<table>
<tr>
<td>
<button><a href='https://www.MYDOMAIN.com/status/verify.php?acode=$Acode'>VERIFY</a></button>
</td>
</tr>
</table>
</body>
</html>";
$headers = "Reply-To: MY NAME <[email protected]>\r\n";
$headers .= "Return-Path: MY NAME <[email protected]>\r\n";
$headers .= "From: MY NAME <[email protected]>\r\n";
$headers .= "MIME-Version: 1.0\r\n";
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
$headers .= "X-Priority: 3\r\n";
//send email
mail($Temail, 'Confirm Your Email Subscription', $emailbody, $headers, '-f ' . '[email protected]');
?>
verify.php --- Attached to link that was sent in the verification email
<?php
//1---DATABASE CONNECTION---
$vHost = "";
$vName = "";
$vUser = "";
$vPassword = "";
$vPort = "";
$vCharset = "";
$vOptions = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
];
$vdsn = "mysql:host=$vHost;dbname=$vName;charset=$vCharset;port=$vPort";
try {
$vpdo = new \PDO($vdsn, $vUser, $vPassword, $vOptions);
} catch (\PDOException $ve) {
throw new \PDOException($ve->getMessage(), (int)$ve->getCode());
}
//1---END---
//2---VERIFICATION LINK---
//set timezone
date_default_timezone_set('America/Los_Angeles');
//prevent direct url access of .php from users, routes to starting page
if(isset($_GET['acode']) == NULL) {
header("Location: email.html");
exit (0);
}
//set verification code variable
$vAcode = $_GET['acode'];
//check if row still exists
$vStmt1 = $vpdo->prepare("SELECT verified, acode FROM emailTable WHERE acode = '$vAcode' LIMIT 1");
$vStmt1->execute();
if($vStmt1->rowCount() == NULL) {
//EXPIRED
header("Location: expired1.html");
exit (0);
}
//check if row is verified ('verified' set to 0)
$vStmt2 = $vpdo->prepare("SELECT verified, acode FROM emailTable WHERE verified = 0 AND acode = '$vAcode' LIMIT 1");
$vStmt2->execute();
if($vStmt2->rowCount() == NULL) { //if 'verified' is set to 1 already
header("Location: expired2.html");
exit (0);
}
//since 'verified' is set to 0,update verification status to 1
$vStmt3 = $vpdo->prepare("UPDATE emailTable SET verified = 1 WHERE acode = '$vAcode' LIMIT 1");
$vStmt3->execute();
//check if the 'verified' was updated correctly
if($vStmt3->fetch()) {
header("Location: failure.html");
exit (0);
}
//SUCCESS
header("Location: verified.html");
exit (0);
//2---END---
?>
Questions and Comments:
- I've included database connections for each php file, but I've found that some prefer to have a global config file for their connections. Why is that? Is it more efficient?
- In the original post, someone mentioned that the regex found in signup.php is missing the unicode flag. Could someone explain this, because I wasn't able to find anything on it.
- While storing IPv4 works well, I haven't been able to figure out how to correctly store IPv6 (as far as I know). I've tried this:
bin2hex(inet_pton($_SERVER['REMOTE_ADDR']));
but I couldn't figure out if the output was correct, because it looked nothing like an ipv6 address. Correct me if this looks usable. - I'm looking into PHPMailer rather than using the native mail() function. In the case of the above scripts, would this be recommended or is it more for bulk email sending?
Edit 1:
I've just realized that the email validation found in signup.php
allows an email address with spaces (ie: ohnothere are [email protected]). The previous version of the script, found at the link at the top of the post, prevented these sorts of addresses. Any ideas why that may be, or how to prevent that? I'd like to stay away from filter_var(FILTER_VALIDATE_EMAIL) since it cancels international characters. Will continue experimenting with it...