1015 lines
26 KiB
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­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());
|
|
}
|
|
}
|
|
?>
|