> 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> $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> $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; } }