From 779550cab4b2ce9977ae35d1bb6b895e3d1beea2 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 9 Apr 2021 16:05:36 +0200 Subject: [PATCH] Contact Mails --- cli.php | 24 ++ core/Api/ContactAPI.class.php | 57 ++-- core/Api/MailAPI.class.php | 259 +++++++++++++++--- core/Configuration/CreateDatabase.class.php | 15 + core/Configuration/Settings.class.php | 4 + core/Driver/SQL/Query/AlterTable.class.php | 2 +- .../SQL/Query/CreateProcedure.class.php | 2 +- core/Driver/SQL/Query/CreateTrigger.class.php | 2 +- core/Driver/SQL/Query/Delete.class.php | 2 +- core/Driver/SQL/Query/Drop.php | 2 +- core/Driver/SQL/Query/Insert.class.php | 2 +- core/Driver/SQL/Query/Query.class.php | 2 + core/Driver/SQL/Query/Select.class.php | 2 +- core/Driver/SQL/Query/Truncate.class.php | 2 +- core/Driver/SQL/Query/Update.class.php | 2 +- core/Driver/SQL/SQL.class.php | 2 + core/core.php | 2 +- 17 files changed, 324 insertions(+), 59 deletions(-) diff --git a/cli.php b/cli.php index 458a5ea..1159071 100644 --- a/cli.php +++ b/cli.php @@ -1,6 +1,7 @@ getConfiguration()->getSettings()->isMailEnabled()) { + _exit("Mails are not configured yet."); + } + + $req = new Api\Mail\Sync($user); + printLine("Syncing emails…"); + if (!$req->execute()) { + _exit("Error syncing mails: " . $req->getLastError()); + } + + _exit("Done."); + } else { + _exit("Usage: cli.php mail [options...]"); + } +} + $argv = $_SERVER['argv']; if (count($argv) < 2) { _exit("Usage: cli.php [options...]"); @@ -481,6 +502,9 @@ switch ($command) { case 'test': onTest($argv); break; + case 'mail': + onMail($argv); + break; default: printLine("Unknown command '$command'"); printLine(); diff --git a/core/Api/ContactAPI.class.php b/core/Api/ContactAPI.class.php index 2021fc2..e4f1401 100644 --- a/core/Api/ContactAPI.class.php +++ b/core/Api/ContactAPI.class.php @@ -18,6 +18,7 @@ namespace Api\Contact { private int $notificationId; private int $contactRequestId; + private ?string $messageId; public function __construct(User $user, bool $externalCall = false) { $parameters = array( @@ -31,6 +32,7 @@ namespace Api\Contact { $parameters["captcha"] = new StringType("captcha"); } + $this->messageId = null; parent::__construct($user, $externalCall, $parameters); } @@ -48,15 +50,28 @@ namespace Api\Contact { } } - if (!$this->insertContactRequest()) { - return false; + $sendMail = $this->sendMail(); + $mailError = $this->getLastError(); + + $insertDB = $this->insertContactRequest(); + $dbError = $this->getLastError(); + + // Create a log entry + if (!$sendMail || $mailError) { + $message = "Error processing contact request."; + if (!$sendMail) { + $message .= " Mail: $mailError"; + } + + if (!$insertDB) { + $message .= " Mail: $dbError"; + } + + error_log($message); } - $this->createNotification(); - $this->sendMail(); - - if (!$this->success) { - return $this->createError("The contact request was saved, but the server was unable to create a notification."); + if (!$sendMail && !$insertDB) { + return $this->createError("The contact request could not be sent. The Administrator is already informed. Please try again later."); } return $this->success; @@ -67,9 +82,10 @@ namespace Api\Contact { $name = $this->getParam("fromName"); $email = $this->getParam("fromEmail"); $message = $this->getParam("message"); + $messageId = $this->messageId ?? null; - $res = $sql->insert("ContactRequest", array("from_name", "from_email", "message")) - ->addRow($name, $email, $message) + $res = $sql->insert("ContactRequest", array("from_name", "from_email", "message", "messageId")) + ->addRow($name, $email, $message, $messageId) ->returning("uid") ->execute(); @@ -112,15 +128,24 @@ namespace Api\Contact { return $this->success; } - private function sendMail() { - /*$email = $this->getParam("fromEmail"); - $settings = $this->user->getConfiguration()->getSettings(); + private function sendMail(): bool { + $name = $this->getParam("fromName"); + $email = $this->getParam("fromEmail"); + $message = $this->getParam("message"); + $request = new \Api\Mail\Send($this->user); $this->success = $request->execute(array( - "to" => $settings->get, - "subject" => "[$siteName] Account Invitation", - "body" => $messageBody - ));*/ + "subject" => "Contact Request", + "body" => $message, + "replyTo" => $email, + "replyName" => $name + )); + + if ($this->success) { + $this->messageId = $request->getResult()["messageId"]; + } + + return $this->success; } } diff --git a/core/Api/MailAPI.class.php b/core/Api/MailAPI.class.php index 2dc81ed..b9243d6 100644 --- a/core/Api/MailAPI.class.php +++ b/core/Api/MailAPI.class.php @@ -1,8 +1,35 @@ user); + $this->success = $req->execute(array("key" => "^mail_")); + $this->lastError = $req->getLastError(); + + if ($this->success) { + $settings = $req->getResult()["settings"]; + + if (!isset($settings["mail_enabled"]) || $settings["mail_enabled"] !== "1") { + $this->createError("Mail is not configured yet."); + return null; + } + + $host = $settings["mail_host"] ?? "localhost"; + $port = intval($settings["mail_port"] ?? "25"); + $login = $settings["mail_username"] ?? ""; + $password = $settings["mail_password"] ?? ""; + $connectionData = new ConnectionData($host, $port, $login, $password); + $connectionData->setProperty("from", $settings["mail_from"] ?? ""); + $connectionData->setProperty("last_sync", $settings["mail_last_sync"] ?? ""); + return $connectionData; + } + + return null; + } } } @@ -11,9 +38,11 @@ namespace Api\Mail { use Api\MailAPI; use Api\Parameter\Parameter; use Api\Parameter\StringType; + use Driver\SQL\Column\Column; + use Driver\SQL\Condition\Compare; + use Driver\SQL\Strategy\UpdateStrategy; use External\PHPMailer\Exception; use External\PHPMailer\PHPMailer; - use Objects\ConnectionData; use Objects\User; class Test extends MailAPI { @@ -45,40 +74,17 @@ namespace Api\Mail { class Send extends MailAPI { public function __construct($user, $externalCall = false) { parent::__construct($user, $externalCall, array( - 'to' => new Parameter('to', Parameter::TYPE_EMAIL), - 'subject' => new StringType('subject', -1), + 'to' => new Parameter('to', Parameter::TYPE_EMAIL, true, null), + 'subject' => new StringType('subject', -1), 'body' => new StringType('body', -1), + 'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null), + 'replyName' => new StringType('replyName', 32, true, "") )); $this->isPublic = false; } - private function getMailConfig() : ?ConnectionData { - $req = new \Api\Settings\Get($this->user); - $this->success = $req->execute(array("key" => "^mail_")); - $this->lastError = $req->getLastError(); - - if ($this->success) { - $settings = $req->getResult()["settings"]; - - if (!isset($settings["mail_enabled"]) || $settings["mail_enabled"] !== "1") { - $this->createError("Mail is not configured yet."); - return null; - } - - $host = $settings["mail_host"] ?? "localhost"; - $port = intval($settings["mail_port"] ?? "25"); - $login = $settings["mail_username"] ?? ""; - $password = $settings["mail_password"] ?? ""; - $connectionData = new ConnectionData($host, $port, $login, $password); - $connectionData->setProperty("from", $settings["mail_from"] ?? ""); - return $connectionData; - } - - return null; - } - public function execute($values = array()): bool { - if(!parent::execute($values)) { + if (!parent::execute($values)) { return false; } @@ -87,12 +93,23 @@ namespace Api\Mail { return false; } + $fromMail = $mailConfig->getProperty('from'); + $toMail = $this->getParam('to') ?? $fromMail; + $subject = $this->getParam('subject'); + $replyTo = $this->getParam('replyTo'); + $replyName = $this->getParam('replyName'); + try { $mail = new PHPMailer; $mail->IsSMTP(); - $mail->setFrom($mailConfig->getProperty("from")); - $mail->addAddress($this->getParam('to')); - $mail->Subject = $this->getParam('subject'); + $mail->setFrom($fromMail); + $mail->addAddress($toMail); + + if ($replyTo) { + $mail->addReplyTo($replyTo, $replyName); + } + + $mail->Subject = $subject; $mail->SMTPDebug = 0; $mail->Host = $mailConfig->getHost(); $mail->Port = $mailConfig->getPort(); @@ -108,6 +125,8 @@ namespace Api\Mail { if (!$this->success) { $this->lastError = "Error sending Mail: $mail->ErrorInfo"; error_log("sendMail() failed: $mail->ErrorInfo"); + } else { + $this->result["messageId"] = $mail->getLastMessageID(); } } catch (Exception $e) { $this->success = false; @@ -117,4 +136,178 @@ namespace Api\Mail { return $this->success; } } + + class Sync extends MailAPI { + + public function __construct(User $user, bool $externalCall = false) { + parent::__construct($user, $externalCall, array()); + $this->csrfTokenRequired = true; + } + + private function fetchMessageIds() { + $sql = $this->user->getSQL(); + $res = $sql->select("uid", "messageId") + ->from("ContactRequest") + ->where(new Compare("messageId", NULL, "!=")) + ->execute(); + + $this->success = ($res !== false); + $this->lastError = $sql->getLastError(); + if (!$this->success) { + return false; + } + + $messageIds = []; + foreach ($res as $row) { + $messageIds[$row["messageId"]] = $row["uid"]; + } + return $messageIds; + } + + private function findContactRequest(array &$messageIds, array &$references): ?int { + foreach ($references as &$ref) { + if (isset($messageIds[$ref])) { + return $messageIds[$ref]; + } + } + + return null; + } + + private function parseBody(string $body): string { + // TODO clean this up + return trim($body); + } + + private function insertMessages($messages): bool { + $sql = $this->user->getSQL(); + + $query = $sql->insert("ContactMessage", ["request_id", "user_id", "message", "messageId"]) + ->onDuplicateKeyStrategy(new UpdateStrategy(["message_id"], ["message" => new Column("message")])); + + foreach ($messages as $message) { + $query->addRow( + $message["requestId"], + $sql->select("uid")->from("User")->where(new Compare("email", $message["from"]))->limit(1), + $message["body"], + $message["messageId"] + ); + } + + $this->success = $query->execute(); + $this->lastError = $sql->getLastError(); + return $this->success; + } + + public function execute($values = array()): bool { + if (!parent::execute($values)) { + return false; + } + + $mailConfig = $this->getMailConfig(); + if (!$this->success) { + return false; + } + + if (!function_exists("imap_open")) { + return $this->createError("IMAP is not enabled. Enable it inside the php config. For more information visit: https://www.php.net/manual/en/imap.setup.php"); + } + + $messageIds = $this->fetchMessageIds(); + if ($messageIds === false) { + return false; + } else if (count($messageIds) === 0) { + // nothing to sync here + return true; + } + + // TODO: IMAP mail settings :( + $port = 993; + $folder = ""; // $folder = "INBOX"; + $host = str_replace("smtp", "imap", $mailConfig->getHost()); + $username = $mailConfig->getLogin(); + $password = $mailConfig->getPassword(); + $lastSync = intval($mailConfig->getProperty("last_sync", "0")); + $flags = ["/ssl"]; + + $mailboxStr = '{' . $host . ':' . $port . implode("", $flags) . '}' . $folder; + $mbox = @imap_open($mailboxStr, $username, $password, OP_READONLY); + if (!$mbox) { + return $this->createError("Can't connect to mail server via IMAP: " . imap_last_error()); + } + + if ($lastSync > 0) { + $lastSyncDateTime = (new \DateTime())->setTimeStamp($lastSync); + $dateStr = $lastSyncDateTime->format("d-M-Y"); + $searchCriteria = "SINCE \"$dateStr\""; + } else { + $lastSyncDateTime = null; + $searchCriteria = "ALL"; + } + + $now = (new \DateTime())->getTimestamp(); + $result = @imap_search($mbox, $searchCriteria); + if ($result === false) { + return $this->createError("Could not run search: " . imap_last_error()); + } + + $messages = []; + foreach ($result as $msgNo) { + $header = imap_headerinfo($mbox, $msgNo); + $date = new \DateTime($header->date); + if ($lastSync === 0 || \datetimeDiff($lastSyncDateTime, $date) > 0) { + + $references = property_exists($header, "references") ? + explode(" ", $header->references) : []; + + $requestId = $this->findContactRequest($messageIds, $references); + if ($requestId) { + $messageId = $header->message_id; + $senderAddress = null; + if (count($header->from) > 0) { + $senderAddress = $header->from[0]->mailbox . "@" . $header->from[0]->host; + } + + // $body = imap_body($mbox, $msgNo); + $structure = imap_fetchstructure($mbox, $msgNo); + $attachments = []; + $hasAttachments = (property_exists($structure, "parts")); + if ($hasAttachments) { + foreach ($structure->parts as $part) { + $disposition = (property_exists($part, "disposition") ? $part->disposition : null); + if ($disposition === "attachment") { + $fileName = array_filter($part->dparameters, function($param) { return $param->attribute === "filename"; }); + if (count($fileName) > 0) { + $attachments[] = $fileName[0]->value; + } + } + } + } + + $body = imap_fetchbody($mbox, $msgNo, "1"); + $body = $this->parseBody($body); + + $messages[] = [ + "messageId" => $messageId, + "requestId" => $requestId, + "timestamp" => $date->getTimestamp(), + "from" => $senderAddress, + "body" => $body, + "attachments" => $attachments + ]; + } + } + } + + @imap_close($mbox); + if (!$this->insertMessages($messages)) { + return false; + } + + $req = new \Api\Settings\Set($this->user); + $this->success = $req->execute(array("settings" => array("mail_last_sync" => "$now"))); + $this->lastError = $req->getLastError(); + return $this->success; + } + } } \ No newline at end of file diff --git a/core/Configuration/CreateDatabase.class.php b/core/Configuration/CreateDatabase.class.php index 1c8af14..11f3805 100644 --- a/core/Configuration/CreateDatabase.class.php +++ b/core/Configuration/CreateDatabase.class.php @@ -151,6 +151,7 @@ class CreateDatabase extends DatabaseScript { ->addRow("mail_username", "", false, false) ->addRow("mail_password", "", true, false) ->addRow("mail_from", "", false, false) + ->addRow("mail_last_sync", "", true, false) ->addRow("message_confirm_email", self::MessageConfirmEmail(), false, false) ->addRow("message_accept_invite", self::MessageAcceptInvite(), false, false) ->addRow("message_reset_password", self::MessageResetPassword(), false, false); @@ -163,9 +164,22 @@ class CreateDatabase extends DatabaseScript { ->addString("from_name", 32) ->addString("from_email", 64) ->addString("message", 512) + ->addString("messageId", 78, true) # null = don't sync with mails (usually if mail could not be sent) ->addDateTime("created_at", false, $sql->currentTimestamp()) + ->unique("messageId") ->primaryKey("uid"); + $queries[] = $sql->createTable("ContactMessage") + ->addSerial("uid") + ->addInt("request_id") + ->addInt("user_id", true) # null = customer has sent this message + ->addString("message", 512) + ->addString("messageId", 78) + ->addDateTime("created_at", false, $sql->currentTimestamp()) + ->unique("messageId") + ->primaryKey("uid") + ->foreignKey("request_id", "ContactRequest", "uid", new CascadeStrategy()); + $queries[] = $sql->createTable("ApiPermission") ->addString("method", 32) ->addJson("groups", true, '[]') @@ -183,6 +197,7 @@ class CreateDatabase extends DatabaseScript { ->addRow("Routes/fetch", array(USER_GROUP_ADMIN), "Allows users to list all configured routes") ->addRow("Routes/save", array(USER_GROUP_ADMIN), "Allows users to create, delete and modify routes") ->addRow("Mail/test", array(USER_GROUP_SUPPORT, USER_GROUP_ADMIN), "Allows users to send a test email to a given address") + ->addRow("Mail/Sync", array(USER_GROUP_SUPPORT, USER_GROUP_ADMIN), "Allows users to synchronize mails with the database") ->addRow("Settings/get", array(USER_GROUP_ADMIN), "Allows users to fetch server settings") ->addRow("Settings/set", array(USER_GROUP_ADMIN), "Allows users create, delete or modify server settings") ->addRow("Stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to fetch server stats") diff --git a/core/Configuration/Settings.class.php b/core/Configuration/Settings.class.php index 0418823..2d45a5d 100644 --- a/core/Configuration/Settings.class.php +++ b/core/Configuration/Settings.class.php @@ -110,4 +110,8 @@ class Settings { public function isRegistrationAllowed(): bool { return $this->registrationAllowed; } + + public function isMailEnabled(): bool { + return $this->mailEnabled; + } } \ No newline at end of file diff --git a/core/Driver/SQL/Query/AlterTable.class.php b/core/Driver/SQL/Query/AlterTable.class.php index 221d539..2af16a5 100644 --- a/core/Driver/SQL/Query/AlterTable.class.php +++ b/core/Driver/SQL/Query/AlterTable.class.php @@ -59,7 +59,7 @@ class AlterTable extends Query { public function getConstraint(): ?Constraint { return $this->constraint; } public function getTable(): string { return $this->table; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $tableName = $this->sql->tableName($this->getTable()); $action = $this->getAction(); $column = $this->getColumn(); diff --git a/core/Driver/SQL/Query/CreateProcedure.class.php b/core/Driver/SQL/Query/CreateProcedure.class.php index 9175e56..7bf3d8e 100644 --- a/core/Driver/SQL/Query/CreateProcedure.class.php +++ b/core/Driver/SQL/Query/CreateProcedure.class.php @@ -36,7 +36,7 @@ class CreateProcedure extends Query { return $this; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $head = $this->sql->getProcedureHead($this); $body = $this->sql->getProcedureBody($this); $tail = $this->sql->getProcedureTail(); diff --git a/core/Driver/SQL/Query/CreateTrigger.class.php b/core/Driver/SQL/Query/CreateTrigger.class.php index 1a651b6..0ff600d 100644 --- a/core/Driver/SQL/Query/CreateTrigger.class.php +++ b/core/Driver/SQL/Query/CreateTrigger.class.php @@ -61,7 +61,7 @@ class CreateTrigger extends Query { public function getTable(): string { return $this->tableName; } public function getProcedure(): CreateProcedure { return $this->procedure; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $name = $this->sql->tableName($this->getName()); $time = $this->getTime(); $event = $this->getEvent(); diff --git a/core/Driver/SQL/Query/Delete.class.php b/core/Driver/SQL/Query/Delete.class.php index cdbc5bc..5a5118a 100644 --- a/core/Driver/SQL/Query/Delete.class.php +++ b/core/Driver/SQL/Query/Delete.class.php @@ -24,7 +24,7 @@ class Delete extends Query { public function getTable(): string { return $this->table; } public function getConditions(): array { return $this->conditions; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $table = $this->sql->tableName($this->getTable()); $where = $this->sql->getWhereClause($this->getConditions(), $params); return "DELETE FROM $table$where"; diff --git a/core/Driver/SQL/Query/Drop.php b/core/Driver/SQL/Query/Drop.php index 4fb96b4..74a1802 100644 --- a/core/Driver/SQL/Query/Drop.php +++ b/core/Driver/SQL/Query/Drop.php @@ -23,7 +23,7 @@ class Drop extends Query { return $this->table; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { return "DROP TABLE " . $this->sql->tableName($this->getTable()); } } \ No newline at end of file diff --git a/core/Driver/SQL/Query/Insert.class.php b/core/Driver/SQL/Query/Insert.class.php index 46235d8..ca1ab23 100644 --- a/core/Driver/SQL/Query/Insert.class.php +++ b/core/Driver/SQL/Query/Insert.class.php @@ -43,7 +43,7 @@ class Insert extends Query { public function onDuplicateKey(): ?Strategy { return $this->onDuplicateKey; } public function getReturning(): ?string { return $this->returning; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $tableName = $this->sql->tableName($this->getTableName()); $columns = $this->getColumns(); $rows = $this->getRows(); diff --git a/core/Driver/SQL/Query/Query.class.php b/core/Driver/SQL/Query/Query.class.php index 852fabf..1fbe3b4 100644 --- a/core/Driver/SQL/Query/Query.class.php +++ b/core/Driver/SQL/Query/Query.class.php @@ -24,4 +24,6 @@ abstract class Query extends Expression { public function execute() { return $this->sql->executeQuery($this); } + + public abstract function build(array &$params): ?string; } \ No newline at end of file diff --git a/core/Driver/SQL/Query/Select.class.php b/core/Driver/SQL/Query/Select.class.php index 7e997ab..905a94c 100644 --- a/core/Driver/SQL/Query/Select.class.php +++ b/core/Driver/SQL/Query/Select.class.php @@ -95,7 +95,7 @@ class Select extends Query { public function getOffset(): int { return $this->offset; } public function getGroupBy(): array { return $this->groupColumns; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $columns = $this->sql->columnName($this->getColumns()); $tables = $this->getTables(); diff --git a/core/Driver/SQL/Query/Truncate.class.php b/core/Driver/SQL/Query/Truncate.class.php index 888ab9c..68ceb2c 100644 --- a/core/Driver/SQL/Query/Truncate.class.php +++ b/core/Driver/SQL/Query/Truncate.class.php @@ -15,7 +15,7 @@ class Truncate extends Query { public function getTable(): string { return $this->tableName; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { return "TRUNCATE " . $this->sql->tableName($this->getTable()); } } \ No newline at end of file diff --git a/core/Driver/SQL/Query/Update.class.php b/core/Driver/SQL/Query/Update.class.php index d67e90a..51698a1 100644 --- a/core/Driver/SQL/Query/Update.class.php +++ b/core/Driver/SQL/Query/Update.class.php @@ -32,7 +32,7 @@ class Update extends Query { public function getConditions(): array { return $this->conditions; } public function getValues(): array { return $this->values; } - public function build(array &$params, Query $context = NULL): ?string { + public function build(array &$params): ?string { $table = $this->sql->tableName($this->getTable()); $valueStr = array(); diff --git a/core/Driver/SQL/SQL.class.php b/core/Driver/SQL/SQL.class.php index 646e6af..ab26632 100644 --- a/core/Driver/SQL/SQL.class.php +++ b/core/Driver/SQL/SQL.class.php @@ -315,6 +315,8 @@ abstract class SQL { protected function createExpression(Expression $exp, array &$params) { if ($exp instanceof Column) { return $this->columnName($exp); + } else if ($exp instanceof Query) { + return "(" . $exp->build($params) . ")"; } else { $this->lastError = "Unsupported expression type: " . get_class($exp); return null; diff --git a/core/core.php b/core/core.php index 966fe29..333db8d 100644 --- a/core/core.php +++ b/core/core.php @@ -1,6 +1,6 @@