Implement queued email tasks

This commit is contained in:
Florine W. Dekker 2022-08-23 13:32:53 +02:00
parent 2c98d4e31e
commit abdc808e4b
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
8 changed files with 198 additions and 76 deletions

View File

@ -1,7 +1,7 @@
{
"name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.",
"version": "0.0.18",
"version": "0.0.19",
"type": "project",
"license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier",

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.0.18",
"version": "0.0.19",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

@ -37,14 +37,15 @@ $conn = Database::connect($config["database"]["filename"]);
$mediawiki = new Mediawiki($logger->withName("Mediawiki"));
$user_manager = new UserManager($logger->withName("UserManager"), $conn);
$tracking_manager = new TrackingManager($logger->withName("TrackingManager"), $mediawiki, $conn);
$mailer = new Mailer($logger->withName("Mailer"), $config);
$mailer = new Mailer($logger->withName("Mailer"), $config, $conn);
// Create db if it does not exist
if (!$db_exists) {
$logger->warning("Database does not exist. Creating new database at '" . $config["database"]["filename"] . "'.");
$logger->warning("Database does not exist. Creating new database at '{$config["database"]["filename"]}'.");
$user_manager->install();
$tracking_manager->install();
$mailer->install();
}
// Start session
@ -205,7 +206,7 @@ if (isset($_POST["action"])) {
break;
default:
$response = new Response(
payload: ["target" => null, "message" => "Unknown POST action '" . $_POST["action"] . "'."],
payload: ["target" => null, "message" => "Unknown POST action '{$_POST["action"]}'."],
satisfied: false
);
break;
@ -234,7 +235,7 @@ if (isset($_POST["action"])) {
break;
default:
$response = new Response(
payload: ["target" => null, "message" => "Unknown GET action '" . $_GET["action"] . "'."],
payload: ["target" => null, "message" => "Unknown GET action '{$_GET["action"]}'."],
satisfied: false
);
}
@ -245,12 +246,17 @@ if (isset($_POST["action"])) {
if (!hash_equals($config["admin"]["cli_secret"], $argv[2]))
exit("Incorrect value for 'cli_secret'.");
if ($argv[1] === "update-all-trackings") {
$logger->info("Updating all trackings.");
$tracking_manager->update_trackings($tracking_manager->list_all_unique_person_names());
exit("Successfully updated all trackings.");
} else {
exit("Unknown CLI action '" . $argv[1] . "'.");
switch ($argv[1]) {
case "update-all-trackings":
$logger->info("Updating all trackings.");
$tracking_manager->update_trackings($tracking_manager->list_all_unique_person_names());
exit("Successfully updated all trackings.");
case "process-email-queue":
$logger->info("Processing email queue.");
$mailer->process_queue();
exit("Successfully processed email queue.");
default:
exit("Unknown CLI action '$argv[1]'.");
}
} else {
// No action given, nothing done, so that's a success

View File

@ -18,7 +18,7 @@ class Database
*/
public static function connect(string $filename): PDO
{
return new PDO("sqlite:" . $filename, options: array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
return new PDO("sqlite:$filename", options: array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
}

View File

@ -3,13 +3,14 @@
namespace php;
use Monolog\Logger;
use PDO;
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
/**
* Sends mails.
* Queues up mails and sends them when appropriate.
*/
class Mailer
{
@ -21,6 +22,10 @@ class Mailer
* @var array The configuration to use for mailing.
*/
private array $config;
/**
* @var PDO The database connection to interact with.
*/
private PDO $conn;
/**
@ -29,77 +34,188 @@ class Mailer
* @param Logger $logger the logger to use for logging
* @param array $config the configuration to use for mailing
*/
public function __construct(Logger $logger, array $config)
public function __construct(Logger $logger, array $config, PDO $conn)
{
$this->logger = $logger;
$this->config = $config;
$this->conn = $conn;
}
public function send_registration_email(string $email, string $token): Response
/**
* Populates the database with the necessary structures for emails.
*
* @return void
*/
public function install(): void
{
// TODO: Send mails asynchronously
// TODO: Create cron job / task lists for emails
// TODO: Tell user what to do
return $this->send_email(
[$email],
"Created account for Death Notifier",
"You created an account at Death Notifier. Please verify your email address by going to " .
$this->config["server"]["base_path"] . "?action=verify-email&email=" . urlencode($email) . "&token=" . $token .
" Until you verify your email address, you will not receive any notifications."
);
$this->conn->exec("CREATE TABLE email_tasks(user_uuid text not null,
email_type text not null,
PRIMARY KEY (user_uuid, email_type));");
}
// TODO: Also send it to old address
public function send_email_verification(string $old_email, string $new_email, string $token): Response
/**
* Queues an email to be sent for a newly registered user.
*
* @param string $uuid the UUID of the newly registered user
* @return Response an empty satisfied response
*/
public function queue_registration(string $uuid): Response
{
return $this->send_email(
[$new_email, $old_email],
"Verify your email address",
"Your email address for the Death Notifier has been changed. Please verify your email address by going " .
"to " . $this->config["server"]["base_path"] . "?action=verify-email&email=" . urlencode($new_email) . "&token=" .
$token . " Until you verify your email address, you will not receive any notifications."
);
}
private function send_email(array $destinations, string $subject, string $body): Response
{
$mail = new PHPMailer();
$mail->IsSMTP();
$mail->CharSet = "UTF-8";
$mail->SMTPDebug = SMTP::DEBUG_OFF;
$mail->Host = $this->config["mail"]["host"];
$mail->SMTPAuth = true;
$mail->Port = $this->config["mail"]["port"];
$mail->Username = $this->config["mail"]["username"];
$mail->Password = $this->config["mail"]["password"];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
try {
$mail->setFrom($this->config["mail"]["username"], $this->config["mail"]["from_name"]);
foreach ($destinations as $destination)
$mail->addAddress($destination);
} catch (Exception $exception) {
$this->logger->warning("Failed to set 'from' and 'to' fields when sending email.", [$exception]);
return new Response(
payload: ["target" => null, "message" => "Unexpected error. Please try again later."],
satisfied: false
);
}
$mail->Subject = $subject;
$mail->Body = $body;
try {
$mail->send();
} catch (Exception $exception) {
$this->logger->warning("Failed to send test email.", [$exception]);
return new Response(
payload: ["target" => null, "message" => "Unexpected error. Please try again later."],
satisfied: false
);
}
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (user_uuid, email_type)
VALUES (:uuid, 'register');");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
return new Response(payload: null, satisfied: true);
}
/**
* Creates the email subject and body to be sent to a newly registered user.
*
* @param string $email the email address of the newly registered user
* @param string $token the email address verification token of the newly registered user
* @return string[] the subject and body of the email
*/
private function create_register_email(string $email, string $token): array
{
$verify_path = "{$this->config["server"]["base_path"]}?action=verify-email&email=" . urlencode($email) . "&token=$token";
// TODO: Nicer message!
// TODO: What if user did not create account?
return [
"Created account for Death Notifier",
"An account has been created for $email for Death Notifier. " .
"Until you verify your email address, you will not receive any notifications. " .
"You can verify your email address by clicking the link below." .
"\n\n" .
"Verify: $verify_path"
];
}
/**
* Queues an email to be sent to a user who should verify their email address.
*
* @param string $uuid the UUID of the user who should verify their email address
* @return Response an empty satisfied response
*/
public function queue_verification(string $uuid): Response
{
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (user_uuid, email_type)
VALUES (:uuid, 'verify');");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
return new Response(payload: null, satisfied: true);
}
/**
* Creates the email subject and body to be sent to a user who should verify their email address.
*
* @param string $email the email address of the user
* @param string $token the email address verification token of the user
* @return string[] the subject and body of the email
*/
private function create_verify_email(string $email, string $token): array
{
$base_path = $this->config["server"]["base_path"];
$verify_path = "$base_path?action=verify-email&email=" . urlencode($email) . "&token=$token";
return [
"Verify your email address",
"Your email address for the Death Notifier has been changed.
Please verify your email address by going to $verify_path.
Until you verify your email address, you will not receive any notifications."
];
}
/**
* Sends all emails in the queue.
*
* @return void
*/
public function process_queue(): void
{
// Open mailer
$mailer = new PHPMailer();
$mailer->IsSMTP();
$mailer->CharSet = "UTF-8";
$mailer->SMTPAuth = true;
$mailer->SMTPDebug = SMTP::DEBUG_OFF;
$mailer->SMTPKeepAlive = true;
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mailer->Host = $this->config["mail"]["host"];
$mailer->Port = $this->config["mail"]["port"];
$mailer->Username = $this->config["mail"]["username"];
$mailer->Password = $this->config["mail"]["password"];
try {
$mailer->setFrom($this->config["mail"]["username"], $this->config["mail"]["from_name"]);
} catch (Exception $exception) {
$this->logger->error("Failed to set 'from' address while processing queue.", ["cause" => $exception]);
$mailer->smtpClose();
}
// Get queue
$stmt = $this->conn->prepare("SELECT user_uuid, email_type FROM email_tasks;");
$stmt->execute();
$email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Process queue
$stmt_user = $this->conn->prepare("SELECT email, email_is_verified, email_verification_token
FROM users
WHERE uuid=:uuid;");
$stmt_user->bindParam(":uuid", $uuid);
$stmt_done = $this->conn->prepare("DELETE FROM email_tasks WHERE user_uuid=:uuid AND email_type=:type;");
$stmt_done->bindParam(":uuid", $uuid);
$stmt_done->bindParam(":type", $email_type);
foreach ($email_tasks as ["user_uuid" => $uuid, "email_type" => $email_type]) {
$stmt_user->execute();
$user = $stmt_user->fetch(PDO::FETCH_ASSOC);
switch ($email_type) {
case "register":
[$mailer->Subject, $mailer->Body] =
$this->create_register_email($user["email"], $user["email_verification_token"]);
try {
$mailer->addAddress($user["email"]);
$mailer->send();
$stmt_done->execute();
} catch (Exception $exception) {
$this->logger->error(
"Failed to send register mail.",
["cause" => $exception, "recipient" => $user["email"]]
);
$mailer->getSMTPInstance()->reset();
}
break;
case "verify":
if ($user["email_verified"]) {
$stmt_done->execute();
break;
}
[$mailer->Subject, $mailer->Body] =
$this->create_verify_email($user["email"], $user["email_verification_token"]);
try {
// TODO: Also send to old email address (by storing old email in `users` table?)
$mailer->addAddress($user["email"]);
$mailer->send();
$stmt_done->execute();
} catch (Exception $exception) {
$this->logger->error(
"Failed to send verify mail.",
["cause" => $exception, "recipient" => $user["email"]]
);
$mailer->getSMTPInstance()->reset();
}
break;
}
$mailer->clearAddresses();
}
}
}

View File

@ -140,7 +140,7 @@ class UserManager
// Respond
$this->conn->commit();
$mailer->send_registration_email($email, $email_verification_token);
$mailer->queue_registration($uuid);
return new Response(payload: null, satisfied: true);
}
@ -297,7 +297,7 @@ class UserManager
// Respond
$this->conn->commit();
$mailer->send_email_verification($email_old, $email, $email_verification_token);
$mailer->queue_verification($uuid);
return new Response(payload: null, satisfied: true);
}