sbgg.jetzt/src/newsletter.php

1015 lines
26 KiB
PHP

<?php
declare(strict_types = 1);
namespace Kimendisch\Sbgg_Jetzt;
use Flake\Dat;
use Flake\Id64;
use Flake\Project;
use Flake\Error;
use Flake\Request;
use PHPMailer\PHPMailer\PHPMailer;
class Newsletter {
/**
* Static content.
*/
private const STATIC_CONTENT = [
"text_sourcecode" => [
"de" => "Quellcode",
"en" => "Source Code"
],
"text_unsubscribe" => [
"de" => "Abbestellen",
"en" => "Unsubscribe"
],
"text_verify" => [
"de" => "Verifizieren",
"en" => "Verify"
],
"text_imprint" => [
"de" => "Impressum",
"en" => "Imprint"
],
"link_imprint" => [
"de" => "https://www.tjdev.de/impressum",
"en" => "https://www.tjdev.de/imprint"
],
"text_privacy_policy" => [
"de" => "Datenschutz&shy;erklärung",
"en" => "Privacy Policy"
],
"link_privacy_policy" => [
"de" => "https://www.tjdev.de/datenschutz",
"en" => "https://www.tjdev.de/privacy"
],
];
/**
* @var array $pending_render List of templates which are currently rendering.
*/
private static array $pending_render = [];
/**
* Start a newsletter subscription verification process.
*
* @param string $mail_address Recipient mail address.
* @param string $language Desired newsletter language.
*/
public static function verify(string $mail_address, string $language): void {
// OPEN DATABASE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// obtain handle
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// MAYBE INITIALIZE DATABASE //
self::db_init(db: $newsletter_db);
// VERIFY LOGIC //
// cleanup of verify list
self::verify_list_cleanup(db: $newsletter_db);
// decide whether verification should be attempted
$do_verify = self::verify_decide(db: $newsletter_db, mail_address: $mail_address, language: $language);
// read current verify key
if($do_verify) $verify_key = $newsletter_db->get(["verify_list", sha1($mail_address), "verify_key"]);
// FINALIZE //
// close database
$newsletter_db->write_close();
// maybe send verify mail
if($do_verify){
$content = self::content_file_read("0000-00-00-verify");
self::send(mail_address: $mail_address, content: $content, dataset: [
"language" => $language,
"verify_key" => $verify_key
]);
}
// release runlock
ignore_user_abort($old_ignore_user_abort);
}
/**
* HELPER: Remove stale entries in verify list.
*
* @param Dat $db Newsletter database object.
*/
private static function verify_list_cleanup(Dat $db): void {
// GET FULL DATABASE //
$db_verify_list = $db->get("verify_list");
$time = time();
// REMOVE STALE ATTEMPT TIMESTAMPS //
foreach($db_verify_list as $one_key => $one_dataset){
$db_verify_list[$one_key]["attempt_list"] = array_values(array_filter($one_dataset["attempt_list"], function($one_attempt) use ($time): bool {
// only keep if younger than 24h
return ($one_attempt + (24 * 3600) > $time);
}));
}
// REMOVE STALE DATASETS //
$db_verify_list = array_filter($db_verify_list, function($one_dataset): bool {
// check whether there are recent attempts
if(sizeof($one_dataset["attempt_list"]) > 0) return true;
// no reason for this dataset to stay
return false;
});
// SET NEW VALUES //
$db->set("verify_list", $db_verify_list);
}
/**
* HELPER: Manage verify list and decide whether a verify attempt should be made.
*
* @param Dat $db Newsletter database object.
* @param string $mail_address Recipient mail address.
* @param string $language Desired newsletter language.
*
* @return bool Whether verify attempt should be made.
*/
private static function verify_decide(Dat $db, string $mail_address, string $language): bool {
// CHECK MEMBER STATUS //
if($db->get(["list", $mail_address]) !== null) return false;
// MAYBE CREATE DATASET //
$verify_list_entry_key = ["verify_list", sha1($mail_address)];
if($db->get($verify_list_entry_key) === null){
$db->set($verify_list_entry_key, [
"attempt_list" => [],
]);
}
// CHECK COOLDOWN //
// get previous attempts
$attempt_list = $db->get($verify_list_entry_key)["attempt_list"];
$attempt_count = sizeof($attempt_list);
// get last attempt timestamp
$last_attempt_timestamp = end($attempt_list);
// get cooldown duration from attempt count
$cooldown_duration = ([
0 => 0,
1 => (5 * 60),
2 => (30 * 60),
3 => (2 * 3600),
4 => (6 * 3600),
])[$attempt_count] ?? (12 * 3600);
// check whether still within cooldown
$time = time();
if($last_attempt_timestamp + $cooldown_duration > $time) return false;
// UPDATE DATASET //
// get old values
$dataset = $db->get($verify_list_entry_key);
// generate new verify key
$dataset["verify_key"] = Id64::new(length: 16);
// add attempt
$dataset["attempt_list"][] = $time;
// update language
$dataset["language"] = $language;
// set new values
$db->set($verify_list_entry_key, $dataset);
// SEND VERIFY MAIL //
return true;
}
/**
* Check a verify key and add a member to the newsletter.
*
* @param string $mail_address Member mail address.
* @param string $verify_key User-provided verify key.
*
* @return bool Whether member has successfully been subscribed.
*/
public static function subscribe(string $mail_address, string $verify_key): bool {
// OPEN DATABASE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// obtain handle
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// MAYBE INITIALIZE DATABASE //
self::db_init(db: $newsletter_db);
// VERIFY LOGIC //
// cleanup of verify list
self::verify_list_cleanup(db: $newsletter_db);
// find dataset in verify list
$verify_list_dataset = $newsletter_db->get(["verify_list", sha1($mail_address)]);
$success = ($verify_list_dataset !== null);
// maybe check verify key
$success = ($success and $verify_list_dataset["verify_key"] === $verify_key);
// MAYBE ADD MEMBER //
if($success){
$entry_key = ["list", $mail_address];
// remove from verify list
$newsletter_db->unset(["verify_list", sha1($mail_address)]);
// generate unsubscribe key
$unsubscribe_key = Id64::new(length: 16);
// only allow new entries
$success = ($newsletter_db->get($entry_key) === null);
// maybe add to database
if($success){
$newsletter_db->set($entry_key, [
"unsubscribe_key" => $unsubscribe_key,
"language" => $verify_list_dataset["language"],
]);
}
}
// FINALIZE //
// close database
$newsletter_db->write_close();
// maybe send welcome mail
if($success) self::send_one(mail_address: $mail_address, content_name: "0000-00-00-welcome");
// release runlock
ignore_user_abort($old_ignore_user_abort);
// return
return $success;
}
/**
* Remove a mail address from the mailing list.
*
* @param string $mail_address Member mail address.
* @param string $unsubscribe_key Unsubscribe key (used for authorization).
*
* @return bool Whether a dataset has successfully been removed.
*/
public static function unsubscribe(string $mail_address, string $unsubscribe_key): bool {
// OPEN DATABASE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// obtain handle
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// MAYBE INITIALIZE DATABASE //
self::db_init(db: $newsletter_db);
// UPDATE DATABASE //
$entry_key = ["list", $mail_address];
// check whether mail address is member of newsletter
$dataset = $newsletter_db->get($entry_key);
$is_member = ($dataset !== null);
// validate unsubscribe key
$unsubscribe_key_valid = (($dataset["unsubscribe_key"] ?? null) === $unsubscribe_key);
// maybe remove from database
$has_removed = false;
if($is_member and $unsubscribe_key_valid){
$newsletter_db->unset($entry_key);
$has_removed = true;
}
// FINALIZE //
// close database
$newsletter_db->write_close();
// release runlock
ignore_user_abort($old_ignore_user_abort);
// return
return $has_removed;
}
/**
* Check whether a mail address is contained in the newsletter list.
*
* @param string $mail_address Mail address to search for.
*
* @return bool Whether this mail address is a newsletter member.
*/
public static function is_member(string $mail_address): bool {
// TRY LOADING FROM DATABASE //
// open
$old_ignore_user_abort = (bool)ignore_user_abort(true);
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ);
// read
$dataset = $newsletter_db->get(["list", $mail_address]);
// close
$newsletter_db->close();
ignore_user_abort($old_ignore_user_abort);
// CHECK //
return ($dataset !== null);
}
/**
* Determine number of members.
*
* @return int Number of members in newsletter list.
*/
public static function member_count(): int {
// LOAD LIST FROM DATABASE //
// open
$old_ignore_user_abort = (bool)ignore_user_abort(true);
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ);
// read
$member_list = $newsletter_db->get("list") ?? [];
// close
$newsletter_db->close();
ignore_user_abort($old_ignore_user_abort);
// RETURN SIZE //
return sizeof($member_list);
}
/**
* Send content to one newsletter member.
*
* @param string $mail_address Member mail address.
* @param string $content_name Name of content to send.
*/
public static function send_one(string $mail_address, string $content_name): void {
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// read content
$content = self::content_file_read($content_name);
// send content
self::send(mail_address: $mail_address, content: $content);
// release runlock
ignore_user_abort($old_ignore_user_abort);
}
/**
* Send content to all newsletter members.
*
* @param string $content_name Name of content to send.
*/
public static function send_all(string $content_name): void {
// LOAD LIST FROM DATABASE //
// open database
$old_ignore_user_abort = (bool)ignore_user_abort(true);
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ);
// read list
$member_list = $newsletter_db->get("list") ?? [];
// close database
$newsletter_db->close();
// SEND CONTENT //
// read content
$content = self::content_file_read($content_name);
// iterate over all list members
foreach($member_list as $one_member_mail_address => $one_member_dataset){
// send to this member
self::send(mail_address: $one_member_mail_address, content: $content, dataset: $one_member_dataset);
}
// FINALIZE //
// release runlock
ignore_user_abort($old_ignore_user_abort);
}
/**
* HELPER: Initialize the database with empty values.
*
* @param Dat $db Newsletter database object.
*/
private static function db_init(Dat $db): void {
// verify list
if(!is_array($db->get("verify_list"))){
$db->set("verify_list", []);
}
// member list
if(!is_array($db->get("list"))){
$db->set("list", []);
}
// job queue
if(!is_array($db->get("queue"))){
$db->set("queue", []);
}
}
/**
* HELPER: List all available content files.
*
* @return array List of content file names.
*/
private static function content_file_list(): array {
// GET AVAILABLE CONTENT FILES //
$indir = scandir("./newsletter/content");
$content_file_list = [];
foreach($indir as $one_element){
// only get files
if($one_element === "." or $one_element === "..") continue;
$path = "./newsletter/content/" . $one_element;
if(!is_file($path)) continue;
// check file name format
if(!preg_match("/^\d{4}-\d{2}-\d{2}(-[a-z0-9]+)+\.data\.php$/", $one_element)) continue;
// add to list
$content_file_list[] = $one_element;
}
// return list
return $content_file_list;
}
/**
* HELPER: List all available content names.
*
* @return array List of content names.
*/
public static function content_list(): array {
// get available content files
$content_file_list = self::content_file_list();
// remove file extension
$content_name_list = [];
foreach($content_file_list as $one_content_file){
$content_name_list[] = substr($one_content_file, 0, strlen($one_content_file) - strlen(".data.php"));
}
// return list
return $content_name_list;
}
/**
* HELPER: Read one content data from file.
*
* @param string $name Name of the content to read.
*
* @return array Content data.
*/
public static function content_file_read(string $name): array {
// GET AVAILABLE CONTENT FILES //
$content_file_list = self::content_file_list();
// PARSE FILE //
// build content file name
$file_name = $name . ".data.php";
// make sure it exists
if(!in_array($file_name, $content_file_list)) Error::error(message: "Unknown content file name", data: ["content_file_list" => $content_file_list, "name" => $name, "file_name" => $file_name]);
// build full file path
$path = "./newsletter/content/" . $file_name;
// run isolated in own function scope
$content_from_file = (function(){
return require(func_get_arg(0));
})($path);
// add content name
$content_from_file["name"] = [
"de" => $name,
"en" => $name,
];
// add static content
return array_merge(self::STATIC_CONTENT, $content_from_file);
}
/**
* HELPER: Render content definition to html body.
*
* @param array $content Content data.
* @param array $dataset Member dataset.
* @param string $root Root template.
*
* @return string Html body.
*/
private static function content_render(array $content, array $dataset, string $root = "base"): string {
// START WITH RAW TEMPLATE //
// read
$html = self::template_read(name: $root);
// remember that our template is not done rendering yet
self::$pending_render[] = $root;
// SUBSTITUTE PLACEHOLDERS //
$callback = function(array $matches) use ($content, $dataset): string {
$key = $matches["key"];
$value = $matches["value"];
$value2 = $matches["value2"] ?? "";
// content value
if($key === ""){
if(!isset($content[$value][$dataset["language"]])) Error::error(message: "Unable to substitute content placeholder", data: ["matches" => $matches, "content" => $content, "dataset" => $dataset]);
return $content[$value][$dataset["language"]];
}
// template
if($key === "template"){
if(in_array($value, self::$pending_render)) Error::error(message: "Recursive template rendering", data: ["pending_render" => self::$pending_render, "template" => $value]);
return self::content_render(content: $content, dataset: $dataset, root: $value);
}
// conditional template
if($key === "template-if-isset-dataset"){
if(!isset($dataset[$value])) return "";
if(in_array($value2, self::$pending_render)) Error::error(message: "Recursive template rendering", data: ["pending_render" => self::$pending_render, "template" => $value2]);
return self::content_render(content: $content, dataset: $dataset, root: $value2);
}
// dataset value
if($key === "dataset"){
if(!isset($dataset[$value])) Error::error(message: "Unable to substitute dataset placeholder", data: ["matches" => $matches, "dataset" => $dataset]);
return $dataset[$value];
}
// constant
if($key === "const"){
if($value === "version") return Project::version();
if($value === "url_prefix") return ("http" . (Request::has_ssl() ? "s" : "") . "://" . Request::domain_raw_full());
Error::error(message: "Unknown constant name", data: ["value" => $value]);
}
// unknown key
Error::error(message: "Unknown placeholder key", data: ["key" => $key, "value" => $value]);
};
$html = preg_replace_callback("/(?:{{)(?:(?<key>[0-9a-z-_\.]+)\:)?(?<value>[0-9a-z-_\.]+)(?:\,(?<value2>[0-9a-z-_\.]+))?(?:}})/", $callback, $html);
// DONE //
// remove from waiting list
unset(self::$pending_render[array_search($root, self::$pending_render)]);
// return
return $html;
}
/**
* HELPER: Render content definition to html body as preview.
*
* @param array $content Content data.
* @param string $language Preview language.
*
* @return string Html body.
*/
public static function content_render_preview(array $content, string $language): string {
// PREPARE A FAKE DATASET //
// defaults
$dataset = [
"language" => $language,
"unsubscribe_key" => Id64::new(length: 16),
"mail_address" => "mail@example.com",
"mail_address_urlencoded" => urlencode("mail@example.com"),
];
// content: `verify`
if($content["name"]["de"] === "0000-00-00-verify"){
unset($dataset["unsubscribe_key"]);
$dataset["verify_key"] = Id64::new(length: 16);
}
// RENDER //
return self::content_render(content: $content, dataset: $dataset);
}
/**
* HELPER: Read template file.
*
* @param string $name Name of template to read.
*
* @return string Template.
*/
private static function template_read(string $name): string {
// GET AVAILABLE TEMPLATE FILES //
$indir = scandir("./newsletter/template");
$template_file_list = [];
foreach($indir as $one_element){
// only get files
if($one_element === "." or $one_element === "..") continue;
$path = "./newsletter/template/" . $one_element;
if(!is_file($path)) continue;
// check extension
if(pathinfo($path)["extension"] !== "html") continue;
// add to list
$template_file_list[] = $one_element;
}
// READ FILE //
// build template file name
$file_name = $name . ".html";
// make sure it exists
if(!in_array($file_name, $template_file_list)){
Error::error(message: "Unknown template file name", data: ["template_file_list" => $template_file_list, "name" => $name, "file_name" => $file_name]);
}
// build full file path
$path = "./newsletter/template/" . $file_name;
// return contents
return file_get_contents($path);
}
/**
* HELPER: Send a mail to one newsletter member.
*
* @param string
* @param array $content Mail content.
* @param ?array $dataset Previously read dataset for this member
* (this makes it possible to use one database read for multiple sends).
*/
private static function send(string $mail_address, array $content, ?array $dataset = null): void {
// MAYBE OBTAIN MEMBER DATASET //
if($dataset === null){
// open
$old_ignore_user_abort = (bool)ignore_user_abort(true);
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ);
// read
$dataset = $newsletter_db->get(["list", $mail_address]);
// close
$newsletter_db->close();
ignore_user_abort($old_ignore_user_abort);
}
// validate
if(!is_array($dataset)){
Error::error(message: "Mail address not contained in database", data: ["mail_address" => $mail_address, "dataset" => $dataset]);
}
// add mail address to dataset
$dataset["mail_address"] = $mail_address;
$dataset["mail_address_urlencoded"] = urlencode($mail_address);
// VALIDATE CONTENT //
// check whether subject is set
if(!isset($content["subject"])){
Error::error(message: "Missing content key: 'subject'", data: ["content" => $content]);
}
// check whether main is set
if(!isset($content["main"])){
Error::error(message: "Missing content key: 'main'", data: ["content" => $content]);
}
// RENDER CONTENT //
$body = self::content_render(content: $content, dataset: $dataset);
// ADD JOB //
// get subject
$subject = $content["subject"][$dataset["language"]];
// add to queue
self::queue_job_add(type: "mail_send_html", data: [
"mail_address" => $mail_address,
"subject" => $subject,
"body" => $body,
]);
}
/**
* HELPER: Add a job to the queue.
*
* @param string $type Job type.
* @param array $data Job data.
*/
private static function queue_job_add(string $type, array $data = []): void {
// OPEN DATABASE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// obtain handle
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// MAYBE INITIALIZE DATABASE //
self::db_init(db: $newsletter_db);
// UPDATE DATABASE //
// get existing job ids
$used_job_id_list = array_keys($newsletter_db->get("queue"));
// generate new unique job id
$job_id = Id64::unique(length: 32, list: $used_job_id_list);
// add new job
$newsletter_db->set(["queue", $job_id], [
"type" => $type,
"data" => $data,
"retry_count" => 0,
"lock" => [
"held" => false,
"time" => 0,
],
]);
// FINALIZE //
// close database
$newsletter_db->write_close();
// release runlock
ignore_user_abort($old_ignore_user_abort);
}
/**
* HELPER: Close the http connection to the client.
*/
public static function api_helper_http_close_connection(): void {
// make sure cgi process is not killed
ignore_user_abort(true);
// make sure client stops listening
header("Connection: close");
header("Content-Encoding: none");
// current buffer is full content
header("Content-Length: " . ob_get_length());
// flush ob to cgi buffer
ob_end_flush();
// flush cgi buffer to client
flush();
}
/**
* CALLBACK: Client connection has been closed, execute queued jobs now.
*/
public static function queue_work(): void {
// INITIALIZE //
// acquire runlock
$old_ignore_user_abort = (bool)ignore_user_abort(true);
// get list of available job ids
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ);
$job_id_list = array_keys($newsletter_db->get("queue") ?? []);
$newsletter_db->close();
foreach($job_id_list as $one_job_id){
// TRY ACQUIRING A JOB LOCK //
// reset timeout
set_time_limit(150);
// open database
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// make sure job still exists
$one_job_data = $newsletter_db->get(["queue", $one_job_id]);
if($one_job_data === null){
$newsletter_db->close();
continue;
}
// check whether lock is either not held or stale
$time = time();
if($one_job_data["lock"]["held"] and $one_job_data["lock"]["time"] + 180 >= $time){
$newsletter_db->close();
continue;
}
// acquire lock
$newsletter_db->set(["queue", $one_job_id, "lock"], [
"held" => true,
"time" => $time,
]);
// close database
$newsletter_db->write_close();
// TRY EXECUTING JOB //
$success = false;
if($one_job_data["type"] === "mail_send_html"){
// `mail_send_html`
$success = self::mail_send_html(...$one_job_data["data"]);
} else {
// unknown job type
Error::warn(message: "Unknown job type", data: ["type" => $one_job_data["type"], "one_job_id" => $one_job_id, "one_job_data" => $one_job_data]);
}
// FINISH THIS JOB //
// open database
$newsletter_db = new Dat("./.dat/newsletter", Dat::MODE_READ_WRITE);
// make sure job still exists
$one_job_data = $newsletter_db->get(["queue", $one_job_id]);
if($one_job_data === null){
$newsletter_db->close();
continue;
}
// maybe remove successful job
if($success){
$newsletter_db->unset(["queue", $one_job_id]);
$newsletter_db->write_close();
continue;
}
// maybe remove failed job with high retry count
if(($one_job_data["retry_count"] + 1) >= 3){
Error::warn(message: "Multiple failed job execute attempts", data: ["one_job_id" => $one_job_id, "one_job_data" => $one_job_data]);
$newsletter_db->unset(["queue", $one_job_id]);
$newsletter_db->write_close();
continue;
}
// increase retry count
$newsletter_db->set(["queue", $one_job_id, "retry_count"], $one_job_data["retry_count"] + 1);
// release job lock
$newsletter_db->set(["queue", $one_job_id, "lock"], [
"held" => false,
"time" => 0,
]);
// close database
$newsletter_db->write_close();
}
// FINALIZE //
// release runlock
ignore_user_abort($old_ignore_user_abort);
}
/**
* HELPER: Actually send html mail using the phpmailer library.
*
* @param string $mail_address Recipient mail address.
* @param string $subject Mail subject.
* @param string $body Mail html body content.
*
* @return bool Whether sending was successful.
*/
private static function mail_send_html(string $mail_address, string $subject, string $body): bool {
// GET NEW PHPMAILER OBJECT //
$phpmailer = new PHPMailer();
// GENERAL SETTINGS //
// make timeout more aggressive
$phpmailer->Timeout = 30;
// we use smtp to send our mail
$phpmailer->IsSMTP();
// we use utf-8 encoding
$phpmailer->CharSet = "UTF-8";
// SMTP SETTINGS //
// host and smtp port
$phpmailer->Host = Env::MAIL["host"];
$phpmailer->Port = Env::MAIL["port"];
// we need to authenticate
$phpmailer->SMTPAuth = true;
// username and password
$phpmailer->Username = Env::MAIL["username"];
$phpmailer->Password = Env::MAIL["password"];
// encryption
$phpmailer->SMTPSecure = (Env::MAIL["starttls"] ? PHPMailer::ENCRYPTION_STARTTLS : PHPMailer::ENCRYPTION_SMTPS);
// FROM AND TO //
// from
$phpmailer->setFrom(Env::MAIL["username"], "SBGG.jetzt");
// recipient
$phpmailer->addAddress($mail_address);
// META STUFF //
// replace x-mailer header
$phpmailer->XMailer = Project::name() . " v" . Project::version();
// set hostname to use for message-id header
$phpmailer->Hostname = "sbgg.jetzt";
// CONTENT //
// this mail has html content
$phpmailer->isHTML(true);
// set subject
$phpmailer->Subject = $subject;
// html body
$phpmailer->Body = $body;
// SEND //
return($phpmailer->send());
}
}
?>