包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、JS空集合保护、 会话过期体验优化(403 JSON)、display_errors 关闭、 固定 key 根治 Integrity check failed 等全部改动 注意:config.inc.php/.htaccess/.user.ini 含敏感信息, 通过 .gitignore 排除,勿推送到公开仓库。
415 lines
15 KiB
PHP
415 lines
15 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace MRBS;
|
|
|
|
use Mail_mimePart;
|
|
use PHPMailer\PHPMailer\Exception;
|
|
use PHPMailer\PHPMailer\SMTP;
|
|
|
|
require_once MRBS_ROOT . '/functions_mail.inc';
|
|
|
|
// A class for handling a mail queue. Normally the queue is flushed on shutdown using a
|
|
// register_shutdown_function() callback. However the class could easily be extended so that the
|
|
// mail queue is held in the database and emptied by a cron job. This would enable rate limits to
|
|
// be adhered to more accurately, as there would be just one queue.
|
|
class MailQueue
|
|
{
|
|
protected static $mails = array();
|
|
|
|
|
|
/**
|
|
* Add an email to the queue for sending
|
|
*
|
|
* @param array $addresses an array of addresses, each being a comma
|
|
* separated list of email addresses. Indexed by
|
|
* 'from'
|
|
* 'reply_to'
|
|
* 'to'
|
|
* 'cc'
|
|
* 'bcc'
|
|
* @param string $subject email subject
|
|
* @param string $text_body text part of body
|
|
* @param string|null $html_body HTML part of body
|
|
* @param array|null $attachment file to attach. An array consisting of
|
|
* 'content' the file or data to attach
|
|
* 'method' the iCalendar METHOD
|
|
* 'name' the name to give it
|
|
* @param string $charset character set used in body
|
|
* @return void
|
|
*/
|
|
public static function add(
|
|
array $addresses,
|
|
string $subject,
|
|
string $text_body,
|
|
?string $html_body,
|
|
?array $attachment,
|
|
string $charset = 'us-ascii'
|
|
) : void
|
|
{
|
|
global $mail_settings;
|
|
|
|
// Don't do anything if mail has been disabled. Useful for testing MRBS without
|
|
// sending emails to those who don't want them
|
|
if ($mail_settings['disabled'])
|
|
{
|
|
mail_debug("Mail disabled: not adding message to queue");
|
|
return;
|
|
}
|
|
|
|
// And no point in doing anything if there are no recipients
|
|
if (self::getNRecipients($addresses) == 0)
|
|
{
|
|
mail_debug("No recipients: not adding message to queue");
|
|
return;
|
|
}
|
|
|
|
$mail = array();
|
|
foreach(array('addresses', 'subject', 'text_body', 'html_body', 'attachment', 'charset') as $var)
|
|
{
|
|
$mail[$var] = $$var;
|
|
}
|
|
self::$mails[] = $mail;
|
|
}
|
|
|
|
|
|
public static function flush() : void
|
|
{
|
|
foreach (self::$mails as $mail)
|
|
{
|
|
self::sendMail(
|
|
$mail['addresses'],
|
|
$mail['subject'],
|
|
$mail['text_body'],
|
|
$mail['html_body'],
|
|
$mail['attachment'],
|
|
$mail['charset']
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the number of recipients in the addresses array.
|
|
*/
|
|
protected static function getNRecipients(array $addresses) : int
|
|
{
|
|
$result = 0;
|
|
|
|
if (!empty($addresses))
|
|
{
|
|
foreach (['to', 'cc', 'bcc'] as $key)
|
|
{
|
|
if (!empty($addresses[$key]))
|
|
{
|
|
$result += count(parse_addresses($addresses[$key]));
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Send an email
|
|
*
|
|
* @param array $addresses an array of addresses, each being a comma
|
|
* separated list of email addresses. Indexed by
|
|
* 'from'
|
|
* 'reply_to'
|
|
* 'to'
|
|
* 'cc'
|
|
* 'bcc'
|
|
* @param string $subject email subject
|
|
* @param string $text_body text part of body
|
|
* @param string|null $html_body HTML part of body
|
|
* @param array|null $attachment file to attach. An array consisting of
|
|
* 'content' the file or data to attach
|
|
* 'method' the iCalendar METHOD
|
|
* 'name' the name to give it
|
|
* @param string $charset character set used in body
|
|
* @return bool TRUE on success, FALSE on failure
|
|
* @throws Exception
|
|
*/
|
|
protected static function sendMail(
|
|
array $addresses,
|
|
string $subject,
|
|
string $text_body,
|
|
?string $html_body,
|
|
?array $attachment,
|
|
string $charset = 'us-ascii'
|
|
) : bool
|
|
{
|
|
// Modify the include path because this is run after shutdown when the working directory may have
|
|
// changed (see the note in https://www.php.net/manual/en/function.register-shutdown-function.php).
|
|
// Put the new path at the beginning of the list in case there is an open_basedir restriction in effect
|
|
// and one of the paths triggers a warning preventing the remaining paths from being reached.
|
|
set_include_path(MRBS_ROOT . PATH_SEPARATOR . get_include_path());
|
|
|
|
require_once 'Mail/mimePart.php';
|
|
|
|
// We use the PHPMailer class to handle the sending of mail. However PHPMailer does not
|
|
// provide the versatility we need to be able to construct the MIME parts necessary for
|
|
// iCalendar applications, so we use the PEAR Mail_Mime package to do that. See the
|
|
// discussion at https://github.com/PHPMailer/PHPMailer/issues/175
|
|
|
|
global $mail_settings, $sendmail_settings, $smtp_settings;
|
|
|
|
static $last_mail_sent = null;
|
|
static $last_n_addresses = null;
|
|
|
|
$mail = new Mailer($mail_settings, $sendmail_settings, $smtp_settings);
|
|
|
|
mail_debug("Preparing to send email ...");
|
|
if ($mail_settings['debug'])
|
|
{
|
|
$mail->Debugoutput = ($mail_settings['debug_output'] == 'log') ? 'error_log' : 'html';
|
|
$mail->SMTPDebug = SMTP::DEBUG_CONNECTION; // show connection status, client -> server and server -> client messages
|
|
}
|
|
|
|
$eol = "\n"; // EOL sequence to use in mail headers. Need "\n" for mail backend
|
|
|
|
if (empty($addresses['from']))
|
|
{
|
|
if (isset($mail_settings['from']))
|
|
{
|
|
$addresses['from'] = $mail_settings['from'];
|
|
}
|
|
else
|
|
{
|
|
trigger_error('$mail_settings["from"] has not been set in the config file.', E_USER_NOTICE);
|
|
}
|
|
}
|
|
|
|
$mail->CharSet = Language::MAIL_CHARSET; // PHPMailer defaults to 'iso-8859-1'
|
|
$mail->AllowEmpty = true; // remove this for production
|
|
|
|
if (!empty($addresses['reply_to']))
|
|
{
|
|
$reply_to_addresses = parse_addresses($addresses['reply_to']);
|
|
foreach ($reply_to_addresses as $reply_to_address)
|
|
{
|
|
$mail->addReplyTo($reply_to_address['address'], $reply_to_address['name']);
|
|
}
|
|
}
|
|
|
|
if (isset($addresses['from']))
|
|
{
|
|
$from_addresses = parse_addresses($addresses['from']);
|
|
$mail->setFrom($from_addresses[0]['address'], $from_addresses[0]['name']);
|
|
}
|
|
|
|
$to_addresses = parse_addresses($addresses['to']);
|
|
foreach ($to_addresses as $to_address)
|
|
{
|
|
$mail->addAddress($to_address['address'], $to_address['name']);
|
|
}
|
|
|
|
if (isset($addresses['cc']))
|
|
{
|
|
$cc_addresses = parse_addresses($addresses['cc']);
|
|
foreach ($cc_addresses as $cc_address)
|
|
{
|
|
// for cases where the mail server refuses to send emails with cc set, put the cc addresses on the to line
|
|
if ($mail_settings['treat_cc_as_to'])
|
|
{
|
|
$mail->addAddress($cc_address['address'], $cc_address['name']);
|
|
}
|
|
// otherwise treat them normally, adding them to the cc line
|
|
else
|
|
{
|
|
$mail->addCC($cc_address['address'], $cc_address['name']);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isset($addresses['bcc']))
|
|
{
|
|
$bcc_addresses = parse_addresses($addresses['bcc']);
|
|
foreach ($bcc_addresses as $bcc_address)
|
|
{
|
|
$mail->addBCC($bcc_address['address'], $bcc_address['name']);
|
|
}
|
|
}
|
|
|
|
$mail->Subject = $subject;
|
|
|
|
|
|
// Build the email. We're going to use the "alternative" subtype which means
|
|
// that we order the sub parts according to how faithful they are to the original,
|
|
// putting the least faithful first, ie the ordinary plain text version. The
|
|
// email client then uses the most faithful version that it can handle. (See
|
|
// https://www.rfc-editor.org/rfc/rfc2046#section-5.1.4)
|
|
//
|
|
// If we are also adding the iCalendar information then we enclose this alternative
|
|
// mime subtype in an outer mime type which is mixed. This is necessary so that
|
|
// the widest variety of calendar applications can access the calendar information.
|
|
// So depending on whether we are sending iCalendar information we will have a Mime
|
|
// structure that looks like this:
|
|
//
|
|
// With iCalendar info Without iCalendar info
|
|
// ------------------- ----------------------
|
|
//
|
|
// multipart/mixed mutlipart/alternative
|
|
// multipart/alternative text/plain
|
|
// text/plain text/html
|
|
// text/html
|
|
// text/calendar
|
|
// application/ics
|
|
|
|
// First of all build the inner mime type, ie the multipart/alternative type.
|
|
// If we're not sending iCalendar information this will become the outer,
|
|
// otherwise we'll then enclose it in an outer mime type.
|
|
$mime_params = array();
|
|
$mime_params['eol'] = $eol;
|
|
$mime_params['content_type'] = "multipart/alternative";
|
|
$mime_inner = new Mail_mimePart('', $mime_params);
|
|
|
|
// Add the text part
|
|
$mime_params['content_type'] = "text/plain";
|
|
$mime_params['cid'] = generate_global_uid('text');
|
|
$mime_params['encoding'] = "8bit";
|
|
$mime_params['charset'] = $charset;
|
|
$mime_inner->addSubPart($text_body, $mime_params);
|
|
unset($mime_params['cid']);
|
|
|
|
// Add the HTML mail
|
|
if (isset($html_body))
|
|
{
|
|
$mime_params['content_type'] = "text/html";
|
|
$mime_params['cid'] = generate_global_uid('html');
|
|
$mime_inner->addSubPart($html_body, $mime_params);
|
|
unset($mime_params['cid']);
|
|
}
|
|
|
|
if (empty($attachment))
|
|
{
|
|
// If we're not sending iCalendar information we've now got everything,
|
|
// so we'll make the "inner" section the complete mime.
|
|
$mime = $mime_inner;
|
|
}
|
|
else
|
|
{
|
|
// Otherwise we need to carry on and add the text version of the iCalendar
|
|
$mime_params['content_type'] = "text/calendar; method=" . $attachment['method'];
|
|
// The encoding needs to be base64, because Postfix will otherwise not preserve
|
|
// CRLF sequences and these are mandatory terminators in the iCalendar file
|
|
$mime_params['encoding'] = "base64";
|
|
$mime_inner->addSubPart($attachment['content'], $mime_params);
|
|
|
|
// and then enclose the inner section in a multipart/mixed outer section.
|
|
// First create the outer section
|
|
$mime_params = array();
|
|
$mime_params['eol'] = $eol;
|
|
$mime_params['content_type'] = "multipart/mixed";
|
|
$mime = new Mail_mimePart('', $mime_params);
|
|
|
|
// Now add the inner section as the first sub part
|
|
$mime_inner = $mime_inner->encode();
|
|
$mime_params = array();
|
|
$mime_params['eol'] = $eol;
|
|
$mime_params['encoding'] = "8bit";
|
|
$mime_params['content_type'] = $mime_inner['headers']['Content-Type'];
|
|
$mime->addSubPart($mime_inner['body'], $mime_params);
|
|
|
|
// And add the attachment as the second sub part
|
|
$mime_params['content_type'] = "application/ics";
|
|
$mime_params['encoding'] = "base64";
|
|
$mime_params['disposition'] = "attachment";
|
|
$mime_params['dfilename'] = $attachment['name'];
|
|
$mime->addSubPart($attachment['content'], $mime_params);
|
|
}
|
|
|
|
// Encode the result
|
|
$mime = $mime->encode();
|
|
|
|
// Construct the MIMEHeader and then call preSend() which sets up the
|
|
// headers. However it also sets up the body, which we don't want,
|
|
// so we have to set the MIMEBody after calling preSend().
|
|
//
|
|
// This is not ideal since $MIMEHeader and $MIMEBody are protected
|
|
// properties of the PHPMailer class. In the future we may have to extend
|
|
// the class to do what we want, unless the features we need are added.
|
|
|
|
$mime_header = '';
|
|
foreach ($mime['headers'] as $name => $value)
|
|
{
|
|
if ($name == 'Content-Type')
|
|
{
|
|
$mail->ContentType = $value;
|
|
}
|
|
$mime_header .= $mail->headerLine($name, $value);
|
|
}
|
|
$mail->set('MIMEHeader', $mime_header);
|
|
|
|
if ($mail->preSend())
|
|
{
|
|
$mail->set('MIMEBody', $mime['body']);
|
|
mail_debug("Using backend '" . $mail_settings['admin_backend'] . "'");
|
|
mail_debug("From: " . ($addresses['from'] ?? ''));
|
|
mail_debug("To: " . ($addresses['to'] ?? ''));
|
|
mail_debug("Cc: " . ($addresses['cc'] ?? ''));
|
|
mail_debug("Bcc: " . ($addresses['bcc'] ?? ''));
|
|
|
|
// Throttle the rate of mail sending if required
|
|
if (!empty($mail_settings['rate_limit']))
|
|
{
|
|
$microtime_now = round(microtime(true), 6);
|
|
if (isset($last_mail_sent))
|
|
{
|
|
$diff = round($microtime_now - $last_mail_sent, 6);
|
|
mail_debug("Last mail sent $diff seconds ago to $last_n_addresses addresses");
|
|
$min_gap = round($last_n_addresses/$mail_settings['rate_limit'], 6);
|
|
if ($min_gap > $diff)
|
|
{
|
|
$sleep_seconds = round($min_gap - $diff, 6);
|
|
mail_debug("Too soon to send the next mail; sleeping for $sleep_seconds seconds");
|
|
usleep(intval($sleep_seconds * 1000000));
|
|
}
|
|
}
|
|
$last_n_addresses = self::getNRecipients($addresses);
|
|
$last_mail_sent = $microtime_now;
|
|
}
|
|
|
|
// PHPMailer uses escapeshellarg() and escapeshellcmd(). In many installations these will
|
|
// have been disabled. If they have been disabled PHP will generate a warning and the functions
|
|
// will return NULL. PHPMailer uses the functions to test if the sender address is shell safe
|
|
// and can be used with the -f option. If the escapeshell*() functions are disabled, mail
|
|
// will still be sent, but -f will not be used. [Note that if a function is disabled you cannot
|
|
// redeclare it, so writing emulations of escapeshellarg() and escapeshellcmd() is not an option.]
|
|
|
|
// As mail still gets through, the warning message will cause error logs to fill up rapidly, so we
|
|
// suppress the standard errors in the cases when they will be generated and issue our own NOTICE error.
|
|
|
|
$disabled_functions = ini_get('disable_functions');
|
|
|
|
if (!empty($disabled_functions) && (mb_strpos($disabled_functions, 'escapeshell') !== FALSE) &&
|
|
in_array($mail_settings['admin_backend'], array('mail', 'sendmail')))
|
|
{
|
|
$message = "Your PHP system has one or both of the escapeshellarg() and escapeshellcmd() functions " .
|
|
"disabled and you are using the '" . $mail_settings['admin_backend'] . "' backend. " .
|
|
"PHPMailer will therefore not have used the -f option when sending mail.";
|
|
mail_debug($message);
|
|
trigger_error($message, E_USER_NOTICE);
|
|
$result = @$mail->postSend();
|
|
}
|
|
else
|
|
{
|
|
$result = $mail->postSend();
|
|
}
|
|
|
|
if ($result)
|
|
{
|
|
mail_debug('Email sent successfully');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
error_log('Error sending email: ' . $mail->ErrorInfo);
|
|
mail_debug('Failed to send email: ' . $mail->ErrorInfo);
|
|
|
|
return false;
|
|
}
|
|
|
|
}
|