death-notifier/src/main/php/Mailer.php

654 lines
20 KiB
PHP

<?php
namespace php;
use Exception;
use Monolog\Logger;
use PDO;
use PHPMailer\PHPMailer\Exception as MailerException;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
/**
* Queues up mails and sends them when appropriate.
*/
class Mailer
{
/**
* @var Logger The logger to use for logging.
*/
private Logger $logger;
/**
* @var array<string, array<string, mixed>> The configuration to use for mailing.
*/
private array $config;
/**
* @var PDO The database connection to interact with.
*/
private PDO $conn;
/**
* Constructs a new mailer.
*
* @param array<string, array<string, mixed>> $config the configuration to use for mailing
* @param Logger $logger the logger to use for logging
* @param PDO $conn the connection to the email database
*/
public function __construct(array $config, Logger $logger, PDO $conn)
{
$this->logger = $logger;
$this->config = $config;
$this->conn = $conn;
}
/**
* Populates the database with the necessary structures for emails.
*
* @return void
*/
public function install(): void
{
$this->conn->exec("CREATE TABLE email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL,
arg1 TEXT NOT NULL DEFAULT(''),
arg2 TEXT NOT NULL DEFAULT(''),
PRIMARY KEY (type, recipient, arg1, arg2));");
}
/**
* Queues an email to be sent.
*
* @param Email $email the email to queue
* @return Response a satisfied `Response` if the email was queued, or an unsatisfied `Response` otherwise
*/
public function queue_email(Email $email): Response
{
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1, arg2)
VALUES (:type, :recipient, :arg1, :arg2);");
$stmt->bindValue(":type", $email->type);
$stmt->bindValue(":recipient", $email->recipient);
$stmt->bindValue(":arg1", $email->arg1);
$stmt->bindValue(":arg2", $email->arg2);
return $stmt->execute() ? Response::satisfied() : Response::unsatisfied(null);
}
/**
* 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 (MailerException $exception) {
$this->logger->error("Failed to set 'from' address while processing queue.", ["cause" => $exception]);
$mailer->smtpClose();
}
// Get queue
$stmt = $this->conn->prepare("SELECT type, recipient, arg1, arg2 FROM email_tasks;");
$stmt->execute();
$email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Process queue
$stmt = $this->conn->prepare("DELETE FROM email_tasks
WHERE type=:type AND recipient=:recipient AND arg1=:arg1 AND arg2=:arg2;");
$stmt->bindParam(":type", $type);
$stmt->bindParam(":recipient", $recipient);
$stmt->bindParam(":arg1", $arg1);
$stmt->bindParam(":arg2", $arg2);
foreach ($email_tasks as ["type" => $type, "recipient" => $recipient, "arg1" => $arg1, "arg2" => $arg2]) {
try {
$email = Email::deserialize($type, $recipient, $arg1, $arg2);
$mailer->Subject = $email->getSubject();
$mailer->Body = $email->getBody($this->config);
try {
$mailer->addAddress($recipient);
$mailer->send();
} catch (MailerException $exception) {
$this->logger->error(
"Failed to send mail.",
["cause" => $exception, "email" => $email]
);
$mailer->getSMTPInstance()->reset();
}
$mailer->clearAddresses();
} catch (Exception $exception) {
$this->logger->error(
"Failed to send mail.",
["cause" => $exception]
);
$mailer->getSMTPInstance()->reset();
}
$stmt->execute();
}
}
}
/**
* A serializable email that can be queued in a database and can be sent.
*
* When serialized, an email is represented by the type of email and two arguments. When deserialized, an email can be
* constructed from those arguments, and the implementation returns a specific subject and message.
*/
abstract class Email
{
/**
* @var string A string identifying the type of email.
*/
public string $type;
/**
* @var string The intended recipient of the email.
*/
public string $recipient;
/**
* @var string The first argument to construct the email.
*/
public string $arg1 = "";
/**
* @var string The second argument to construct the email.
*/
public string $arg2 = "";
/**
* Returns the subject header of the email.
*
* @return string the subject header of the email
*/
public abstract function getSubject(): string;
/**
* Returns the body of the email.
*
* @param array<string, array<string, mixed>> $config the software configuration
* @return string the body of the email
*/
public abstract function getBody(array $config): string;
/**
* Deserializes an email back into an instance.
*
* Only types of emails that are known can be deserialized.
*
* @param string $type the type of email to deserialize
* @param string $recipient the intended recipient of the email
* @param string $arg1 the first argument to construct the email
* @param string $arg2 the second argument to construct the email
* @return Email a deserialized email
* @throws Exception if the `type` is not recognized
*/
public static function deserialize(string $type, string $recipient, string $arg1, string $arg2): Email
{
return match ($type) {
RegisterEmail::TYPE => new RegisterEmail($recipient, $arg1),
VerifyEmailEmail::TYPE => new VerifyEmailEmail($recipient, $arg1),
ChangedEmailEmail::TYPE => new ChangedEmailEmail($recipient, $arg1),
ChangedPasswordEmail::TYPE => new ChangedPasswordEmail($recipient),
ResetPasswordEmail::TYPE => new ResetPasswordEmail($recipient, $arg1),
NotifyArticleDeletedEmail::TYPE => new NotifyArticleDeletedEmail($recipient, $arg1),
NotifyArticleUndeletedEmail::TYPE => new NotifyArticleUndeletedEmail($recipient, $arg1),
NotifyStatusChangedEmail::TYPE => new NotifyStatusChangedEmail($recipient, $arg1, $arg2),
default => throw new Exception("Unknown email type $type."),
};
}
}
/**
* An email to be sent to a recently registered user, including instructions for email verification.
*/
class RegisterEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "register";
/**
* @var string The token to verify the email address with.
*/
public string $token;
/**
* Constructs an email to be sent to a recently registered user, including instructions for email verification.
*
* @param string $recipient the intended recipient of the email
* @param string $token the token to verify the email address with
*/
public function __construct(string $recipient, string $token)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->token = $token;
$this->arg1 = $token;
}
public function getSubject(): string
{
return "Welcome to Death Notifier";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return
"You have successfully created an account with Death Notifier. " .
"Welcome!" .
"\n\n" .
"Until you verify your email address, you will not receive any notifications. " .
"You can verify your email address by clicking the link below. " .
"This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." .
"\n" .
"Verify: $verify_path" .
"\n\n" .
"If you did not create this account, you can delete the account. " .
"Go to the Death Notifier website, reset your password, log in, and delete the account." .
"\n\n" .
$base_path;
}
}
/**
* An email to help a user verify their email address.
*/
class VerifyEmailEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "verify-email";
/**
* @var string The token to verify the email address with.
*/
public string $token;
/**
* Constructs an email to help a user verify their email address.
*
* @param string $recipient the intended recipient of the email
* @param string $token the token to verify the email address with
*/
public function __construct(string $recipient, string $token)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->token = $token;
$this->arg1 = $token;
}
public function getSubject(): string
{
return "Verify your email address";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return
"You requested a new verification link for your Death Notifier account. " .
"You can verify your email address by clicking the link below. " .
"This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes. " .
"Until you verify your email address, you will not receive any notifications." .
"\n" .
"Verify: $verify_path" .
"\n\n" .
$base_path;
}
}
/**
* An email informing a user that their email has been changed, and needs verification.
*/
class ChangedEmailEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "changed-email";
/**
* @var string The token to verify the email address with.
*/
public string $token;
/**
* Constructs an email informing a user that their email has been changed, and needs verification.
*
* @param string $recipient the intended recipient of the email
* @param string $token the token to verify the email address with
*/
public function __construct(string $recipient, string $token)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->token = $token;
$this->arg1 = $token;
}
public function getSubject(): string
{
return "Verify your new email address";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return
"You changed the email address of your Death Notifier account. " .
"Until you verify your email address, you will not receive any notifications. " .
"You can verify your new email address by clicking the link below. " .
"This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." .
"\n" .
"Verify: $verify_path" .
"\n\n" .
$base_path;
}
}
/**
* An email informing a user that their password has been changed.
*/
class ChangedPasswordEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "changed-password";
/**
* Constructs an email informing a user that their email has been changed, and needs verification.
*
* @param string $recipient the intended recipient of the email
*/
public function __construct(string $recipient)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
}
public function getSubject(): string
{
return "Your password has been changed";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
return
"You changed the password of your Death Notifier account." .
"\n\n" .
"If you did not change the password of your account, go to the Death Notifier website and use the forgot " .
"password option to change your password back." .
"\n\n" .
$base_path;
}
}
/**
* An email to help a user reset their password.
*/
class ResetPasswordEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "reset-password";
/**
* @var string The token to reset the password with.
*/
public string $token;
/**
* Constructs an email to help a user reset their password.
*
* @param string $recipient the intended recipient of the email
* @param string $token the token to reset the password with
*/
public function __construct(string $recipient, string $token)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->token = $token;
$this->arg1 = $token;
}
public function getSubject(): string
{
return "Reset your password";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
$verify_path =
"$base_path?action=reset-password&email=" . rawurlencode($this->recipient) . "&token=$this->token";
return
"You requested a password reset link for your Death Notifier account. " .
"You can choose a new password by clicking the link below. " .
"This link expires after " . UserManager::MINUTES_VALID_PASSWORD_RESET . " minutes." .
"\n" .
"Reset password: $verify_path" .
"\n\n" .
"If you did not request a new password, you can safely ignore this message." .
"\n\n" .
$base_path;
}
}
/**
* An email to inform a user that a tracked article has been deleted.
*/
class NotifyArticleDeletedEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "notify-article-deleted";
/**
* @var string The name of the article that was deleted.
*/
public string $name;
/**
* Constructs an email to inform a user that a tracked article has been deleted.
*
* @param string $recipient the intended recipient of the email
* @param string $name the name of the article that was deleted
*/
public function __construct(string $recipient, string $name)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->name = $name;
$this->arg1 = $name;
}
public function getSubject(): string
{
return "$this->name article has been deleted";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
return
"The Wikipedia article about $this->name has been deleted. " .
"Death Notifier is now unable to send you a notification if $this->name dies. " .
"If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " .
"article, and you will receive another notification." .
"\n\n" .
"You are receiving this message because of the preferences in your Death Notifier account. " .
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
"preferences." .
"\n\n" .
$base_path;
}
}
/**
* An email to inform a user that a tracked article has been re-created.
*/
class NotifyArticleUndeletedEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "notify-article-undeleted";
/**
* @var string The name of the article that was re-created.
*/
public string $name;
/**
* Constructs an email to inform a user that a tracked article has been re-created.
*
* @param string $recipient the intended recipient of the email
* @param string $name the name of the article that was re-created
*/
public function __construct(string $recipient, string $name)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->name = $name;
$this->arg1 = $name;
}
public function getSubject(): string
{
return "$this->name article has been re-created";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
return
"The Wikipedia article about $this->name has been re-created. " .
"Death Notifier will once again track the article and notify you if $this->name dies." .
"\n\n" .
"You are receiving this message because of the preferences in your Death Notifier account. " .
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
"preferences." .
"\n\n" .
$base_path;
}
}
/**
* An email to inform a user a tracker person's status has changed.
*/
class NotifyStatusChangedEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "notify-status-changed";
/**
* @var string The name of the person whose status has changed.
*/
public string $name;
/**
* @var string The new status of the person.
*/
public string $new_status;
/**
* Constructs an email to inform a user someone has died.
*
* @param string $recipient the intended recipient of the email
* @param string $name the name of the person who died
* @param string $new_status the new status of the person
*/
public function __construct(string $recipient, string $name, string $new_status)
{
$this->type = self::TYPE;
$this->recipient = $recipient;
$this->name = $name;
$this->arg1 = $name;
$this->new_status = $new_status;
$this->arg2 = $new_status;
}
public function getSubject(): string
{
return "$this->name may be $this->new_status";
}
public function getBody(array $config): string
{
$base_path = $config["server"]["base_path"];
return
"Someone has edited Wikipedia to state that $this->name is $this->new_status. " .
"For more information, read their Wikipedia article at " .
"https://en.wikipedia.org/wiki/" . rawurlencode($this->name) .
"\n\n" .
"You are receiving this message because of the preferences in your Death Notifier account. " .
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
"preferences." .
"\n\n" .
$base_path;
}
}