diff --git a/Message/Mail/DsnNotificationType.php b/Message/Mail/DsnNotificationType.php new file mode 100644 index 000000000..f7864ff90 --- /dev/null +++ b/Message/Mail/DsnNotificationType.php @@ -0,0 +1,38 @@ +. If empty this is automatically generated. + * + * @var string + * @since 1.0.0 + */ + protected string $messageId = ''; + + /** + * Unique ID used for message ID and boundaries. + * + * @var string + * @since 1.0.0 + */ + protected string $uniqueid = ''; + + /** + * Mailer for sending message + * + * @var string + * @since 1.0.0 + */ + protected string $mailer = SubmitType::MAIL; + + /** + * Mail from. + * + * @var string + * @since 1.0.0 + */ + protected string $from = ''; + + /** + * Mail from. + * + * @var string + * @since 1.0.0 + */ + protected string $fromName = ''; + + /** + * Return path/bounce address + * + * @var string + * @since 1.0.0 + */ + public string $sender = ''; + + /** + * Confirm address. + * + * @var string + */ + public string $confirmAddress = ''; + + /** + * Mail to. + * + * @var array + * @since 1.0.0 + */ + public array $to = []; + + /** + * Mail subject. + * + * @var string + * @since 1.0.0 + */ + public string $subject = ''; + + /** + * Mail cc. + * + * @var array + * @since 1.0.0 + */ + public array $cc = []; + + /** + * Mail bcc. + * + * @var array + * @since 1.0.0 + */ + public array $bcc = []; + + /** + * The array of reply-to names and addresses. + * + * @var array + * @since 1.0.0 + */ + public array $replyTo = []; + + /** + * Mail attachments. + * + * @var array + * @since 1.0.0 + */ + protected array $attachment = []; + + /** + * Mail body. + * + * @var string + * @since 1.0.0 + */ + public string $body = ''; + + /** + * Mail alt. + * + * @var string + * @since 1.0.0 + */ + public string $bodyAlt = ''; + + /** + * Mail mime. + * + * @var string + * @since 1.0.0 + */ + public string $bodyMime = ''; + + /** + * The array of MIME boundary strings. + * + * @var array + * @since 1.0.0 + */ + protected array $boundary = []; + + /** + * Mail header. + * + * @var string + * @since 1.0.0 + */ + protected string $header = ''; + + /** + * Mail header. + * + * @var string + * @since 1.0.0 + */ + public string $headerMime = ''; + + /** + * The array of custom headers. + * + * @var array + * @since 1.0.0 + */ + protected array $customHeader = []; + + /** + * Word wrap. + * + * @var int + * @since 1.0.0 + */ + protected int $wordWrap = 72; + + /** + * Encoding. + * + * @var string + * @since 1.0.0 + */ + protected string $encoding = EncodingType::E_8BIT; + + /** + * Mail content type. + * + * @var string + * @since 1.0.0 + */ + protected string $contentType = MimeType::M_TXT; + + /** + * Character set + * + * @var string + * @since 1.0.0 + */ + protected string $charset = CharsetType::ISO_8859_1; + + /** + * Mail message type. + * + * @var string + * @since 1.0.0 + */ + protected string $messageType = ''; + + /** + * Mail from. + * + * @var null|\DateTime + * @since 1.0.0 + */ + public ?\DateTimeImmutable $messageDate = null; + + /** + * Priority + * + * @var int + * @since 1.0.0 + */ + public int $priority = 0; + + /** + * Should confirm reading + * + * @var bool + * @since 1.0.0 + */ + protected bool $confirmReading = false; + + /** + * The S/MIME certificate file path. + * + * @var string + * @since 1.0.0 + */ + protected string $signCertFile = ''; + + /** + * The S/MIME key file path. + * + * @var string + * @since 1.0.0 + */ + protected string $signKeyFile = ''; + + /** + * The optional S/MIME extra certificates ("CA Chain") file path. + * + * @var string + * @since 1.0.0 + */ + protected string $signExtracertFiles = ''; + + /** + * The S/MIME password for the key. + * Used only if the key is encrypted. + * + * @var string + * @since 1.0.0 + */ + protected string $signKeyPass = ''; + + /** + * DKIM selector. + * + * @var string + * @since 1.0.0 + */ + public string $dkimSelector = ''; + + /** + * DKIM Identity. + * Usually the email address used as the source of the email. + * + * @var string + * @since 1.0.0 + */ + public string $dkimIdentity = ''; + + /** + * DKIM passphrase. + * Used if your key is encrypted. + * + * @var string + * @since 1.0.0 + */ + public string $dkimPass = ''; + + /** + * DKIM signing domain name. + * + * @var string + * @since 1.0.0 + */ + public string $dkimDomain = ''; + + /** + * DKIM Copy header field values for diagnostic use. + * + * @var bool + * @since 1.0.0 + */ + public bool $dkimCopyHeader = true; + + /** + * DKIM Extra signing headers. + * + * @example ['List-Unsubscribe', 'List-Help'] + * + * @var array + * @since 1.0.0 + */ + public array $dkimHeaders = []; + + /** + * DKIM private key file path. + * + * @var string + * @since 1.0.0 + */ + public string $dkimPrivatePath = ''; + + /** + * DKIM private key string. + * + * If set, takes precedence over `$dkimPrivatePath`. + * + * @var string + * @since 1.0.0 + */ + public string $dkimPrivateKey = ''; + + /** + * Constructor. + * + * @param string $id Id + * + * @since 1.0.0 + */ + public function __construct(string $id = '') + { + $this->id = $id; + } + + /** + * Set the From and FromName. + * + * @param string $address Email address + * @param string $name Name + * + * @return bool + * + * @since 1.0.0 + */ + public function setFrom(string $address, string $name = '') : bool + { + $address = \trim($address); + $name = \trim(\preg_replace('/[\r\n]+/', '', $name)); + + if (!EmailValidator::isValid($address)) { + return false; + } + + $this->from = $address; + $this->fromName = $name; + + if (empty($this->sender)) { + $this->sender = $address; + } + + return true; + } + + /** + * Sets message type to html or plain. + * + * @param bool $isHtml Html mode + * + * @return void + * + * @since 1.0.0 + */ + public function setHtml(bool $isHtml = true) : void + { + $this->contentType = $isHtml ? MimeType::M_HTML : MimeType::M_TEXT; + } + + /** + * Add a "To" address. + * + * @param string $address Email address + * @param string $name Name + * + * @return bool + * + * @since 1.0.0 + */ + public function addTo(string $address, string $name = '') : bool + { + if (!EmailValidator::isValid($address)) { + return false; + } + + $this->to[$address] = [$address, $name]; + + return true; + } + + /** + * Add a "CC" address. + * + * @param string $address Email address + * @param string $name Name + * + * @return bool + * + * @since 1.0.0 + */ + public function addCC(string $address, string $name = '') : bool + { + if (!EmailValidator::isValid($address)) { + return false; + } + + $this->cc[$address] = [$address, $name]; + + return true; + } + + /** + * Add a "BCC" address. + * + * @param string $address Email address + * @param string $name Name + * + * @return bool + * + * @since 1.0.0 + */ + public function addBCC(string $address, string $name = '') : bool + { + if (!EmailValidator::isValid($address)) { + return false; + } + + $this->bcc[$address] = [$address, $name]; + + return true; + } + + /** + * Add a "Reply-To" address. + * + * @param string $address Email address + * @param string $name Name + * + * @return bool + * + * @since 1.0.0 + */ + public function addReplyTo(string $address, string $name = '') : bool + { + if (!EmailValidator::isValid($address)) { + return false; + } + + $this->replyTo[$address] = [$address, $name]; + + return true; + } + + /** + * Parse and validate a string containing one or more RFC822-style comma-separated email addresses + * of the form "display name
" into an array of name/address pairs. + * + * @param string $addrstr Address line + * @param bool $useImap Use imap for parsing + * + * @return array + * + * @since 1.0.0 + */ + public static function parseAddresses(string $addrstr, bool $useimap = true) : array + { + $addresses = []; + if ($useimap && \function_exists('imap_rfc822_parse_adrlist')) { + $list = \imap_rfc822_parse_adrlist($addrstr, ''); + foreach ($list as $address) { + if (('.SYNTAX-ERROR.' !== $address->host) + && EmailValidator::isValid($address->mailbox . '@' . $address->host) + ) { + $addresses[] = [ + 'name' => (\property_exists($address, 'personal') ? $address->personal : ''), + 'address' => $address->mailbox . '@' . $address->host, + ]; + } + } + + return $addresses; + } + + $list = \explode(',', $addrstr); + foreach ($list as $address) { + $address = trim($address); + if (\strpos($address, '<') === false) { + if (EmailValidator::isValid($address)) { + $addresses[] = [ + 'name' => '', + 'address' => $address, + ]; + } + } else { + list($name, $email) = \explode('<', $address); + $email = \trim(\str_replace('>', '', $email)); + + if (EmailValidator::isValid($email)) { + $addresses[] = [ + 'name' => \trim(\str_replace(['"', "'"], '', $name)), + 'address' => $email, + ]; + } + } + } + + return $addresses; + } + + /** + * Pre-send preparations + * + * @param string $mailer Mailer tool + * + * @return bool + * + * @since 1.0.0 + */ + public function preSend(string $mailer) : bool + { + $this->header = ''; + $this->mailer = $mailer; + + if (\count($this->to) + \count($this->cc) + \count($this->bcc) < 1) { + return false; + } + + if (!empty($this->altBody)) { + $this->contentType = MimeType::M_ALT; + } + + $this->setMessageType(); + + $this->headerMime = ''; + $this->bodyMime = $this->createBody(); + + $tempheaders = $this->headerMime; + $this->headerMime = $this->createHeader(); + $this->headerMime .= $tempheaders; + + if ($this->mailer === SubmitType::MAIL) { + $this->header .= \count($this->to) > 0 + ? $this->createAddressList('To', $this->to) + : 'Subject: undisclosed-recipients:;' . self::$LE; + + $this->header .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE; + } + + // Sign with DKIM if enabled + if (!empty($this->dkimDomain) + && !empty($this->dkimSelector) + && (!empty($this->dkimPrivateKey) + || (!empty($this->dkimPrivatePath) + && FileUtils::isPermittedPath($this->dkimPrivatePath) + && \is_file($this->dkimPrivatePath) + ) + ) + ) { + $headerDkim = $this->dkimAdd( + $this->headerMime . $this->header, + $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))), + $this->bodyMime + ); + + $this->headerMime = rtrim($this->headerMime, " \r\n\t") . self::$LE . + self::normalizeBreaks($headerDkim, self::$LE) . self::$LE; + } + + return true; + } + + /** + * Assemble message headers. + * + * @return string The assembled headers + * + * @since 1.0.0 + */ + private function createHeader() : string + { + $result = ''; + $result .= 'Date : ' . ($this->messageDate === null + ? (new \DateTime('now'))->format('D, j M Y H:i:s O') + : $this->messageDate->format('D, j M Y H:i:s O')) + . self::$LE; + + if ($this->mailer === SubmitType::MAIL) { + $result .= \count($this->to) > 0 + ? $this->addrAppend('To', $this->to) + : 'To: undisclosed-recipients:;' . self::$LE; + } + + $result .= $this->addrAppend('From', [[\trim($this->from), $this->fromName]]); + + // sendmail and mail() extract Cc from the header before sending + if (\count($this->cc) > 0) { + $result .= $this->addrAppend('Cc', $this->cc); + } + + // sendmail and mail() extract Bcc from the header before sending + if (($this->mailer === SubmitType::MAIL || $this->mailer === SubmitType::SENDMAIL || $this->mailer === SubmitType::QMAIL) + && \count($this->bcc) > 0 + ) { + $result .= $this->addrAppend('Bcc', $this->bcc); + } + + if (\count($this->replyTo) > 0) { + $result .= $this->addrAppend('Reply-To', $this->replyTo); + } + + // mail() sets the subject itself + if ($this->mailer !== SubmitType::MAIL) { + $result .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE; + } + + $this->hostname = empty($this->hostname) ? SystemUtils::getHostname() : $this->hostname; + + // Only allow a custom message Id if it conforms to RFC 5322 section 3.6.4 + // https://tools.ietf.org/html/rfc5322#section-3.6.4 + $this->messageId = $this->messageId !== '' && \preg_match('/^<.*@.*>$/', $this->messageId) + ? $this->messageId + : \sprintf('<%s@%s>', $this->uniqueid, $this->hostname); + + $result .= 'Message-ID: ' . $this->messageId . self::$LE; + + if ($this->priority > 0) { + $result .= 'X-Priority: ' . $this->priority . self::$LE; + } + + $result .= 'X-Mailer: ' . self::XMAILER . self::$LE; + + if ($this->confirmAddress !== '') { + $result .= 'Disposition-Notification-To: ' . '<' . $this->confirmAddress . '>' . self::$LE; + } + + // Add custom headers + foreach ($this->customHeader as $header) { + $result .= \trim($header[0]) . ': ' . $this->encodeHeader(\trim($header[1])) . self::$LE; + } + + if (!empty($this->signKeyFile)) { + $result .= 'MIME-Version: 1.0' . self::$LE; + $result .= $this->getMailMime(); + } + + return $result; + } + + /** + * Create recipient headers. + * + * @param string $type Address type + * @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User'] + * + * @return string + * + * @since 1.0.0 + */ + private function addrAppend(string $type, array $addr) : string + { + $addresses = []; + foreach ($addr as $address) { + $addresses[] = $this->addrFormat($address); + } + + return $type . ': ' . \implode(', ', $addresses) . self::$LE; + } + + /** + * Format an address for use in a message header. + * + * @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User'] + * + * @return string + * + * @since 1.0.0 + */ + public function addrFormat(array $addr) : string + { + if (empty($addr[1])) { + return \trim(\str_replace(["\r", "\n"], '', $addr[0])); + } + + return $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $addr[1])), 'phrase') . + ' <' . \trim(\str_replace(["\r", "\n"], '', $addr[0])) . '>'; + } + + /** + * Get the message MIME type headers. + * + * @return string + * + * @since 1.0.0 + */ + private function getMailMime() : string + { + $result = ''; + $isMultipart = true; + + switch ($this->messageType) { + case 'inline': + $result .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; + $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE; + break; + case 'attach': + case 'inline_attach': + case 'alt_attach': + case 'alt_inline_attach': + $result .= 'Content-Type: ' . MimeType::M_MIXED . ';' . self::$LE; + $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE; + break; + case 'alt': + case 'alt_inline': + $result .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE; + $result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE; + break; + default: + // Catches case 'plain': and case '': + $result .= 'Content-Type: ' . $this->contentType . '; charset=' . $this->charset . self::$LE; + $isMultipart = false; + break; + } + + // RFC1341 part 5 says 7bit is assumed if not specified + if ($this->encoding === EncodingType::E_7BIT) { + return $result; + } + + // RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE + if ($isMultipart) { + if ($this->encoding === EncodingType::E_8BIT) { + $result .= 'Content-Transfer-Encoding: ' . EncodingType::E_8BIT . self::$LE; + } + + // quoted-printable and base64 are 7bit compatible + } else { + $result .= 'Content-Transfer-Encoding: ' . $this->encoding . self::$LE; + } + + return $result; + } + + /** + * Converts IDN in given email address to its ASCII form + * + * @param string $charset Charset + * @param string $address Email address + * + * @return string The encoded address in ASCII form + * + * @since 1.0.0 + */ + private function punyencodeAddress(string $charset, string $address) : string + { + if (empty($charset) || !EmailValidator::isValid($address)) { + return $address; + } + + $pos = \strrpos($address, '@'); + $domain = \substr($address, ++$pos); + + if (!((bool) \preg_match('/[\x80-\xFF]/', $domain)) || !\mb_check_encoding($domain, $charset)) { + return $address; + } + + $domain = \mb_convert_encoding($domain, 'UTF-8', $charset); + + $errorcode = 0; + if (defined('INTL_IDNA_VARIANT_UTS46')) { + $punycode = \idn_to_ascii($domain, $errorcode, \INTL_IDNA_VARIANT_UTS46); + } else { + $punycode = \idn_to_ascii($domain, $errorcode); + } + + if ($punycode !== false) { + return \substr($address, 0, $pos) . $punycode; + } + + return $address; + } + + /** + * Create a unique ID to use for boundaries. + * + * @return string + * + * @since 1.0.0 + */ + protected function generateId() : string + { + $len = 32; //32 bytes = 256 bits + $bytes = ''; + $bytes = \random_bytes($len); + + if ($bytes === '') { + $bytes = \hash('sha256', \uniqid((string) \mt_rand(), true), true); + } + + return \str_replace(['=', '+', '/'], '', \base64_encode(\hash('sha256', $bytes, true))); + } + + /** + * Assemble the message body. + * + * @return string Empty on failure + * + * @since 1.0.0 + */ + public function createBody() : string + { + $body = ''; + $this->uniqueid = $this->generateId(); + + $this->boundary[1] = 'b1_' . $this->uniqueid; + $this->boundary[2] = 'b2_' . $this->uniqueid; + $this->boundary[3] = 'b3_' . $this->uniqueid; + + if (!empty($this->signKeyFile)) { + $body .= $this->getMailMime() . self::$LE; + } + + $this->setWordWrap(); + + $bodyEncoding = $this->encoding; + $bodyCharSet = $this->charset; + + // Can we do a 7-bit downgrade? + if ($bodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->body))) { + $bodyEncoding = EncodingType::E_7BIT; + + //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit + $bodyCharSet = CharsetType::ASCII; + } + + // If lines are too long, and we're not already using an encoding that will shorten them, + // change to quoted-printable transfer encoding for the body part only + if ($this->encoding !== EncodingType::E_BASE64 + && ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->body)) + ) { + $bodyEncoding = EncodingType::E_QUOTED; + } + + $altBodyEncoding = $this->encoding; + $altBodyCharSet = $this->charset; + + //Can we do a 7-bit downgrade? + if ($altBodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->bodyAlt))) { + $altBodyEncoding = EncodingType::E_7BIT; + + //All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit + $altBodyCharSet = CharsetType::ASCII; + } + + //If lines are too long, and we're not already using an encoding that will shorten them, + //change to quoted-printable transfer encoding for the alt body part only + if ($altBodyEncoding !== EncodingType::E_BASE64 + && ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->bodyAlt)) + ) { + $altBodyEncoding = EncodingType::E_QUOTED; + } + + //Use this as a preamble in all multipart message types + $mimePre = 'This is a multi-part message in MIME format.' . self::$LE . self::$LE; + switch ($this->messageType) { + case 'inline': + $body .= $mimePre; + $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + $body .= $this->attachAll('inline', $this->boundary[1]); + break; + case 'attach': + $body .= $mimePre; + $body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + $body .= $this->attachAll('attachment', $this->boundary[1]); + break; + case 'inline_attach': + $body .= $mimePre; + $body .= '--' . $this->boundary[1] . self::$LE; + $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; + $body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE; + $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE; + $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, '', $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + $body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE; + $body .= $this->attachAll('attachment', $this->boundary[1]); + break; + case 'alt': + $body .= $mimePre; + $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); + $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; + $body .= $this->getBoundary( $this->boundary[1], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + + if (!empty($this->ical)) { + $method = ICALMethodType::REQUEST; + $methods = ICALMethodType::getConstants(); + + foreach ($methods as $imethod) { + if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) { + $method = $imethod; + break; + } + } + + $body .= $this->getBoundary($this->boundary[1], '', MimeType::M_ICS . '; method=' . $method, ''); + $body .= $this->encodeString($this->ical, $this->encoding) . self::$LE; + } + + $body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE; + break; + case 'alt_inline': + $body .= $mimePre; + $body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); + $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; + $body .= '--' . $this->boundary[1] . self::$LE; + $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; + $body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE; + $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE; + $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + $body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE; + $body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE; + break; + case 'alt_attach': + $body .= $mimePre; + $body .= '--' . $this->boundary[1] . self::$LE; + $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE; + $body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE . self::$LE; + $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); + $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; + $body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + + if (!empty($this->ical)) { + $method = ICALMethodType::REQUEST; + $methods = ICALMethodType::getConstants(); + + foreach ($methods as $imethod) { + if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) { + $method = $imethod; + break; + } + } + + $body .= $this->getBoundary($this->boundary[2], '', MimeType::M_ICS . '; method=' . $method, ''); + $body .= $this->encodeString($this->ical, $this->encoding); + } + + $body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE; + $body .= $this->attachAll('attachment', $this->boundary[1]); + break; + case 'alt_inline_attach': + $body .= $mimePre; + $body .= '--' . $this->boundary[1] . self::$LE; + $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE; + $body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE; + $body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding); + $body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE; + $body .= '--' . $this->boundary[2] . self::$LE; + $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE; + $body .= ' boundary="' . $this->boundary[3] . '";' . self::$LE; + $body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE; + $body .= $this->getBoundary($this->boundary[3], $bodyCharSet, MimeType::M_HTML, $bodyEncoding); + $body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE; + $body .= $this->attachAll('inline', $this->boundary[3]) . self::$LE; + $body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE; + $body .= $this->attachAll('attachment', $this->boundary[1]); + break; + default: + // Catch case 'plain' and case '', applies to simple `text/plain` and `text/html` body content types + $this->encoding = $bodyEncoding; + $body .= $this->encodeString($this->body, $this->encoding); + break; + } + + if (!empty($this->signKeyFile)) { + if (!defined('PKCS7_TEXT')) { + return ''; + } + + $file = \tempnam(\sys_get_temp_dir(), 'srcsign'); + $signed = \tempnam(\sys_get_temp_dir(), 'mailsign'); + \file_put_contents($file, $body); + + //Workaround for PHP bug https://bugs.php.net/bug.php?id=69197 + $sign = empty($this->signExtracertFiles) + ? \openssl_pkcs7_sign($file, $signed, + 'file://' . \realpath($this->signCertFile), + ['file://' . \realpath($this->signKeyFile), $this->signKeyPass], + [] + ) + : \openssl_pkcs7_sign($file, $signed, + 'file://' . \realpath($this->signCertFile), + ['file://' . \realpath($this->signKeyFile), $this->signKeyPass], + [], + PKCS7_DETACHED, + $this->signExtracertFiles + ); + + \unlink($file); + if ($sign === false) { + \unlink($signed); + return ''; + } + + $body = \file_get_contents($signed); + \unlink($signed); + + //The message returned by openssl contains both headers and body, so need to split them up + $parts = \explode("\n\n", $body, 2); + $this->headerMime .= $parts[0] . self::$LE . self::$LE; + $body = $parts[1]; + } + + return $body; + } + + /** + * Return the start of a message boundary. + * + * @param string $boundary Boundary + * @param string $charset Charset + * @param string $contentType Content type + * @param string $encoding Concoding + * + * @return string + * + * @since 1.0.0 + */ + protected function getBoundary(string $boundary, string $charset, string $contentType, string $encoding) : string + { + $result = ''; + if ($charset === '') { + $charset = $this->charset; + } + + if ($contentType === '') { + $contentType = $this->contentType; + } + + if ($encoding === '') { + $encoding = $this->encoding; + } + + $result .= '--' . $boundary . self::$LE; + $result .= \sprintf('Content-Type: %s; charset=%s', $contentType, $charset); + $result .= self::$LE; + + // RFC1341 part 5 says 7bit is assumed if not specified + if ($encoding !== EncodingType::E_7BIT) { + $result .= 'Content-Transfer-Encoding: ' . $encoding . self::$LE; + } + + return $result . self::$LE; + } + + /** + * Attach all file, string, and binary attachments to the message. + * + * @param string $dispositionType Disposition type + * @param string $boundary Boundary string + * + * @return string + * + * @since 1.0.0 + */ + protected function attachAll(string $dispositionType, string $boundary) : string + { + $mime = []; + $cidUniq = []; + $incl = []; + + $attachments = $this->getAttachments(); + foreach ($attachments as $attachment) { + if ($attachment[6] !== $dispositionType) { + continue; + } + + $bString = $attachment[5]; + $string = $bString ? $attachment[0] : ''; + $path = !$bString ? $attachment[0] : ''; + + $inclHash = \hash('sha256', \serialize($attachment)); + if (\in_array($inclHash, $incl, true)) { + continue; + } + + $incl[] = $inclHash; + $name = $attachment[2]; + $encoding = $attachment[3]; + $type = $attachment[4]; + $disposition = $attachment[6]; + $cid = $attachment[7]; + + if ($disposition === 'inline' && isset($cidUniq[$cid])) { + continue; + } + + $cidUniq[$cid] = true; + $mime[] = \sprintf('--%s%s', $boundary, self::$LE); + + //Only include a filename property if we have one + $mime[] = !empty($name) + ? \sprintf('Content-Type: %s; name=%s%s', + $type, + self::quotedString($this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name)))), + self::$LE + ) + : \sprintf('Content-Type: %s%s', + $type, + self::$LE + ); + + // RFC1341 part 5 says 7bit is assumed if not specified + if ($encoding !== EncodingType::E_7BIT) { + $mime[] = \sprintf('Content-Transfer-Encoding: %s%s', $encoding, self::$LE); + } + + //Only set Content-IDs on inline attachments + if ((string) $cid !== '' && $disposition === 'inline') { + $mime[] = 'Content-ID: <' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $cid))) . '>' . self::$LE; + } + + // Allow for bypassing the Content-Disposition header + if (!empty($disposition)) { + $encodedName = $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name))); + $mime[] = !empty($encodedName) + ? \sprintf('Content-Disposition: %s; filename=%s%s', + $disposition, + self::quotedString($encodedName), + self::$LE . self::$LE + ) + : \sprintf('Content-Disposition: %s%s', $disposition, self::$LE . self::$LE); + } else { + $mime[] = self::$LE; + } + + // Encode as string attachment + $mime[] = $bString + ? $this->encodeString($string, $encoding) + : $this->encodeFile($path, $encoding); + + $mime[] = self::$LE; + } + + $mime[] = \sprintf('--%s--%s', $boundary, self::$LE); + + return \implode('', $mime); + } + + /** + * If a string contains any "special" characters, double-quote the name, + * and escape any double quotes with a backslash. + * + * @param string $str String to quote + * + * @return string + * + * @since 1.0.0 + */ + private static function quotedString(string $str) : string + { + if (\preg_match('/[ ()<>@,;:"\/\[\]?=]/', $str)) { + return '"' . \str_replace('"', '\\"', $str) . '"'; + } + + return $str; + } + + /** + * Encode a file attachment in requested format. + * + * @param string $path Path + * @param string $encoding Encoding + * + * @return string + * + * @since 1.0.0 + */ + private function encodeFile(string $path, string $encoding = EncodingType::E_BASE64) : string + { + if (!FileUtils::isAccessible($path)) { + return ''; + } + + $fileBuffer = file_get_contents($path); + if (false === $fileBuffer) { + return ''; + } + + $fileBuffer = $this->encodeString($fileBuffer, $encoding); + + return $fileBuffer; + } + + /** + * Encode a string in requested format. + * + * @param string $str The text to encode + * @param string $encoding Encoding + * + * @return string + * + * @since 1.0.0 + */ + private function encodeString(string $str, string $encoding = EncodingType::E_BASE64) : string + { + $encoded = ''; + switch (strtolower($encoding)) { + case EncodingType::E_BASE64: + $encoded = \chunk_split(\base64_encode($str), self::STD_LINE_LENGTH, self::$LE); + break; + case EncodingType::E_7BIT: + case EncodingType::E_8BIT: + $encoded = self::normalizeBreaks($str, self::$LE); + if (\substr($encoded, -(\strlen(self::$LE))) !== self::$LE) { + $encoded .= self::$LE; + } + + break; + case EncodingType::E_BINARY: + $encoded = $str; + break; + case EncodingType::E_QUOTED: + $encoded = self::normalizeBreaks(\quoted_printable_encode($str), self::$LE); + break; + default: + return ''; + } + + return $encoded; + } + + /** + * Set message type based on content + * + * @return void + * + * @since 1.0.0 + */ + protected function setMessageType() : void + { + $type = []; + if (!empty($this->altBody)) { + $type[] = 'alt'; + } + + if ($this->hasInlineImage()) { + $type[] = 'inline'; + } + + if ($this->hasAttachment()) { + $type[] = 'attach'; + } + + $this->messageType = \implode('_', $type); + if ($this->messageType === '') { + $this->messageType = 'plain'; + } + } + + /** + * Mail has inline image + * + * @return bool + * + * @since 1.0.0 + */ + public function hasInlineImage() : bool + { + foreach ($this->attachment as $attachment) { + if ($attachment[6] === 'inline') { + return true; + } + } + + return false; + } + + /** + * Mail has attachment + * + * @return bool + * + * @since 1.0.0 + */ + public function hasAttachment() : bool + { + foreach ($this->attachment as $attachment) { + if ($attachment[6] === 'attachment') { + return true; + } + } + + return false; + } + + + /** + * Create address list + * + * @param string $type Address type + * @param array $addr Addresses + * + * @return string + * + * @since 1.0.0 + */ + public function createAddressList(string $type, array $addr) : string + { + $addresses = []; + foreach ($addr as $address) { + $addresses[] = $this->addrFormat($address); + } + + return $type . ': ' . implode(', ', $addresses) . static::$LE; + } + + /** + * Apply word wrapping + * + * @return void + * + * @return 1.0.0 + */ + public function setWordWrap() : void + { + if ($this->wordWrap < 1) { + return; + } + + switch ($this->messageType) { + case 'alt': + case 'alt_inline': + case 'alt_attach': + case 'alt_inline_attach': + $this->altBody = $this->wrapText($this->altBody, $this->wordWrap); + break; + default: + $this->body = $this->wrapText($this->body, $this->wordWrap); + break; + } + } + + /** + * Word-wrap message. + * Original written by philippe. + * + * @param string $message The message to wrap + * @param int $length The line length to wrap to + * @param bool $qpMode Use Quoted-Printable mode + * + * @return string + * + * @since 1.0.0 + */ + private function wrapText(string $message, int $length, bool $qpMode = false) : string + { + $softBreak = $qpMode ? \sprintf(' =%s', self::$LE) : self::$LE; + + // Don't split multibyte characters + $isUtf8 = \strtolower($this->charset) === CharsetType::UTF_8; + $leLen = \strlen(self::$LE); + $crlfLen = \strlen(self::$LE); + + $message = self::normalizeBreaks($message, self::$LE); + + //Remove a trailing line break + if (\substr($message, -$leLen) === self::$LE) { + $message = \substr($message, 0, -$leLen); + } + + //Split message into lines + $lines = \explode(self::$LE, $message); + + $message = ''; + foreach ($lines as $line) { + $words = \explode(' ', $line); + $buf = ''; + $firstword = true; + + foreach ($words as $word) { + if ($qpMode && \strlen($word) > $length) { + $spaceLeft = $length - \strlen($buf) - $crlfLen; + + if (!$firstword) { + if ($spaceLeft > 20) { + $len = $spaceLeft; + if ($isUtf8) { + $len = MbStringUtils::utf8CharBoundary($word, $len); + } elseif ('=' === \substr($word, $len - 1, 1)) { + --$len; + } elseif ('=' === \substr($word, $len - 2, 1)) { + $len -= 2; + } + + $part = \substr($word, 0, $len); + $word = \substr($word, $len); + $buf .= ' ' . $part; + $message .= $buf . \sprintf('=%s', self::$LE); + } else { + $message .= $buf . $softBreak; + } + + $buf = ''; + } + + while ($word !== '') { + if ($length <= 0) { + break; + } + + $len = $length; + if ($isUtf8) { + $len = MbStringUtils::utf8CharBoundary($word, $len); + } elseif (\substr($word, $len - 1, 1) === '=') { + --$len; + } elseif (\substr($word, $len - 2, 1) === '=') { + $len -= 2; + } + + $part = \substr($word, 0, $len); + $word = (string) \substr($word, $len); + + if ($word !== '') { + $message .= $part . \sprintf('=%s', self::$LE); + } else { + $buf = $part; + } + } + } else { + $bufO = $buf; + if (!$firstword) { + $buf .= ' '; + } + + $buf .= $word; + if ('' !== $bufO && \strlen($buf) > $length) { + $message .= $bufO . $softBreak; + $buf = $word; + } + } + + $firstword = false; + } + + $message .= $buf . self::$LE; + } + + return $message; + } + + /** + * Encode a header value (not including its label) optimally. + * Picks shortest of Q, B, or none. Result includes folding if needed. + * + * @param string $str Header value + * @param string $position Context + * + * @return string + * + * @since 1.0.0 + */ + public function encodeHeader(string $str, string $position = 'text') : string + { + $matchcount = 0; + switch (\strtolower($position)) { + case 'phrase': + if (!\preg_match('/[\200-\377]/', $str)) { + $encoded = \addcslashes($str, "\0..\37\177\\\""); + + return $str === $encoded && !\preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str) + ? $encoded + : "\"$encoded\""; + } + + $matchcount = \preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches); + break; + /* @noinspection PhpMissingBreakStatementInspection */ + case 'comment': + $matchcount = \preg_match_all('/[()"]/', $str, $matches); + case 'text': + default: + $matchcount += \preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches); + break; + } + + $charset = ((bool) \preg_match('/[\x80-\xFF]/', $str)) ? $this->charset : CharsetType::ASCII; + + // Q/B encoding adds 8 chars and the charset ("` =??[QB]??=`"). + $overhead = 8 + \strlen($charset); + $maxLen = $this->mailer === SubmitType::MAIL + ? self::MAIL_MAX_LINE_LENGTH - $overhead + : self::MAX_LINE_LENGTH - $overhead; + + // Select the encoding that produces the shortest output and/or prevents corruption. + if ($matchcount > \strlen($str) / 3) { + // More than 1/3 of the content needs encoding, use B-encode. + $encoding = 'B'; + } elseif ($matchcount > 0) { + // Less than 1/3 of the content needs encoding, use Q-encode. + $encoding = 'Q'; + } elseif (\strlen($str) > $maxLen) { + // No encoding needed, but value exceeds max line length, use Q-encode to prevent corruption. + $encoding = 'Q'; + } else { + // No reformatting needed + $encoding = ''; + } + + switch ($encoding) { + case 'B': + if (\strlen($str) > \mb_strlen($str, $this->charset)) { + $encoded = $this->base64EncodeWrapMB($str, "\n"); + } else { + $encoded = \base64_encode($str); + $maxLen -= $maxLen % 4; + $encoded = \trim(\chunk_split($encoded, $maxLen, "\n")); + } + $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . "?$encoding?\\1?=", $encoded); + break; + case 'Q': + $encoded = $this->encodeQ($str, $position); + $encoded = $this->wrapText($encoded, $maxLen, true); + $encoded = \str_replace('=' . self::$LE, "\n", \trim($encoded)); + $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . "?$encoding?\\1?=", $encoded); + break; + default: + return $str; + } + + return \trim(self::normalizeBreaks($encoded, self::$LE)); + } + + /** + * Encode a string using Q encoding. + * + * @param string $str Text to encode + * @param string $position Where the text is going to be used, see the RFC for what that means + * + * @return string + * + * @since 1.0.0 + */ + private function encodeQ(string $str, string $position = 'text') : string + { + $pattern = ''; + $encoded = \str_replace(["\r", "\n"], '', $str); + + switch (\strtolower($position)) { + case 'phrase': + $pattern = '^A-Za-z0-9!*+\/ -'; + break; + case 'comment': + $pattern = '\(\)"'; + case 'text': + default: + // Replace every high ascii, control, =, ? and _ characters + $pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern; + break; + } + + if (\preg_match_all("/[{$pattern}]/", $encoded, $matches) !== false) { + return \str_replace(' ', '_', $encoded); + } + + $matches = []; + // If the string contains an '=', make sure it's the first thing we replace + // so as to avoid double-encoding + $eqkey = \array_search('=', $matches[0], true); + if (false !== $eqkey) { + unset($matches[0][$eqkey]); + \array_unshift($matches[0], '='); + } + + $unique = \array_unique($matches[0]); + foreach ($unique as $char) { + $encoded = \str_replace($char, '=' . \sprintf('%02X', \ord($char)), $encoded); + } + + // Replace spaces with _ (more readable than =20) + // RFC 2047 section 4.2(2) + return \str_replace(' ', '_', $encoded); + } + + /** + * Encode and wrap long multibyte strings for mail headers + * + * @param string $str Multi-byte text to wrap encode + * @param string $linebreak string to use as linefeed/end-of-line + * + * @return string + * + * @since 1.0.0 + */ + private function base64EncodeWrapMB(string $str, string $linebreak) : string + { + $start = '=?' . $this->charset . '?B?'; + $end = '?='; + $encoded = ''; + + $mbLength = \mb_strlen($str, $this->charset); + $length = 75 - \strlen($start) - \strlen($end); + $ratio = $mbLength / \strlen($str); + $avgLength = \floor($length * $ratio * .75); + + $offset = 0; + for ($i = 0; $i < $mbLength; $i += $offset) { + $lookBack = 0; + + do { + $offset = $avgLength - $lookBack; + $chunk = \mb_substr($str, $i, $offset, $this->charset); + $chunk = \base64_encode($chunk); + ++$lookBack; + } while (\strlen($chunk) > $length); + + $encoded .= $chunk . $linebreak; + } + + return \substr($encoded, 0, -\strlen($linebreak)); + } + + + /** + * Add an attachment from a path on the filesystem. + * + * @param string $path Path + * @param string $name Overrides the attachment name + * @param string $encoding File encoding + * @param string $type Mime type; determined automatically from $path if not specified + * @param string $disposition Disposition to use + * + * @return bool + * + * @since 1.0.0 + */ + public function addAttachment( + string $path, + string $name = '', + string $encoding = EncodingType::E_BASE64, + string $type = '', + string $disposition = 'attachment' + ) : bool { + if (!FileUtils::isAccessible($path)) { + return false; + } + + // Mime from file + if ($type === '') { + $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION)); + } + + $filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME); + if ($name === '') { + $name = $filename; + } + + $this->attachment[] = [ + 0 => $path, + 1 => $filename, + 2 => $name, + 3 => $encoding, + 4 => $type, + 5 => false, // isStringAttachment + 6 => $disposition, + 7 => $name, + ]; + + return true; + } + + /** + * Return the array of attachments. + * + * @return array + */ + public function getAttachments() + { + return $this->attachment; + } + + /** + * Add a string or binary attachment (non-filesystem). + * + * @param string $string String attachment data + * @param string $filename Name of the attachment + * @param string $encoding File encoding (see $encoding) + * @param string $type File extension (MIME) type + * @param string $disposition Disposition to use + * + * @return bool + * + * @since 1.0.0 + */ + public function addStringAttachment( + string $string, + string $filename , + string $encoding = EncodingType::E_BASE64, + string $type = '', + string $disposition = 'attachment' + ) : bool { + // Mime from file + if ($type === '') { + $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION)); + } + + $this->attachment[] = [ + 0 => $string, + 1 => $filename, + 2 => FileUtils::mb_pathinfo($filename, \PATHINFO_BASENAME), + 3 => $encoding, + 4 => $type, + 5 => true, // isStringAttachment + 6 => $disposition, + 7 => 0, + ]; + + return true; + } + + /** + * Add an embedded (inline) attachment from a file. + * This can include images, sounds, and just about any other document type. + * + * @param string $path Path to the attachment + * @param string $cid Content ID of the attachment + * @param string $name Overrides the attachment name + * @param string $encoding File encoding (see $encoding) + * @param string $type File MIME type + * @param string $disposition Disposition to use + * + * @return bool + * + * @since 1.0.0 + */ + public function addEmbeddedImage( + string $path, + string $cid, + string $name = '', + string $encoding = EncodingType::E_BASE64, + string $type = '', + string $disposition = 'inline' + ) : bool { + if (!FileUtils::isAccessible($path)) { + return false; + } + + // Mime from file + if ($type === '') { + $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION)); + } + + $filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME); + if ($name === '') { + $name = $filename; + } + + // Append to $attachment array + $this->attachment[] = [ + 0 => $path, + 1 => $filename, + 2 => $name, + 3 => $encoding, + 4 => $type, + 5 => false, // isStringAttachment + 6 => $disposition, + 7 => $cid, + ]; + + return true; + } + + /** + * Add an embedded stringified attachment. + * This can include images, sounds, and just about any other document type. + * + * @param string $string The attachment binary data + * @param string $cid Content ID of the attachment + * @param string $name A filename for the attachment. Should use extension. + * @param string $encoding File encoding (see $encoding), defaults to 'base64' + * @param string $type MIME type - will be used in preference to any automatically derived type + * @param string $disposition Disposition to use + * + * @return bool + * + * @since 1.0.0 + */ + public function addStringEmbeddedImage( + string $string, + string $cid, + string $name = '', + string $encoding = EncodingType::E_BASE64, + string $type = '', + string $disposition = 'inline' + ) : bool { + // Mime from file + if ($type === '' && !empty($name)) { + $type = MimeType::extensionToMime(FileUtils::mb_pathinfo($name, \PATHINFO_EXTENSION)); + } + + // Append to $attachment array + $this->attachment[] = [ + 0 => $string, + 1 => $name, + 2 => $name, + 3 => $encoding, + 4 => $type, + 5 => true, // isStringAttachment + 6 => $disposition, + 7 => $cid, + ]; + + return true; + } + + /** + * Check if an embedded attachment is present with this cid. + * + * @param string $cid Cid + * + * @return bool + * + * @since 1.0.0 + */ + protected function cidExists(string $cid) : bool + { + foreach ($this->attachment as $attachment) { + if ($attachment[6] === 'inline' && $cid === $attachment[7]) { + return true; + } + } + + return false; + } + + /** + * Add a custom header. + * + * @param string $name Name + * @param null|string $value Value + * + * @return bool + * + * @since 1.0.0 + */ + public function addCustomHeader(string $name, string $value = null) : bool + { + $name = \trim($name); + $value = \trim($value); + + if (empty($name) || \strpbrk($name . $value, "\r\n") !== false) { + return false; + } + + $this->customHeader[] = [$name, $value]; + + return true; + } + + /** + * Returns all custom headers. + * + * @return array + * + * @since 1.0.0 + */ + public function getCustomHeaders() : array + { + return $this->customHeader; + } + + /** + * Create a message body from an HTML string. + * + * $basedir is prepended when handling relative URLs, e.g. and must not be empty + * will look for an image file in $basedir/images/a.png and convert it to inline. + * If you don't provide a $basedir, relative paths will be left untouched (and thus probably break in email) + * Converts data-uri images into embedded attachments. + * + * If you don't want to apply these transformations to your HTML, just set Body and AltBody directly. + * + * @param string $message HTML message string + * @param string $basedir Absolute path to a base directory to prepend to relative paths to images + * @param null|\Closure $advanced Internal or external text to html converter + * + * @return string + * + * @since 1.0.0 + */ + public function msgHTML(string $message, string $basedir = '', \Closure $advanced = null) + { + \preg_match_all('/(? 1 && \substr($basedir, -1) !== '/') { + $basedir .= '/'; + } + + foreach ($images[2] as $imgindex => $url) { + // Convert data URIs into embedded images + $match = []; + if (\preg_match('#^data:(image/(?:jpe?g|gif|png));?(base64)?,(.+)#', $url, $match)) { + if (\count($match) === 4 && EncodingType::E_BASE64 === $match[2]) { + $data = \base64_decode($match[3]); + } elseif ('' === $match[2]) { + $data = \rawurldecode($match[3]); + } else { + continue; + } + + $cid = \substr(\hash('sha256', $data), 0, 32) . '@phpoms.0'; // RFC2392 S 2 + if (!$this->cidExists($cid)) { + $this->addStringEmbeddedImage($data, $cid, 'embed' . $imgindex, EncodingType::E_BASE64, $match[1]); + } + + $message = \str_replace($images[0][$imgindex], $images[1][$imgindex] . '="cid:' . $cid . '"', $message); + + continue; + } + + if (!empty($basedir) + && (\strpos($url, '..') === false) + && \strpos($url, 'cid:') !== 0 + && !\preg_match('#^[a-z][a-z0-9+.-]*:?//#i', $url) + ) { + $filename = FileUtils::mb_pathinfo($url, \PATHINFO_BASENAME); + $directory = \dirname($url); + + if ('.' === $directory) { + $directory = ''; + } + + // RFC2392 S 2 + $cid = \substr(\hash('sha256', $url), 0, 32) . '@phpoms.0'; + if (\strlen($basedir) > 1 && '/' !== \substr($basedir, -1)) { + $basedir .= '/'; + } + + if (\strlen($directory) > 1 && '/' !== \substr($directory, -1)) { + $directory .= '/'; + } + + if ($this->addEmbeddedImage( + $basedir . $directory . $filename, + $cid, + $filename, + EncodingType::E_BASE64, + MimeType::extensionToMime((string) FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION)) + ) + ) { + $message = \preg_replace( + '/' . $images[1][$imgindex] . '=["\']' . \preg_quote($url, '/') . '["\']/Ui', + $images[1][$imgindex] . '="cid:' . $cid . '"', + $message + ); + } + } + } + } + + $this->contentType = MimeType::M_HTML; + $this->body = self::normalizeBreaks($message, self::$LE); + $this->bodyAlt = self::normalizeBreaks($this->html2text($message, $advanced), self::$LE); + + if (empty($this->bodyAlt)) { + // @todo: localize + $this->bodyAlt = 'This is an HTML-only message. To view it, activate HTML in your email application.' . self::$LE; + } + + return $this->body; + } + + /** + * Normalize line breaks in a string. + * + * @param string $text + * @param string $breaktype What kind of line break to use; defaults to self::$LE + * + * @return string + * + * @since 1.0.0 + */ + private static function normalizeBreaks($text, $breaktype) : string + { + $text = \str_replace(["\r\n", "\r"], "\n", $text); + + if ($breaktype !== "\n") { + $text = \str_replace("\n", $breaktype, $text); + } + + return $text; + } + + /** + * Convert an HTML string into plain text. + * + * @param string $html The HTML text to convert + * @param null|\Closure $advanced Internal or external text to html converter + * + * @return string + * + * @since 1.0.0 + */ + private function html2text(string $html, \Closure $advanced = null) : string + { + if ($advanced !== null) { + return $advanced($html); + } + + return \html_entity_decode( + \trim(\strip_tags(\preg_replace('/<(head|title|style|script)[^>]*>.*?<\/\\1>/si', '', $html))), + \ENT_QUOTES, + $this->charset + ); + } + + /** + * Set the public and private key files and password for S/MIME signing. + * + * @param string $certFile Certification file + * @param string $keyFile Key file + * @param string $keyPass Password for private key + * @param string $extracertsFile Optional path to chain certificate + * + * @return void + * + * @since 1.0.0 + */ + public function sign($certFile, $keyFile, $keyPass, $extracertsFile = '') : void + { + $this->signCertFile = $certFile; + $this->signKeyFile = $keyFile; + $this->signKeyPass = $keyPass; + $this->signExtracertFiles = $extracertsFile; + } + + /** + * Quoted-Printable-encode a DKIM header. + * + * @param string $txt Text + * + * @return string + * + * @since 1.0.0 + */ + public function dkimQP(string $txt) : string + { + $line = ''; + $len = \strlen($txt); + + for ($i = 0; $i < $len; ++$i) { + $ord = \ord($txt[$i]); + $line .= ((0x21 <= $ord) && ($ord <= 0x3A)) || $ord === 0x3C || ((0x3E <= $ord) && ($ord <= 0x7E)) + ? $txt[$i] + : '=' . \sprintf('%02X', $ord); + } + + return $line; + } + + /** + * Generate a DKIM signature. + * + * @param string $signHeader + * + * @return string The DKIM signature value + * + * @since 1.0.0 + */ + public function dkimSign(string $signHeader) : string + { + if (!defined('PKCS7_TEXT')) { + return ''; + } + + $privKeyStr = !empty($this->dkimPrivateKey) + ? $this->dkimPrivateKey + : \file_get_contents($this->dkimPrivatePath); + + $privKey = $this->dkimPass !== '' + ? \openssl_pkey_get_private($privKeyStr, $this->dkimPass) + : \openssl_pkey_get_private($privKeyStr); + + if (\openssl_sign($signHeader, $signature, $privKey, 'sha256WithRSAEncryption')) { + return \base64_encode($signature); + } + + return ''; + } + + /** + * Generate a DKIM canonicalization header. + * + * @param string $signHeader Header + * + * @return string + * + * @since 1.0.0 + */ + public function dkimHeaderC(string $signHeader) : string + { + $signHeader = self::normalizeBreaks($signHeader, "\r\n"); + $signHeader = \preg_replace('/\r\n[ \t]+/', ' ', $signHeader); + $lines = \explode("\r\n", $signHeader); + + foreach ($lines as $key => $line) { + if (\strpos($line, ':') === false) { + continue; + } + + list($heading, $value) = \explode(':', $line, 2); + $heading = \strtolower($heading); + $value = \preg_replace('/[ \t]+/', ' ', $value); + + $lines[$key] = \trim($heading, " \t") . ':' . \trim($value, " \t"); + } + + return \implode("\r\n", $lines); + } + + /** + * Generate a DKIM canonicalization body. + * + * @param string $body Message Body + * + * @return string + * + * @since 1.0.0 + */ + public function dkimBodyC(string $body) : string + { + if (empty($body)) { + return "\r\n"; + } + + $body = self::normalizeBreaks($body, "\r\n"); + + return \rtrim($body, " \r\n\t") . "\r\n"; + } + + /** + * Create the DKIM header and body in a new message header. + * + * @param string $headersLine Header lines + * @param string $subject Subject + * @param string $body Body + * + * @return string + * + * @since 1.0.0 + */ + public function dkimAdd($headersLine, $subject, $body) : string + { + $DKIMsignatureType = 'rsa-sha256'; + $DKIMcanonicalization = 'relaxed/simple'; + $DKIMquery = 'dns/txt'; + $DKIMtime = \time(); + + $autoSignHeaders = [ + 'from', + 'to', + 'cc', + 'date', + 'subject', + 'reply-to', + 'message-id', + 'content-type', + 'mime-version', + 'x-mailer', + ]; + + if (\stripos($headersLine, 'Subject') === false) { + $headersLine .= 'Subject: ' . $subject . self::$LE; + } + + $headerLines = \explode(self::$LE, $headersLine); + $currentHeaderLabel = ''; + $currentHeaderValue = ''; + $parsedHeaders = []; + $headerLineIndex = 0; + $headerLineCount = \count($headerLines); + + foreach ($headerLines as $headerLine) { + $matches = []; + if (\preg_match('/^([^ \t]*?)(?::[ \t]*)(.*)$/', $headerLine, $matches)) { + if ($currentHeaderLabel !== '') { + $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue]; + } + + $currentHeaderLabel = $matches[1]; + $currentHeaderValue = $matches[2]; + } elseif (\preg_match('/^[ \t]+(.*)$/', $headerLine, $matches)) { + $currentHeaderValue .= ' ' . $matches[1]; + } + + ++$headerLineIndex; + + if ($headerLineIndex >= $headerLineCount) { + $parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue]; + } + } + + $copiedHeaders = []; + $headersToSignKeys = []; + $headersToSign = []; + + foreach ($parsedHeaders as $header) { + if (\in_array(\strtolower($header['label']), $autoSignHeaders, true)) { + $headersToSignKeys[] = $header['label']; + $headersToSign[] = $header['label'] . ': ' . $header['value']; + + if ($this->dkimCopyHeader) { + $copiedHeaders[] = $header['label'] . ':' + . \str_replace('|', '=7C', $this->dkimQP($header['value'])); + } + + continue; + } + + if (\in_array($header['label'], $this->dkimHeaders, true)) { + foreach ($this->customHeader as $customHeader) { + if ($customHeader[0] === $header['label']) { + $headersToSignKeys[] = $header['label']; + $headersToSign[] = $header['label'] . ': ' . $header['value']; + + if ($this->dkimCopyHeader) { + $copiedHeaders[] = $header['label'] . ':' + . \str_replace('|', '=7C', $this->dkimQP($header['value'])); + } + + continue 2; + } + } + } + } + + $copiedHeaderFields = ''; + if ($this->dkimCopyHeader && \count($copiedHeaders) > 0) { + $copiedHeaderFields = ' z='; + $first = true; + + foreach ($copiedHeaders as $copiedHeader) { + if (!$first) { + $copiedHeaderFields .= self::$LE . ' |'; + } + + $copiedHeaderFields .= \strlen($copiedHeader) > self::STD_LINE_LENGTH - 3 + ? \substr( + \chunk_split($copiedHeader, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS), + 0, + -\strlen(self::$LE . self::FWS) + ) + : $copiedHeader; + + $first = false; + } + + $copiedHeaderFields .= ';' . self::$LE; + } + + $headerKeys = ' h=' . \implode(':', $headersToSignKeys) . ';' . self::$LE; + $headerValues = \implode(self::$LE, $headersToSign); + $body = $this->dkimBodyC($body); + $DKIMb64 = \base64_encode(\pack('H*', \hash('sha256', $body))); + $ident = ''; + + if ($this->dkimIdentity !== '') { + $ident = ' i=' . $this->dkimIdentity . ';' . self::$LE; + } + + $dkimSignatureHeader = 'DKIM-Signature: v=1;' + . ' d=' . $this->dkimDomain . ';' + . ' s=' . $this->dkimSelector . ';' . self::$LE + . ' a=' . $DKIMsignatureType . ';' + . ' q=' . $DKIMquery . ';' + . ' t=' . $DKIMtime . ';' + . ' c=' . $DKIMcanonicalization . ';' . self::$LE + . $headerKeys . $ident . $copiedHeaderFields + . ' bh=' . $DKIMb64 . ';' . self::$LE + . ' b='; + + $canonicalizedHeaders = $this->dkimHeaderC( + $headerValues . self::$LE . $dkimSignatureHeader + ); + + $signature = $this->dkimSign($canonicalizedHeaders); + $signature = \trim(\chunk_split($signature, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS)); + + return self::normalizeBreaks($dkimSignatureHeader . $signature, self::$LE); + } +} diff --git a/Message/Mail/EmailAbstract.php b/Message/Mail/EmailAbstract.php deleted file mode 100644 index 78964b53a..000000000 --- a/Message/Mail/EmailAbstract.php +++ /dev/null @@ -1,669 +0,0 @@ -host = $host; - $this->port = $port; - $this->timeout = $timeout; - $this->ssl = $ssl; - - \imap_timeout(\IMAP_OPENTIMEOUT, $timeout); - \imap_timeout(\IMAP_READTIMEOUT, $timeout); - \imap_timeout(\IMAP_WRITETIMEOUT, $timeout); - \imap_timeout(\IMAP_CLOSETIMEOUT, $timeout); - } - - /** - * Decode - * - * @param string $content Content to decode - * @param int $encoding Encoding type - * - * @return string - * - * @since 1.0.0 - */ - public static function decode(string $content, string $encoding) - { - if ($encoding === EncodingType::E_BASE64) { - return \imap_base64($content); - } elseif ($encoding === EncodingType::E_8BIT) { - return \imap_8bit($content); - } - - return \imap_qprint($content); - } - - /** - * Descrutor - * - * @since 1.0.0 - */ - public function __destruct() - { - $this->disconnect(); - } - - /** - * Disconnect server - * - * @return void - * - * @since 1.0.0 - */ - public function disconnect() : void - { - if ($this->con !== null) { - \imap_close($this->con); - $this->con = null; - } - } - - /** - * Connect to server - * - * @param string $user Username - * @param string $pass Password - * - * @return bool - * - * @since 1.0.0 - */ - public function connect(string $user = '', string $pass = '') : bool - { - $this->mailbox = \substr($this->mailbox, 0, -1) . ($this->ssl ? '/ssl/validate-cert' : '/novalidate-cert') . '}'; - - try { - $this->con = \imap_open($this->mailbox . 'INBOX', $user, $pass); - - return true; - } catch (\Throwable $t) { - $this->con = null; - - return false; - } - } - - /** - * Test connection - * - * @return bool - * - * @since 1.0.0 - */ - public function isConnected() : bool - { - return $this->con === null ? false : \imap_ping($this->con); - } - - /** - * Set submit type/software - * - * @param string $submitType Submit type/software - * - * @return void - * - * @since 1.0.0 - */ - public function setSubmitType(string $submitType) : void - { - $this->submitType = $submitType; - - if ($this->submitType === SubmitType::SMTP - || $this->submitType === SubmitType::MAIL - ) { - $this->endOfLine = $this->submitType === SubmitType::SMTP || !\stripos(\PHP_OS, 'WIN') === 0 ? \PHP_EOL : "\r\n"; - } elseif ($this->submitType === SubmitType::SENDMAIL) { - $this->endOfLine = \PHP_EOL; - $path = \ini_get('sendmail_path'); - - $this->sendmailPath = \stripos($path, 'sendmail') === false ? '/usr/sbin/sendmail' : $path; - } elseif ($this->submitType === SubmitType::QMAIL) { - $this->endOfLine = \PHP_EOL; - $path = \ini_get('sendmail_path'); - - $this->sendmailPath = \stripos($path, 'qmail') === false ? '/var/qmail/bin/qmail-inject' : $path; - } - } - - /** - * Send a Mail - * - * @param Mail $mail Mail to send - * - * @return bool - * - * @since 1.0.0 - */ - public function send(Mail $mail) : bool - { - if (empty($mail->getTo())) { - return false; - } - - $this->preSend($mail); - $this->postSend($mail); - - return true; - } - - private function preSend(Mail $mail) : bool - { - $this->endOfLine = $this->submitType === SubmitType::SMTP - || ($this->submitType === SubmitType::MAIL && \stripos(\PHP_OS, 'WIN') === 0) - ? "\r\n" : \PHP_EOL; - - $this->setMessageType(); - - if ($this->submitType === SubmitType::MAIL) { - $this->header .= 'to: ' . $this->addrAppend('to', $this->to); - $this->header .= $this->headerLine('Subject', $this->encodeHeader($this->secureHeader($this->subject))); - } - - return true; - } - - private function postSend(Mail $mail) : bool - { - return true; - } - - /** - * Get boxes. - * - * @param string $pattern Pattern for boxes - * - * @return array - * - * @since 1.0.0 - */ - public function getBoxes(string $pattern = '*') : array - { - $list = \imap_list($this->con, $this->host, $pattern); - - return $list === false ? [] : $list; - } - - /** - * Get inbox quota. - * - * @return array - * - * @since 1.0.0 - */ - public function getQuota() : array - { - $quota = []; - - try { - $quota = \imap_get_quotaroot($this->con, "INBOX"); - } finally { - return $quota === false ? [] : $quota; - } - } - - /** - * Get email. - * - * @param string $id mail id - * - * @return Mail - * - * @since 1.0.0 - */ - public function getEmail(string $id) : Mail - { - $mail = new Mail($id); - - if ((int) $id > $this->countMessages()) { - return $mail; - } - - $mail->setOverview(\imap_fetch_overview($this->con, $id)); - $mail->setBody(\imap_fetchbody($this->con, (int) $id, '1.1')); - //$mail->setEncoding(\imap_fetchstructure($this->con, (int) $id)); - - return $mail; - } - - /** - * Get all inbox messages. - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxAll() : array - { - return $this->getInboxOverview('ALL'); - } - - /** - * Get inbox overview. - * - * @param string $option Inbox option (imap_search creterias) - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxOverview(string $option = 'ALL') : array - { - $ids = \imap_search($this->con, $option, \SE_FREE, 'UTF-8'); - - return \is_array($ids) ? \imap_fetch_overview($this->con, \implode(',', $ids)) : []; - } - - /** - * Get all new inbox messages. - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxNew() : array - { - return $this->getInboxOverview('NEW'); - } - - /** - * Get all inbox messages from a person. - * - * @param string $from Messages from - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxFrom(string $from) : array - { - return $this->getInboxOverview('FROM "' . $from . '"'); - } - - /** - * Get all inbox messages to a person. - * - * @param string $to Messages to - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxTo(string $to) : array - { - return $this->getInboxOverview('TO "' . $to . '"'); - } - - /** - * Get all inbox messages cc a person. - * - * @param string $cc Messages cc - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxCc(string $cc) : array - { - return $this->getInboxOverview('CC "' . $cc . '"'); - } - - /** - * Get all inbox messages bcc a person. - * - * @param string $bcc Messages bcc - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxBcc(string $bcc) : array - { - return $this->getInboxOverview('BCC "' . $bcc . '"'); - } - - /** - * Get all answered inbox messages. - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxAnswered() : array - { - return $this->getInboxOverview('ANSWERED'); - } - - /** - * Get all inbox messages with a certain subject. - * - * @param string $subject Subject - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxSubject(string $subject) : array - { - return $this->getInboxOverview('SUBJECT "' . $subject . '"'); - } - - /** - * Get all inbox messages from a certain date onwards. - * - * @param \DateTime $since Messages since - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxSince(\DateTime $since) : array - { - return $this->getInboxOverview('SINCE "' . $since->format('d-M-Y') . '"'); - } - - /** - * Get all unseen inbox messages. - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxUnseen() : array - { - return $this->getInboxOverview('UNSEEN'); - } - - /** - * Get all seen inbox messages. - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxSeen() : array - { - return $this->getInboxOverview('SEEN'); - } - - /** - * Get all deleted inbox messages. - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxDeleted() : array - { - return $this->getInboxOverview('DELETED'); - } - - /** - * Get all inbox messages with text. - * - * @param string $text Text in message body - * - * @return array - * - * @since 1.0.0 - */ - public function getInboxText(string $text) : array - { - return $this->getInboxOverview('TEXT "' . $text . '"'); - } - - /** - * Create mailbox - * - * @param string $mailbox Mailbox to create - * - * @return bool - * - * @since 1.0.0 - */ - public function createMailbox(string $mailbox) : bool - { - return \imap_createmailbox($this->con, $mailbox); - } - - /** - * Rename mailbox - * - * @param string $old Old mailbox name - * @param string $new New mailbox name - * - * @return bool - * - * @since 1.0.0 - */ - public function renameMailbox(string $old, string $new) : bool - { - return \imap_renamemailbox($this->con, $old, $new); - } - - /** - * Delete mailbox - * - * @param string $mailbox Mailbox to delete - * - * @return bool - * - * @since 1.0.0 - */ - public function deleteMailbox(string $mailbox) : bool - { - return \imap_deletemailbox($this->con, $mailbox); - } - - /** - * Check message to delete - * - * @param int $id Message id - * - * @return bool - * - * @since 1.0.0 - */ - public function deleteMessage(int $id) : bool - { - return \imap_delete($this->con, $id); - } - - /** - * Delete all marked messages - * - * @return bool - * - * @since 1.0.0 - */ - public function deleteMarkedMessages() : bool - { - return \imap_expunge($this->con); - } - - /** - * Check message to delete - * - * @param int $length Amount of message overview - * @param int $start Start index of the overview for pagination - * - * @return array - * - * @since 1.0.0 - */ - public function getMessageOverview(int $length = 0, int $start = 1) : array - { - if ($length === 0) { - $info = \imap_check($this->con); - $length = $info->Nmsgs; - } - - return \imap_fetch_overview($this->con, $start . ':' . ($length - 1 + $start), 0); - } - - /** - * Count messages - * - * @return int - * - * @since 1.0.0 - */ - public function countMessages() : int - { - return \imap_num_msg($this->con); - } - - /** - * Get message header - * - * @param int $id Message id - * - * @return string - * - * @since 1.0.0 - */ - public function getMessageHeader(int $id) : string - { - if ($id > $this->countMessages()) { - return ''; - } - - return \imap_fetchheader($this->con, $id); - } - - /** - * Set the OAuth connection - * - * @param OAuth $oauth OAuth - * - * @return void - * - * @since 1.0.0 - */ - public function setOAuth($oauth) : void - { - $this->oauth = $oauth; - } -} diff --git a/Message/Mail/EncodingType.php b/Message/Mail/EncodingType.php index 763d30462..82b15d09e 100644 --- a/Message/Mail/EncodingType.php +++ b/Message/Mail/EncodingType.php @@ -19,7 +19,7 @@ use phpOMS\Stdlib\Base\Enum; /** * Encoding enum. * - * @package phpOMS\Message\Mail + * @package phpOMS\Message\Mail * @license OMS License 1.0 * @link https://orange-management.org * @since 1.0.0 diff --git a/Message/Mail/EncryptionType.php b/Message/Mail/EncryptionType.php index 5c0a339d1..b60c818ca 100644 --- a/Message/Mail/EncryptionType.php +++ b/Message/Mail/EncryptionType.php @@ -19,14 +19,16 @@ use phpOMS\Stdlib\Base\Enum; /** * Encryption enum. * - * @package phpOMS\Message\Mail + * @package phpOMS\Message\Mail * @license OMS License 1.0 * @link https://orange-management.org * @since 1.0.0 */ abstract class EncryptionType extends Enum { + public const NONE = ''; + public const TLS = 'tls'; - public const SSL = 'ssl'; + public const SMTPS = 'ssl'; } diff --git a/Message/Mail/HeaderContext.php b/Message/Mail/HeaderContext.php index 0645f2da7..4e0f29984 100644 --- a/Message/Mail/HeaderContext.php +++ b/Message/Mail/HeaderContext.php @@ -19,7 +19,7 @@ use phpOMS\Stdlib\Base\Enum; /** * Submit enum. * - * @package phpOMS\Message\Mail + * @package phpOMS\Message\Mail * @license OMS License 1.0 * @link https://orange-management.org * @since 1.0.0 diff --git a/Message/Mail/ICALMethodType.php b/Message/Mail/ICALMethodType.php index f4213f52b..339effc48 100644 --- a/Message/Mail/ICALMethodType.php +++ b/Message/Mail/ICALMethodType.php @@ -19,7 +19,7 @@ use phpOMS\Stdlib\Base\Enum; /** * Calendar message types enum. * - * @package phpOMS\Message\Mail + * @package phpOMS\Message\Mail * @license OMS License 1.0 * @link https://orange-management.org * @since 1.0.0 diff --git a/Message/Mail/Imap.php b/Message/Mail/Imap.php index 839db59bf..b9088b779 100644 --- a/Message/Mail/Imap.php +++ b/Message/Mail/Imap.php @@ -22,22 +22,142 @@ namespace phpOMS\Message\Mail; * @link https://orange-management.org * @since 1.0.0 */ -class Imap extends EmailAbstract +class Imap extends MailHandler implements MailBoxInterface { /** - * Connect to server + * Destructor. * - * @param string $user Username - * @param string $pass Password + * @since 1.0.0 + */ + public function __destruct() + { + $this->inboxClose(); + parent::__destruct(); + } + + /** + * Connect to server * * @return bool * * @since 1.0.0 */ - public function connect(string $user = '', string $pass = '') : bool + public function connectInbox() : bool { - $this->mailbox = '{' . $this->host . ':' . $this->port . '/imap}'; + $this->mailbox = ($tmp = \imap_open( + '{' + . $this->host . ':' . $this->port . '/imap' + . ($this->encryption !== EncryptionType::NONE ? '/ssl' : '') + . '}', + $this->username, $this->password + ) === false) ? null : $tmp; + } - return parent::connect($user, $pass); + public function getBoxes() : array + { + $list = \imap_list($this->mailbox, '{' . $this->host . ':' . $this->port . '}'); + if (!\is_array($list)) { + return []; + } + + foreach ($list as $key => $value) { + $list[$key] = \imap_utf7_decode($value); + } + + return $list; + } + + public function renameBox(string $old, string $new) : bool + { + return \imap_renamemailbox($this->mailbox, $old, \imap_utf7_encode($new)); + } + + public function deleteBox(string $box) : bool + { + return \imap_deletemailbox($this->mailbox, $box); + } + + public function createBox(string $box) : bool + { + return \imap_createmailbox($this->mailbox, \imap_utf7_encode($box)); + } + + public function countMail(string $box) : int + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_num_msg($this->mailbox); + } + + public function getMailboxInfo(string $box) : object + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_status($this->mailbox); + } + + public function getRecentCount(string $box) : int + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_num_recent($this->mailbox); + } + + public function copyMail(string|array $messages, string $box) : bool + { + return \imap_mail_copy($this->mailbox, !\is_string($messages) ? \implode(',', $messages) : $messages, $box); + } + + public function moveMail(string|array $messages, string $box) : bool + { + return \imap_mail_copy($this->mailbox, !\is_string($messages) ? \implode(',', $messages) : $messages, $box); + } + + public function deleteMail(int $msg) : bool + { + return \imap_delete($this->mailbox, $msg); + } + + public function getHeaders(string $box) : array + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_headers($this->mailbox); + } + + public function getHeaderInfo(int $msg) : object + { + return \imap_headerinfo($this->mailbox, $msg); + } + + public function getMail(int $msg) : Email + { + return new Email(); + } + + /** + * Close mailbox + * + * @return void + * + * @since 1.0.0 + */ + public function inboxClose() : void + { + if ($this->mailbox !== null) { + \imap_close($this->mailbox); + } } } diff --git a/Message/Mail/Mail.php b/Message/Mail/Mail.php deleted file mode 100644 index 469617a80..000000000 --- a/Message/Mail/Mail.php +++ /dev/null @@ -1,1511 +0,0 @@ -id = $id; - } - - /** - * Set body. - * - * @param string $body Mail body - * - * @return void - * - * @since 1.0.0 - */ - public function setBody(string $body) : void - { - $this->body = $body; - } - - /** - * Set body alt. - * - * @param string $body Mail body - * - * @return void - * - * @since 1.0.0 - */ - public function setBodyAlt(string $body) : void - { - $this->bodyAlt = $body; - $this->contentType = empty($body) ? MimeType::M_TXT : MimeType::M_ALT; - } - - /** - * Set body. - * - * @param array $overview Mail overview - * - * @return void - * - * @since 1.0.0 - */ - public function setOverview(array $overview) : void - { - $this->overview = $overview; - } - - /** - * Set encoding. - * - * @param int $encoding Mail encoding - * - * @return void - * - * @since 1.0.0 - */ - public function setEncoding(int $encoding) : void - { - $this->encoding = $encoding; - } - - /** - * Set content type. - * - * @param int $contentType Mail content type - * - * @return void - * - * @since 1.0.0 - */ - public function setContentType(int $contentType) : void - { - $this->contentType = empty($this->bodyAlt) ? $contentType : MimeType::M_ALT; - } - - /** - * Set subject - * - * @param string $subject Subject - * - * @return void - * - * @since 1.0.0 - */ - public function setSubject(string $subject) : void - { - $this->subject = \trim($subject); - } - - /** - * Set the from address - * - * @param string $mail Mail address - * @param string $name Name - * - * @return bool - * - * @since 1.0.0 - */ - public function setFrom(string $mail, string $name = '') : bool - { - $mail = $this->normalizeEmailAddress($mail); - $name = $this->normalizeName($name); - - if ($mail === null) { - return false; - } - - $this->from = $mail; - $this->fromName = $name; - - return true; - } - - /** - * Add a to address - * - * @param string $mail Mail address - * @param string $name Name - * - * @return bool - * - * @since 1.0.0 - */ - public function addTo(string $mail, string $name = '') : bool - { - $mail = $this->normalizeEmailAddress($mail); - $name = \trim($name); - - if ($mail === null) { - return false; - } - - $this->to[$mail] = $name; - - return true; - } - - /** - * Get to addresses - * - * @return array - * - * @since 1.0.0 - */ - public function getTo() : array - { - return $this->to; - } - - /** - * Add a cc address - * - * @param string $mail Mail address - * @param string $name Name - * - * @return bool - * - * @since 1.0.0 - */ - public function addCc(string $mail, string $name = '') : bool - { - $mail = $this->normalizeEmailAddress($mail); - $name = \trim($name); - - if ($mail === null) { - return false; - } - - $this->cc[$mail] = $name; - - return true; - } - - /** - * Add a bcc address - * - * @param string $mail Mail address - * @param string $name Name - * - * @return bool - * - * @since 1.0.0 - */ - public function addBcc(string $mail, string $name = '') : bool - { - $mail = $this->normalizeEmailAddress($mail); - $name = \trim($name); - - if ($mail === null) { - return false; - } - - $this->bcc[$mail] = $name; - - return true; - } - - /** - * Add an attachment - * - * @param string $path Path to the file - * @param string $name Name of the file - * @param string $encoding Encoding - * @param string $type Mime type - * @param string $disposition Disposition - * - * @return bool - * - * @since 1.0.0 - */ - public function addAttachment( - string $path, - string $name = '', - string $encoding = EncodingType::E_BASE64, - string $type = '', - string $disposition = DispositionType::ATTACHMENT - ) : bool { - if ((bool) \preg_match('#^[a-z]+://#i', $path)) { - return false; - } - - $this->attachment[] = [ - 'path' => $path, - 'filename' => \basename($path), - 'name' => $name, - 'encoding' => $encoding, - 'type' => $type, - 'string' => false, - 'disposition' => $disposition, - 'id' => $name, - ]; - - return true; - } - - /** - * Add string attachment - * - * @return bool - * - * @since 1.0.0 - */ - public function addStringAttachment( - string $string, - string $filename, - string $encoding = EncodingType::E_BASE64, - string $type = '', - string $disposition = DispositionType::ATTACHMENT - ) : bool { - $type = $type === '' ? MimeType::getByName('M_' . \strtoupper(File::extension($filename))) : $type; - - $this->attachment[] = [ - 'path' => $string, - 'filename' => $filename, - 'name' => \basename($filename), - 'encoding' => $encoding, - 'type' => $type, - 'string' => true, - 'disposition' => $disposition, - 'id' => 0, - ]; - - return true; - } - - /** - * Add inline image - * - * @return bool - * - * @since 1.0.0 - */ - public function addEmbeddedImage( - string $path, - string $cid, - string $name = '', - string $encoding = EncodingType::E_BASE64, - string $type = '', - string $disposition = DispositionType::INLINE - ) : bool { - if ((bool) \preg_match('#^[a-z]+://#i', $path)) { - return false; - } - - $type = $type === '' ? MimeType::getByName('M_' . \strtoupper(File::extension($path))) : $type; - $filename = \basename($path); - - $this->attachment[] = [ - 'path' => $path, - 'filename' => $filename, - 'name' => empty($name) ? $filename : $name, - 'encoding' => $encoding, - 'type' => $type, - 'string' => false, - 'disposition' => $disposition, - 'id' => $cid, - ]; - - return true; - } - - /** - * Add inline image attachment - * - * @return bool - * - * @since 1.0.0 - */ - public function addStringEmbeddedImage( - string $string, - string $cid, - string $name = '', - string $encoding = EncodingType::E_BASE64, - string $type = '', - string $disposition = DispositionType::INLINE - ) : bool { - $type = $type === '' ? MimeType::getByName('M_' . \strtoupper(File::extension($name))) : $type; - - $this->attachment[] = [ - 'path' => $string, - 'filename' => $name, - 'name' => $name, - 'encoding' => $encoding, - 'type' => $type, - 'string' => true, - 'disposition' => $disposition, - 'id' => $cid, - ]; - - return true; - } - - /** - * The email should be confirmed by the receivers - * - * @param string $confirm Should be confirmed? - * - * @return void - * - * @sicne 1.0.0 - */ - public function shouldBeConfirmed(bool $confirm = true) : void - { - $this->confirmReading = $confirm; - } - - /** - * Normalize an email address - * - * @param string $mail Mail address - * - * @return null|string - * - * @since 1.0.0 - */ - private function normalizeEmailAddress(string $mail) : ?string - { - $mail = \trim($mail); - $pos = \strrpos($mail, '@'); - - if ($pos === false || !\filter_var($mail, \FILTER_VALIDATE_EMAIL)) { - return null; - } - - $domain = \substr($mail, ++$pos); - if (!((bool) \preg_match('/[\x80-\xFF]/', $domain))) { - return $mail; - } - - $domain = \mb_convert_encoding($domain, 'UTF-8', $this->charset); - $normalized = \idn_to_ascii($mail); - - return $normalized === false ? $mail : \substr($domain, 0, $pos) . $normalized; - } - - /** - * Normalize an email name - * - * @param string $name Name - * - * @return string - * - * @since1 1.0.0 - */ - private function normalizeName(string $name) : string - { - return \trim(\preg_replace("/[\r\n]+/", '', $name)); - } - - /** - * Parsing an email containing a name - * - * @param string $mail Mail string - * - * @return array - * - * @since 1.0.0 - */ - private function parseEmailAddress(string $mail) : array - { - $addresses = []; - $list = \explode(',', $mail); - - foreach ($list as $address) { - $address = \trim($address); - - if (\stripos($address, '<') === false) { - if (($address = $this->normalizeEmailAddress($address)) !== null) { - $addresses[] = [ - 'name' => '', - 'address' => $address, - ]; - } - } else { - $parts = \explode('<', $address); - $address = \trim(\str_replace('>', '', $parts[1])); - - if (($address = $this->normalizeEmailAddress($address)) !== null) { - $addresses[] = [ - 'name' => \trim(\str_replace(['"', '\''], '', $parts[0])), - 'address' => $address, - ]; - } - } - } - - return $addresses; - } - - /** - * Define the message type based on the content - * - * @return void - * - * @since 1.0.0 - */ - private function setMessageType() : void - { - $this->messageType = ''; - - $type = []; - if (!empty($this->bodyAlt)) { - $type[] = DispositionType::ALT; - } - - foreach ($this->attachment as $attachment) { - if ($attachment['disposition'] === DispositionType::INLINE) { - $type[] = DispositionType::INLINE; - } elseif ($attachment['disposition'] === DispositionType::ATTACHMENT) { - $type[] = DispositionType::ATTACHMENT; - } - } - - $this->messageType = \implode('_', $type); - $this->messageType = empty($this->messageType) ? DispositionType::PLAIN : $this->messageType; - } - - /** - * Create the mail body - * - * @return string - * - * @since 1.0.0 - */ - public function createBody() : string - { - $this->id = empty($this->id) ? $this->generatedId() : $this->id; - - $output = ''; - $boundary = []; - $boundary[0] = 'b0_' . $this->id; - $boundary[1] = 'b1_' . $this->id; - $boundary[2] = 'b2_' . $this->id; - $boundary[3] = 'b3_' . $this->id; - - $output .= !empty($this->signKeyFile) ? $this->generateMimeHeader($boundary) . $this->endOfLine : ''; - - $body = $this->wrapText($this->body, $this->wordWrap, false); - $bodyEncoding = $this->encoding; - $bodyCharset = $this->charset; - - if ($bodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $body))) { - $bodyEncoding = EncodingType::E_7BIT; - $bodyCharset = CharsetType::ASCII; - } - - if ($this->encoding !== EncodingType::E_BASE64 && ((bool) \preg_match('/^(.{' . (63 + \strlen($this->endOfLine)) . ',})/m', $body))) { - $bodyEncoding = EncodingType::E_QUOTED; - } - - $bodyAlt = $this->wrapText($this->bodyAlt, $this->wordWrap, false); - $bodyAltEncoding = $this->encoding; - $bodyAltCharset = $this->charset; - - if ($bodyAlt !== '') { - if ($bodyAltEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $bodyAlt))) { - $bodyAltEncoding = EncodingType::E_7BIT; - $bodyAltCharset = CharsetType::ASCII; - } - - if ($this->encoding !== EncodingType::E_BASE64 && ((bool) \preg_match('/^(.{' . (63 + \strlen($this->endOfLine)) . ',})/m', $bodyAlt))) { - $bodyAltEncoding = EncodingType::E_QUOTED; - } - } - - $mimeBody = 'This is a multi-part message in MIME format.' . $this->endOfLine . $this->endOfLine; - - switch ($this->messageType) { - case DispositionType::INLINE: - case DispositionType::ATTACHMENT: - $body .= $mimeBody; - $body .= $this->getBoundary($boundary[0], $bodyCharset, $this->contentType, $bodyEncoding); - $body .= $this->encodeString($this->body, $bodyEncoding); - $body .= $this->endOfLine; - $body .= $this->attachAll($this->messageType, $boundary[0]); - break; - case DispositionType::INLINE . '_' . DispositionType::ATTACHMENT: - $body .= $mimeBody; - $body .= '--' . $boundary[0] . $this->endOfLine; - $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . $this->endOfLine; - $body .= ' boundary ="' . $boundary[1] . '";' . $this->endOfLine; - $body .= ' type ="' . MimeType::M_HTML . '";' . $this->endOfLine; - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[1], $bodyCharset, $this->contentType, $bodyEncoding); - $body .= $this->encodeString($this->body, $bodyEncoding); - $body .= $this->endOfLine; - $body .= $this->attachAll(DispositionType::INLINE, $boundary[1]); - $body .= $this->endOfLine; - $body .= $this->attachAll(DispositionType::ATTACHMENT, $boundary[1]); - break; - case DispositionType::ALT: - $body .= $mimeBody; - $body .= $this->getBoundary($boundary[0], $bodyAltCharset, MimeType::M_TEXT, $bodyAltEncoding); - $body .= $this->encodeString($this->bodyAlt, $bodyAltEncoding); - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[0], $bodyCharset, MimeType::M_HTML, $bodyEncoding); - $body .= $this->encodeString($this->body, $bodyEncoding); - $body .= $this->endOfLine; - - if (!empty($this->icalBody)) { - $method = ICALMethodType::REQUEST; - $constants = ICALMethodType::getConstants(); - - foreach ($constants as $enum) { - if (\stripos($this->icalBody, 'METHOD:' . $enum) !== false - || \stripos($this->icalBody, 'METHOD: ' . $enum) !== false - ) { - $method = $enum; - break; - } - } - - $body .= $this->getBoundary($boundary[0], $this->charset, MimeType::M_ICS . '; method=' . $method, $this->encoding); - $body .= $this->encodeString($this->icalBody, $this->encoding); - $body .= $this->endOfLine; - } - - $body .= $this->endOfLine . '--' . $boundary[0] . '--' . $this->endOfLine; - break; - case DispositionType::ALT . '_' . DispositionType::INLINE: - $body .= $mimeBody; - $body .= $this->getBoundary($boundary[0], $bodyAltCharset, MimeType::M_TEXT, $bodyAltEncoding); - $body .= $this->encodeString($this->bodyAlt, $bodyAltEncoding); - $body .= $this->endOfLine; - $body .= '--' . $boundary[0] . $this->endOfLine; - $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . $this->endOfLine; - $body .= ' boundary="' . $boundary[1] . '";' . $this->endOfLine; - $body .= ' type="' . MimeType::M_HTML . '";' . $this->endOfLine; - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[1], $bodyCharset, MimeType::M_HTML, $bodyEncoding); - $body .= $this->encodeString($this->body, $bodyEncoding); - $body .= $this->endOfLine; - $body .= $this->attachAll(DispositionType::INLINE, $boundary[1]); - $body .= $this->endOfLine; - $body .= $this->endOfLine . '--' . $boundary[0] . '--' . $this->endOfLine; - break; - case DispositionType::ALT . '_' . DispositionType::ATTACHMENT: - $body .= $mimeBody; - $body .= '--' . $boundary[0] . $this->endOfLine; - $body .= 'Content-Type: ' . MimeType::M_ALT . ';' . $this->endOfLine; - $body .= ' boundary="' . $boundary[1] . '"' . $this->endOfLine; - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[1], $bodyAltCharset, MimeType::M_TEXT, $bodyAltEncoding); - $body .= $this->encodeString($this->bodyAlt, $bodyAltEncoding); - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[1], $bodyCharset, MimeType::M_HTML, $bodyEncoding); - $body .= $this->encodeString($this->body, $bodyEncoding); - $body .= $this->endOfLine; - - if (!empty($this->icalBody)) { - $method = ICALMethodType::REQUEST; - $constants = ICALMethodType::getConstants(); - - foreach ($constants as $enum) { - if (\stripos($this->icalBody, 'METHOD:' . $enum) !== false - || \stripos($this->icalBody, 'METHOD: ' . $enum) !== false - ) { - $method = $enum; - break; - } - } - - $body .= $this->getBoundary($boundary[1], $this->charset, MimeType::M_ICS . '; method=' . $method, $this->encoding); - $body .= $this->encodeString($this->icalBody, $this->encoding); - } - - $body .= $this->endOfLine . '--' . $boundary[1] . '--' . $this->endOfLine; - $body .= $this->endOfLine; - $body .= $this->attachAll(DispositionType::ATTACHMENT, $boundary[0]); - break; - case DispositionType::ALT . '_' . DispositionType::INLINE . '_' . DispositionType::ATTACHMENT: - $body .= $mimeBody; - $body .= '--' . $boundary[0] . $this->endOfLine; - $body .= 'Content-Type: ' . MimeType::M_ALT . $this->endOfLine; - $body .= ' boundary="' . $boundary[1] . '"' . $this->endOfLine; - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[1], $bodyAltCharset, MimeType::M_TEXT, $bodyAltEncoding); - $body .= $this->encodeString($this->bodyAlt, $bodyAltEncoding); - $body .= $this->endOfLine; - $body .= '--' . $boundary[1] . $this->endOfLine; - $body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . $this->endOfLine; - $body .= ' boundary="' . $boundary[2] . '"' . $this->endOfLine; - $body .= ' type="' . MimeType::M_HTML . '"' . $this->endOfLine; - $body .= $this->endOfLine; - $body .= $this->getBoundary($boundary[2], $bodyCharset, MimeType::M_HTML, $bodyEncoding); - $body .= $this->encodeString($this->body, $bodyEncoding); - $body .= $this->endOfLine; - $body .= $this->attachAll(DispositionType::INLINE, $boundary[2]); - $body .= $this->endOfLine; - $body .= $this->endOfLine . '--' . $boundary[2] . '--' . $this->endOfLine; - $body .= $this->attachAll(DispositionType::ATTACHMENT, $boundary[1]); - break; - default: - $body .= $this->encodeString($this->body, $bodyEncoding); - } - - if ($this->signKeyFile !== '') { - // @todo implement - $output .= ''; - } - - return $output; - } - - /** - * Create html message - * - * @return void - * - * @since 1.0.0 - */ - public function createHtmlMsg() : void - { - } - - /** - * Convert html to text message - * - * @return string - * - * @since 1.0.0 - */ - private function htmlToText() : string - { - return ''; - } - - /** - * Normalize text - * - * Line break - * - * @param string $text Text to normalized - * @param string $lb Line break - * - * @return string - * - * @since 1.0.0 - */ - private function normalizeText(string $text, string $lb = "\n") : string - { - return \str_replace(["\r\n", "\r", "\n"], $lb, $text); - } - - /** - * Generate a random id - * - * @return string - * - * @since 1.0.0 - */ - private function generatedId() : string - { - $rand = ''; - - try { - $rand = \random_bytes(32); - } catch (\Throwable $t) { - $rand = \hash('sha256', \uniqid((string) \mt_rand(), true), true); - } - - return \base64_encode(\hash('sha256', $rand, true)); - } - - /** - * Generate the mime header - * - * @param array $boundary Message boundary - * - * @return string - * - * @since 1.0.0 - */ - private function generateMimeHeader(array $boundary) : string - { - $mime = ''; - $isMultipart = true; - - switch ($this->messageType) { - case DispositionType::INLINE: - $mime .= 'Content-Type:' . MimeType::M_RELATED . ';' . $this->endOfLine; - $mime .= ' boundary="' . $boundary[0] . '"' . $this->endOfLine; - break; - case DispositionType::ATTACHMENT: - case DispositionType::INLINE . '_' . DispositionType::ATTACHMENT: - case DispositionType::ALT . '_' . DispositionType::ATTACHMENT: - case DispositionType::ALT . '_' . DispositionType::INLINE . '_' . DispositionType::ATTACHMENT: - $mime .= 'Content-Type:' . MimeType::M_MIXED . ';' . $this->endOfLine; - $mime .= ' boundary="' . $boundary[0] . '"' . $this->endOfLine; - break; - case DispositionType::ALT: - case DispositionType::ALT . '_' . DispositionType::INLINE: - $mime .= 'Content-Type:' . MimeType::M_ALT . ';' . $this->endOfLine; - $mime .= ' boundary="' . $boundary[0] . '"' . $this->endOfLine; - break; - default: - $mime .= 'Content-Type:' . $this->contentType . '; charset=' . CharsetType::UTF_8 . ';' . $this->endOfLine; - - $isMultipart = false; - } - - return $isMultipart && $this->encoding !== EncodingType::E_7BIT - ? 'Content-Transfer-Encoding:' . $this->encoding . ';' . $this->endOfLine - : $mime; - } - - /** - * Wrap text - * - * @param string $text Text to wrap - * @param int $length Line length - * @param bool $quoted Is quoted - * - * @return string - * - * @since 1.0.0 - */ - private function wrapText(string $text, int $length, bool $quoted = false) : string - { - if ($length < 1 || $text === '') { - return $text; - } - - $softEndOfLine = $quoted ? ' =' . $this->endOfLine : $this->endOfLine; - - $text = $this->normalizeText($text, $this->endOfLine); - $text = \rtrim($text, "\r\n"); - - $lines = \explode($this->endOfLine, $text); - - $buffer = ''; - $output = ''; - $crlfLength = \strlen($this->endOfLine); - $first = true; - $isUTF8 = $this->charset === CharsetType::UTF_8; - - foreach ($lines as $line) { - $words = \explode(' ', $line); - - foreach ($words as $word) { - if ($quoted && \strlen($word) > $length) { - if ($first) { - $spaces = $length - \strlen($buffer) - $crlfLength; - - if ($spaces > 20) { - $len = $spaces; - if ($isUTF8) { - $len = MbStringUtils::utf8CharBoundary($word, $len); - } elseif ($word[$len - 1] === '=') { - --$len; - } elseif ($word[$len - 2] === '=') { - $len -= 2; - } - - $part = \substr($word, 0, $len); - $word = \substr($word, $len); - $output .= $buffer . ' ' . $part . '=' . $this->endOfLine; - } else { - $output .= $buffer . $softEndOfLine; - } - - $buffer = ''; - } - - while ($word !== '') { - if ($length < 1) { - break; - } - - $len = $length; - - if ($isUTF8) { - $len = MbStringUtils::utf8CharBoundary($word, $len); - } elseif ($word[$len - 1] === '=') { - --$len; - } elseif ($word[$len - 2] === '=') { - $len -= 2; - } - - $part = \substr($word, 0, $len); - $word = \substr($word, $len); - - if ($word !== '') { - $output .= $part . '=' . $this->endOfLine; - } else { - $buffer = $part; - } - } - } else { - $oldBuf = $buffer; - $buffer .= $word . ' '; - - if (\strlen($buffer) > $length) { - $output .= \rtrim($oldBuf) . $softEndOfLine; - $buffer = $word; - } - } - } - - $output .= \rtrim($buffer) . $this->endOfLine; - } - - return $output; - } - - /** - * Render the boundary - * - * @param string $boundary Boundary identifier - * @param string $charset Charset - * @param string $contentType ContentType - * @param string $encoding Encoding - * - * @return string - * - * @since 1.0.0 - */ - private function getBoundary(string $boundary, string $charset = null, string $contentType = null, string $encoding = null) : string - { - $boundary = ''; - $charset = empty($charset) ? $this->charset : $charset; - $contentType = empty($contentType) ? $this->contentType : $contentType; - $encoding = empty($encoding) ? $this->encoding : $encoding; - - $boundary .= '--' . $boundary . $this->endOfLine; - $boundary .= 'Content-Type: ' . $contentType . '; charset=' . $charset . $this->endOfLine; - - if ($encoding !== EncodingType::E_7BIT) { - $boundary .= 'Content-Transfer-Encoding: ' . $encoding . $this->endOfLine; - } - - return $boundary . $this->endOfLine; - } - - /** - * Encode a string - * - * @param string $text Text to encode - * @param string $encoding Encoding to use - * - * @return string - * - * @since 1.0.0 - */ - private function encodeString(string $text, string $encoding = EncodingType::E_BASE64) : string - { - $encoded = ''; - if ($encoding === EncodingType::E_BASE64) { - $encoded = \chunk_split(\base64_encode($text), 76, $this->endOfLine); - } elseif ($encoding === EncodingType::E_7BIT || $encoding === EncodingType::E_8BIT) { - $encoded = $this->normalizeText($text, $this->endOfLine); - - if (\substr($encoded, -\strlen($this->endOfLine)) !== $this->endOfLine) { - $encoded .= $this->endOfLine; - } - } elseif ($encoding === EncodingType::E_BINARY) { - $encoded = $text; - } elseif ($encoded === EncodingType::E_QUOTED) { - $encoded = $this->normalizeText(\quoted_printable_decode($text)); - } - - return $encoded; - } - - /** - * Attach all attachments - * - * @param string $disposition Disposition type - * @param string $boundary Boundary identifier - * - * @return string - * - * @since 1.0.0 - */ - private function attachAll(string $disposition, string $boundary) : string - { - $mime = []; - $cid = []; - $incl = []; - - foreach ($this->attachment as $attach) { - if ($attach['disposition'] === $disposition) { - $hash = \hash('sha256', \serialize($attach)); - if (\in_array($hash, $incl, true)) { - continue; - } - - $incl[] = $hash; - - if ($attach['disposition'] && isset($cid[$attach['id']])) { - continue; - } - - $cid[$attach['id']] = true; - - $mime[] = '--' . $boundary . $this->endOfLine; - $mime[] = !empty($attach['name']) - ? 'Content-Type: ' . $attach['type'] . '; name=' . $this->quotedString($this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $attach['name'])))) . '"' . $this->endOfLine - : 'Content-Type: ' . $attach['type'] . $this->endOfLine; - - if ($attach['encoding'] !== EncodingType::E_7BIT) { - $mime[] = 'Content-Transfer-Encoding: ' . $attach['encoding'] . $this->endOfLine; - } - - if (((string) $attach['cid']) !== '' && $attach['disposition'] === DispositionType::INLINE) { - $mime[] = 'Content-ID: <' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $attach['cid']))) . '>' . $this->endOfLine; - } - - if (!empty($attach['disposition'])) { - $encodedName = $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $attach['name']))); - - // @todo: "" might be wrong for || condition - $mime[] = !empty($encodedName) - ? 'Content-Disposition: ' . $attach['disposition'] . '; filename=' . $this->quotedString($encodedName) . $this->endOfLine - : 'Content-Disposition: ' . $attach['disposition'] . $this->endOfLine; - } - - $mime[] = $this->endOfLine; - $mime[] = $attach['string'] ? $this->encodeString($attach['path'], $attach['encoding']) : $this->encodeFile($attach['path'], $attach['encoding']); - $mime[] = $this->endOfLine; - } - } - - $mime[] = '--' . $boundary . '--' . $this->endOfLine; - - return \implode('', $mime); - } - - /** - * Encode header value - * - * @param string $value Value to encode - * @param int $context Value context - * - * @return string - * - * @since 1.0.0 - */ - private function encodeHeader(string $value, int $context = HeaderContext::TEXT) : string - { - $matches = 0; - switch ($context) { - case HeaderContext::PHRASE: - if (!\preg_match('/[\200-\377]/', $value)) { - $encoded = \addslashes($value, "\0..\37\177\\\""); - - return ($encoded === $value) && !\preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $value) ? $encoded : '"' . $encoded . '"'; - } - - $matches = \preg_match_all('/[^\040\041\043-\133\135-\176]/', $value, $matched); - break; - case HeaderContext::COMMENT: - $matches = \preg_match_all('/[()"]/', $value, $matched); - /* fallthrough */ - default: - $matches += \preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $value, $matched); - } - - $charset = ((bool) \preg_match('/[\x80-\xFF]/', $value)) ? $this->charset : CharsetType::ASCII; - $overhead = \strlen($charset) + 8; - $maxlength = $this->submitType === SubmitType::MAIL ? 63 - $overhead : 998 - $overhead; - - $valueLength = \strlen($value); - $encoded = ''; - - if ($matches > $valueLength / 3) { - $encoded = MbStringUtils::hasMultiBytes($value) - ? $this->base64EncodeWrapMb($value, "\n") - : \trim(\chunk_split(\base64_encode($value), $maxlength - $maxlength % 4, "\n")); - - $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?B?\\1?=', $encoded); - } elseif ($matches > 0 || $valueLength > $maxlength) { - $encoded = $this->encodeQ($value, $context); - $encoded = $this->wrapText($encoded, $maxlength, true); - $encoded = \str_replace('=' . $this->endOfLine, "\n", \trim($encoded)); - $encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?Q?\\1?=', $encoded); - } else { - return $value; - } - - return \trim($this->normalizeText($encoded)); - } - - /** - * Encode a file - * - * @param string $path Path to a file - * @param string $encoding Encoding of the file - * - * @return string - * - * @since 1.0.0 - */ - private function encodeFile(string $path, string $encoding = EncodingType::E_BASE64) : string - { - if (!\is_readable($path) || (bool) \preg_match('#^[a-z]+://#i', $path)) { - return ''; - } - - $content = \file_get_contents($path); - - if ($content === false) { - return ''; - } - - return $this->encodeString($content, $encoding); - } - - /** - * Encode text as base64 multibye - * - * @param string $text Text to encode - * @param string $lb Linebreak - * - * @return string - * - * @since 1.0.0 - */ - private function base64EncodeWrapMb(string $text, string $lb = "\n") : string - { - $start = '=?' . $this->charset . '?B?'; - $end = '?='; - $encoded = ''; - - $mbLength = \mb_strlen($text, $this->charset); - $length = 75 - \strlen($start) - \strlen($end); - $ratio = $mbLength / \strlen($text); - $avgLength = \floor($length * $ratio * 0.75); - - $offset = 0; - $chunk = ''; - - for ($i = 0; $i < $mbLength; $i += $offset) { - $lookBack = 0; - - do { - $offset = $avgLength - $lookBack; - $chunk = \mb_substr($text, $i, $offset, $this->charset); - $chunk = \base64_encode($chunk); - - ++$lookBack; - } while (\strlen($chunk) > $length); - - $encoded .= $chunk . $lb; - } - - return \substr($encoded, 0, -\strlen($lb)); - } - - /** - * Escape special strings - * - * @param string $text Text to escape - * - * @return string - * - * @since 1.0.0 - */ - private function quotedString(string $text) : string - { - return \preg_match('/[ ()<>@,;:"\/\[\]?=]/', $text) === false ? $text : '"' . \str_replace('"', '\\"', $text) . '"'; - } - - /** - * Quoted encode - * - * @param string $text Text to encode - * @param int $context Value context - * - * @return string - * - * @since 1.0.0 - */ - private function encodeQ(string $text, int $context = HeaderContext::TEXT) : string - { - $pattern = ''; - switch ($context) { - case HeaderContext::PHRASE: - $pattern = '^A-Za-z0-9!*+\/ -'; - break; - case HeaderContext::COMMENT: - $pattern = '\(\)"'; - break; - case HeaderContext::TEXT: - default: - $pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern; - } - - $matches = []; - $encoded = \str_replace(["\r", "\n"], '', $text); - - if (\preg_match_all('/[{' . $pattern . '}]/', $encoded, $matches) !== false) { - $eqkey = \array_search('', $matches[0], true); - if ($eqkey !== false) { - unset($matches[0][$eqkey]); - \array_unshift($matches[0], '='); - } - - $unique = \array_unique($matches[0]); - foreach ($unique as $char) { - $encoded = \str_replace($char, '=' . \sprintf('%02X', \ord($char)), $encoded); - } - } - - return \str_replace(' ', '_', $encoded); - } - - protected function hasCid(string $cid) : bool - { - foreach ($this->attachment as $attachment) { - if ($attachment['disposition'] === DispositionType::INLINE && $attachment['id'] === $cid) { - return true; - } - } - - return false; - } - - public function hasInlineImage() : bool - { - foreach ($this->attachment as $attachment) { - if ($attachment['disposition'] === DispositionType::INLINE) { - return true; - } - } - - return false; - } - - public function hasAttachment() : bool - { - foreach ($this->attachment as $attachmnet) { - if ($attachment['disposition'] === DispositionType::ATTACHMENT) { - return true; - } - } - - return false; - } - - public function hasAlternative() : bool - { - return !empty($this->bodyAlt); - } - - public function clearAddresses() : void - { - $this->to = []; - $this->cc = []; - $this->bcc = []; - } - - public function clearAllCC() : void - { - $this->cc = []; - } - - public function clearAllTo() : void - { - $this->to = []; - } - - public function clearAllBCC() : void - { - $this->bcc = []; - } - - public function clearAttachments() : void - { - $this->attachment = []; - } - - public function clearCustomHeaders() : void - { - $this->header = []; - } - - protected function serverHostname() : string - { - if (!empty($this->hostname)) { - return $this->hostname; - } elseif (isset($_SERVER, $_SERVER['SERVER_NAME'])) { - return $_SERVER['SERVER_NAME']; - } - - return ($host = \gethostname()) === false ? 'localhost.localdomain' : $host; - } - - public function addHeader(string $name, string $value = nulll) : bool - { - if ($vallue === null && \strpos($name, ':') !== false) { - list($name, $value) = \explode(':', $name, 2); - } - - $name = \trim($name); - $value = \trim($value); - - if (empty($name) || \strbrk($name . $value, "\r\n") !== false) { - return false; - } - - // todo: consider to add by name and make the name an array -> multiple values per name - $this->header[] = [$name, $value]; - - return true; - } - - public function getHeader() : array - { - return $this->header; - } - - public function msgHtml(string $message, string $baseDir = '') : string - { - } - - /** - * Set signing files - * - * @param string $certFile Certification file path - * @param string $keyFile Key file path - * @param string $keyPass Password for the key - * - * @return void - * - * @since 1.0.0 - */ - public function sign(string $certFile, string $keyFile, string $keyPass) : void - { - $this->signCertFile = $certFile; - $this->signKeyFile = $keyFile; - $this->signKeyPass = $keyPass; - } - - public function preSend() : void - { - $this->mimeHeader = ''; - $this->mimeBody = $this->createBody(); - // @todo: only if createBody impements sign / #tempheader = $this->header - $this->mimeHeader = $this->createHeader(); - - // set mime body - // set mime header - // ... - } - - private function addrAppend(string $type, array $addr) : string - { - } -} diff --git a/Message/Mail/MailBoxInterface.php b/Message/Mail/MailBoxInterface.php new file mode 100644 index 000000000..b324de7b8 --- /dev/null +++ b/Message/Mail/MailBoxInterface.php @@ -0,0 +1,29 @@ +username = $user; + $this->password = $pass; + $this->port = $port; + $this->encryption = $encryption; + } + + /** + * Destructor. + * + * @since 1.0.0 + */ + public function __destruct() + { + $this->smtpClose(); + } + + /** + * Set the mailer and the mailer tool + * + * @param string $mailer Mailer + * + * @return void + * + * @since 1.0.0 + */ + public function setMailer(string $mailer) : void + { + $this->mailer = $mailer; + + switch ($mailer) { + case SubmitType::MAIL: + case SubmitType::SMTP: + return; + case SubmitType::SENDMAIL: + $this->mailerTool = \stripos($sendmailPath = \ini_get('sendmail_path'), 'sendmail') === false + ? '/usr/sbin/sendmail' + : $sendmailPath; + return; + case SubmitType::QMAIL: + $this->mailerTool = \stripos($sendmailPath = \ini_get('sendmail_path'), 'qmail') === false + ? '/var/qmail/bin/qmail-inject' + : $sendmailPath; + return; + default: + return; + } + } + + /** + * Send mail + * + * @param $mail Mail + * + * @return bool + * + * @since 1.0.0 + */ + public function send(Email $mail) : bool + { + if (!$mail->preSend($this->mailer)) { + return false; + } + + return $this->postSend($mail); + } + + /** + * Send the mail + * + * @param Email $mail Mail + * + * @return bool + * + * @since 1.0.0 + */ + private function postSend(Email $mail) : bool + { + switch ($this->mailer) { + case SubmitType::SENDMAIL: + case SubmitType::QMAIL: + return $this->sendmailSend($mail); + case SubmitType::SMTP: + return $this->smtpSend($mail); + case SubmitType::MAIL: + return $this->mailSend($mail); + default: + return false; + } + + return false; + } + + /** + * Send mail + * + * @param Email $mail Mail + * + * @return bool + * + * @since 1.0.0 + */ + protected function sendmailSend(Email $mail) : bool + { + $header = \rtrim($mail->headerMime, " \r\n\t") . self::$LE . self::$LE; + + // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. + if (!empty($mail->sender) && StringUtils::isShellSafe($mail->sender)) { + $mailerToolFmt = $this->mailer === SubmitType::QMAIL + ? '%s -f%s' + : '%s -oi -f%s -t'; + } elseif ($this->mailer === SubmitType::QMAIL) { + $mailerToolFmt = '%s'; + } else { + $mailerToolFmt = '%s -oi -t'; + } + + $mailerTool = \sprintf($mailerToolFmt, \escapeshellcmd($this->sendmail), $mail->sender); + + $con = \popen($mailerTool, 'w'); + if ($con === false) { + return false; + } + + \fwrite($con, $header); + \fwrite($con, $mail->bodyMime); + + $result = \pclose($con); + if ($result !== 0) { + return false; + } + + return true; + } + + /** + * Send mail + * + * @param Email $mail Mail + * + * @return bool + * + * @since 1.0.0 + */ + protected function mailSend(Email $mail) : bool + { + $header = \rtrim($mail->headerMime, " \r\n\t") . self::$LE . self::$LE; + + $toArr = []; + foreach ($mail->to as $toaddr) { + $toArr[] = $mail->addrFormat($toaddr); + } + + $to = \implode(', ', $toArr); + + //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver + // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. + $params = null; + if (!empty($mail->sender) && EmailValidator::isValid($mail->sender) && StringUtils::isShellSafe($mail->sender)) { + $params = \sprintf('-f%s', $mail->sender); + } + + if (!empty($mail->sender) && EmailValidator::isValid($mail->sender)) { + $oldFrom = \ini_get('sendmail_from'); + \ini_set('sendmail_from', $mail->sender); + } + + $result = false; + $result = $this->mailPassthru($to, $mail, $header, $params); + + if (isset($oldFrom)) { + \ini_set('sendmail_from', $oldFrom); + } + + return $result; + } + + /** + * Call mail() in a safe_mode-aware fashion. + * + * @param string $to To + * @param Email $mail Mail + * @param string $body Message Body + * @param string $header Additional Header(s) + * @param null|string $params Params + * + * @return bool + * + * @since 1.0.0 + */ + private function mailPassthru(string $to, Email $mail, string $header, string $params = null) : bool + { + $subject = $mail->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $mail->subject))); + + return !$this->useMailOptions || $params === null + ? \mail($to, $subject, $mail->body, $header) + : \mail($to, $subject, $mail->body, $header, $params); + } + + /** + * Send mail + * + * @param Email $mail Mail + * + * @return bool + * + * @since 1.0.0 + */ + protected function smtpSend(Email $mail) : bool + { + $header = \rtrim($mail->headerMime, " \r\n\t") . self::$LE . self::$LE; + + if (!$this->smtpConnect($this->smtpOptions)) { + return false; + } + + $smtpFrom = $mail->sender === '' ? $mail->from : $mail->sender; + + if (!$this->smtp->mail($smtpFrom)) { + return false; + } + + $badRcpt = []; + foreach ([$mail->to, $mail->cc, $mail->bcc] as $togroup) { + foreach ($togroup as $to) { + $badRcpt = $this->smtp->recipient($to[0], $this->dsn) ? $badRcpt + 1 : $badRcpt; + } + } + + // Only send the DATA command if we have viable recipients + if ((\count($this->to) + \count($this->cc) + \count($this->bcc) > $badRcpt) + && !$this->smtp->data($header . $mail->body, self::MAX_LINE_LENGTH) + ) { + return false; + } + + //$transactinoId = $this->smtp->getLastTransactionId(); + + if ($this->keepAlive) { + $this->smtp->reset(); + } else { + $this->smtp->quit(); + $this->smtp->close(); + } + + if (!empty($badRcpt)) { + return false; + } + + return true; + } + + /** + * Initiate a connection to an SMTP server. + * + * @param array $options An array of options compatible with stream_context_create() + * + * @return bool + * + * @since 1.0.0 + */ + public function smtpConnect(array $options = null) : bool + { + if ($this->smtp === null) { + $this->smtp = new Smtp(); + } + + if ($this->smtp->isConnected()) { + return true; + } + + if ($options === null) { + $options = $this->smtpOptions; + } + + $this->smtp->timeout = $this->timeout; + $this->smtp->doVerp = $this->useVerp; + + $hosts = \explode(';', $this->host); + foreach ($hosts as $hostentry) { + $hostinfo = []; + if (!\preg_match( + '/^(?:(ssl|tls):\/\/)?(.+?)(?::(\d+))?$/', + \trim($hostentry), + $hostinfo + ) + ) { + // Not a valid host entry + continue; + } + + // $hostinfo[1]: optional ssl or tls prefix + // $hostinfo[2]: the hostname + // $hostinfo[3]: optional port number + + //Check the host name is a valid name or IP address + if (!Hostname::isValid($hostinfo[2])) { + continue; + } + + $prefix = ''; + $secure = $this->encryption; + $tls = ($this->encryption === EncryptionType::TLS); + + if ($hostinfo[1] === 'ssl' || ($hostinfo[1] === '' && $this->encryption === EncryptionType::SMTPS)) { + $prefix = 'ssl://'; + $tls = false; + $secure = EncryptionType::SMTPS; + } elseif ('tls' === $hostinfo[1]) { + $tls = true; + $secure = EncryptionType::TLS; + } + + //Do we need the OpenSSL extension? + $sslExt = defined('OPENSSL_ALGO_SHA256'); + if (($secure === EncryptionType::TLS || $secure === EncryptionType::SMTPS) + && !$sslExt + ) { + return false; + } + + $host = $hostinfo[2]; + $port = $this->port; + + if (isset($hostinfo[3]) + && \is_numeric($hostinfo[3]) + && $hostinfo[3] > 0 && $hostinfo[3] < 65536 + ) { + $port = (int) $hostinfo[3]; + } + + if ($this->smtp->connect($prefix . $host, $port, $this->timeout, $options)) { + $hello = !empty($this->helo) ? $this->helo : SystemUtils::getHostname(); + + $this->smtp->hello($hello); + $tls = $this->useAutoTLS && $sslExt && 'ssl' !== $secure && $this->smtp->getServerExt('STARTTLS') + ? true : $tls; + + $this->smtp->hello($hello); + + //Automatically enable TLS encryption + $tls = $this->useAutoTLS && $sslExt && $secure !== EncryptionType::SMTPS && $this->smtp->getServerExt('STARTTLS') + ? true : $tls; + + if ($tls) { + if (!$this->smtp->startTLS()) { + return false; + } + + // Resend EHLO + $this->smtp->hello($hello); + } + + return !($this->useSMTPAuth + && !$this->smtp->authenticate($this->username, $this->password, $this->authType, $this->oauth ) + ); + } + } + + // If we get here, all connection attempts have failed + $this->smtp->close(); + + return false; + } + + /** + * Close SMTP + * + * @return void + * + * @since 1.0.0 + */ + public function smtpClose() : void + { + if ($this->smtp !== null && $this->smtp->isConnected()) { + $this->smtp->quit(); + $this->smtp->close(); + } + } +} diff --git a/Message/Mail/MessageInterface.php b/Message/Mail/MessageInterface.php new file mode 100644 index 000000000..152b12247 --- /dev/null +++ b/Message/Mail/MessageInterface.php @@ -0,0 +1,40 @@ +inboxClose(); + parent::__destruct(); + } + + /** + * Connect to server * * @return bool * * @since 1.0.0 */ - public function connect(string $user = '', string $pass = '') : bool + public function connectInbox() : bool { - $this->mailbox = '{' . $this->host . ':' . $this->port . '/pop3}'; + $this->mailbox = ($tmp = \imap_open( + '{' + . $this->host . ':' . $this->port . '/pop3' + . ($this->encryption !== EncryptionType::NONE ? '/ssl' : '') + . '}', + $this->username, $this->password + ) === false) ? null : $tmp; + } - return parent::connect(); + public function getBoxes() : array + { + $list = \imap_list($this->mailbox, '{' . $this->host . ':' . $this->port . '}'); + if (!\is_array($list)) { + return []; + } + + foreach ($list as $key => $value) { + $list[$key] = \imap_utf7_decode($value); + } + + return $list; + } + + public function renameBox(string $old, string $new) : bool + { + return \imap_renamemailbox($this->mailbox, $old, \imap_utf7_encode($new)); + } + + public function deleteBox(string $box) : bool + { + return \imap_deletemailbox($this->mailbox, $box); + } + + public function createBox(string $box) : bool + { + return \imap_createmailbox($this->mailbox, \imap_utf7_encode($box)); + } + + public function countMail(string $box) : int + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_num_msg($this->mailbox); + } + + public function getMailboxInfo(string $box) : object + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_status($this->mailbox); + } + + public function getRecentCount(string $box) : int + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_num_recent($this->mailbox); + } + + public function copyMail(string|array $messages, string $box) : bool + { + return \imap_mail_copy($this->mailbox, !\is_string($messages) ? \implode(',', $messages) : $messages, $box); + } + + public function moveMail(string|array $messages, string $box) : bool + { + return \imap_mail_copy($this->mailbox, !\is_string($messages) ? \implode(',', $messages) : $messages, $box); + } + + public function deleteMail(int $msg) : bool + { + return \imap_delete($this->mailbox, $msg); + } + + public function getHeaders(string $box) : array + { + if ($this->box !== $box) { + \imap_reopen($this->box, $box); + $this->box = $box; + } + + return \imap_headers($this->mailbox); + } + + public function getHeaderInfo(int $msg) : object + { + return \imap_headerinfo($this->mailbox, $msg); + } + + public function getMail(int $msg) : Email + { + return new Email(); } /** - * {@inheritdoc} + * Close mailbox + * + * @return void + * + * @since 1.0.0 */ - public function send() : bool + public function inboxClose() : void { + if ($this->mailbox !== null) { + \imap_close($this->mailbox); + } } } diff --git a/Message/Mail/SMTPAuthType.php b/Message/Mail/SMTPAuthType.php new file mode 100644 index 000000000..c56e2c2fc --- /dev/null +++ b/Message/Mail/SMTPAuthType.php @@ -0,0 +1,38 @@ +isConnected()) { + return false; + } + + $this->con = $this->getSMTPConnection($host, $port, $timeout, $options); + if ($this->con === null) { + return false; + } + + $this->lastReply = $this->getLines(); + $responseCode = (int) \substr($this->lastReply, 0, 3); + if ($responseCode === 220) { + return true; + } + + if ($responseCode === 554) { + $this->quit(); + } + + $this->close(); + + return false; + } + + /** + * Create connection to the SMTP server. + * + * @param string $host SMTP server IP or host name + * @param int $port The port number to connect to + * @param int $timeout How long to wait for the connection to open + * @param array $options An array of options for stream_context_create() + * + * @return null|resource + * + * @since 1.0.0 + */ + protected function getSMTPConnection(string $host, int $port = 25, int $timeout = 30, array $options = []) : mixed + { + static $streamok; + if ($streamok === null) { + $streamok = \function_exists('stream_socket_client'); + } + + $errno = 0; + $errstr = ''; + + if ($streamok) { + $socketContext = \stream_context_create($options); + $connection = \stream_socket_client($host . ':' . $port, $errno, $errstr, $timeout, \STREAM_CLIENT_CONNECT, $socketContext); + } else { + //Fall back to fsockopen which should work in more places, but is missing some features + $connection = \fsockopen($host, $port, $errno, $errstr, $timeout); + } + + if (!\is_resource($connection)) { + return null; + } + + // SMTP server can take longer to respond, give longer timeout for first read + // Windows does not have support for this timeout function + if (\strpos(\PHP_OS, 'WIN') !== 0) { + $max = (int) \ini_get('max_execution_time'); + if ($max !== 0 && $timeout > $max && \strpos(\ini_get('disable_functions'), 'set_time_limit') === false) { + \set_time_limit($timeout); + } + + \stream_set_timeout($connection, $timeout, 0); + } + + return $connection === false ? null : $connection; + } + + /** + * Initiate a TLS (encrypted) session. + * + * @return bool + * + * @since 1.0.0 + */ + public function startTLS() : bool + { + if (!$this->sendCommand('STARTTLS', 'STARTTLS', 220)) { + return false; + } + + $crypto_method = \STREAM_CRYPTO_METHOD_TLS_CLIENT; + if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { + $crypto_method |= \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + $crypto_method |= \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; + } + + return (bool) \stream_socket_enable_crypto($this->con, true, $crypto_method); + } + + /** + * Perform SMTP authentication. + * Must be run after hello(). + * + * @param string $username The user name + * @param string $password The password + * @param string $authtype The auth type (CRAM-MD5, PLAIN, LOGIN, XOAUTH2) + * @param OAuth $oauth An optional OAuth instance for XOAUTH2 authentication + * + * @return bool + * + * @since 1.0.0 + */ + public function authenticate( + string $username, + string $password, + string $authtype = '', + mixed $oauth = null + ) : bool { + if (empty($this->serverCaps)) { + return false; + } + + if (isset($this->serverCaps['EHLO'])) { + // SMTP extensions are available; try to find a proper authentication method + if (!isset($this->serverCaps['AUTH'])) { + // 'at this stage' means that auth may be allowed after the stage changes + // e.g. after STARTTLS + return false; + } + + //If we have requested a specific auth type, check the server supports it before trying others + if ($authtype !== '' && !\in_array($authtype, $this->serverCaps['AUTH'], true)) { + $authtype = ''; + } + + if ($authtype !== '') { + //If no auth mechanism is specified, attempt to use these, in this order + //Try CRAM-MD5 first as it's more secure than the others + foreach (['CRAM-MD5', 'LOGIN', 'PLAIN', 'XOAUTH2'] as $method) { + if (\in_array($method, $this->serverCaps['AUTH'], true)) { + $authtype = $method; + break; + } + } + + if ($authtype === '') { + return false; + } + } + + if (!\in_array($authtype, $this->serverCaps['AUTH'], true)) { + return false; + } + } elseif ($authtype === '') { + $authtype = 'LOGIN'; + } + + switch ($authtype) { + case 'PLAIN': + // Start authentication + if (!$this->sendCommand('AUTH', 'AUTH PLAIN', 334) + || !$this->sendCommand('User & Password', + \base64_encode("\0" . $username . "\0" . $password), + 235 + ) + ) { + return false; + } + break; + case 'LOGIN': + // Start authentication + if (!$this->sendCommand('AUTH', 'AUTH LOGIN', 334) + || !$this->sendCommand('Username', \base64_encode($username), 334) + || !$this->sendCommand('Password', \base64_encode($password), 235) + ) { + return false; + } + break; + case 'CRAM-MD5': + // Start authentication + if (!$this->sendCommand('AUTH CRAM-MD5', 'AUTH CRAM-MD5', 334)) { + return false; + } + + $challenge = \base64_decode(\substr($this->lastReply, 4)); + $response = $username . ' ' . $this->hmac($challenge, $password); + + // send encoded credentials + return $this->sendCommand('Username', \base64_encode($response), 235); + case 'XOAUTH2': + //The OAuth instance must be set up prior to requesting auth. + if ($OAuth === null) { + return false; + } + + $oauth = $OAuth->getOauth64(); + if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) { + return false; + } + break; + default: + return false; + } + + return true; + } + + /** + * Calculate an MD5 HMAC hash. + * + * @param string $data The data to hash + * @param string $key The key to hash with + * + * @return string + * + * @since 1.0.0 + */ + protected function hmac(string $data, string $key) : string + { + // RFC 2104 HMAC implementation for php. + // Creates an md5 HMAC. + // by Lance Rushing + $byteLen = 64; + if (strlen($key) > $byteLen) { + $key = \pack('H*', \md5($key)); + } + + $key = \str_pad($key, $byteLen, \chr(0x00)); + $ipad = \str_pad('', $byteLen, \chr(0x36)); + $opad = \str_pad('', $byteLen, \chr(0x5c)); + $k_ipad = $key ^ $ipad; + $k_opad = $key ^ $opad; + + return \md5($k_opad . \pack('H*', \md5($k_ipad . $data))); + } + + /** + * Check connection state. + * + * @return bool + * + * @since 1.0.0 + */ + public function isConnected() : bool + { + if (!\is_resource($this->con)) { + return false; + } + + $status = \stream_get_meta_data($this->con); + if ($status['eof']) { + $this->close(); + + return false; + } + + return true; + } + + /** + * Close the socket and clean up the state of the class. + * Try to QUIT first! + * + * @return void + * + * @since 1.0.0 + */ + public function close() : void + { + $this->serverCaps = []; + $this->heloRply = ''; + + if (\is_resource($this->con)) { + fclose($this->con); + $this->con = null; + } + } + + /** + * Send an SMTP DATA command. + * + * @param string $msg_data Message data to send + * + * @return bool + * + * @since 1.0.0 + */ + public function data($msg_data, int $maxLineLength = 998) : bool + { + if (!$this->sendCommand('DATA', 'DATA', 354)) { + return false; + } + + /* The server is ready to accept data! + * According to rfc821 we should not send more than 1000 characters on a single line (including the LE) + */ + $lines = \explode("\n", \str_replace(["\r\n", "\r"], "\n", $msg_data)); + + /* To distinguish between a complete RFC822 message and a plain message body, we check if the first field + * of the first line (':' separated) does not contain a space then it _should_ be a header and we will + * process all lines before a blank line as headers. + */ + $field = \substr($lines[0], 0, \strpos($lines[0], ':')); + $inHeaders = (!empty($field) && \strpos($field, ' ') === false); + + foreach ($lines as $line) { + $linesOut = []; + if ($inHeaders && $line === '') { + $inHeaders = false; + } + + while (isset($line[$maxLineLength])) { + $pos = \strrpos(\substr($line, 0, $maxLineLength), ' '); + if (!$pos) { + $pos = $maxLineLength - 1; + $linesOut[] = \substr($line, 0, $pos); + $line = \substr($line, $pos); + } else { + $linesOut[] = \substr($line, 0, $pos); + $line = \substr($line, $pos + 1); + } + + if ($inHeaders) { + $line = "\t" . $line; + } + } + + $linesOut[] = $line; + + foreach ($linesOut as $lineOut) { + if (!empty($lineOut) && $lineOut[0] === '.') { + $lineOut = '.' . $lineOut; + } + + $this->clientSend($lineOut . self::$LE, 'DATA'); + } + } + + $tmpTimeLimit = $this->timeLimit; + $this->timeLimit *= 2; + $result = $this->sendCommand('DATA END', '.', 250); + + $this->recordLastTransactionId(); + + $this->timeLimit = $tmpTimeLimit; + + return $result; + } + + /** + * Send an SMTP HELO or EHLO command. + * + * @param string $host The host name or IP to connect to + * + * @return bool + * + * @since 1.0.0 + */ + public function hello(string $host = '') : bool + { + if ($this->sendHello('EHLO', $host)) { + return true; + } + + if (\substr($this->heloRply, 0, 3) == '421') { + return false; + } + + return $this->sendHello('HELO', $host); + } + + /** + * Send an SMTP HELO or EHLO command. + * + * @param string $hello The HELO string + * @param string $host The hostname to say we are + * + * @return bool + * + * @since 1.0.0 + */ + protected function sendHello(string $hello, string $host) : bool + { + $status = $this->sendCommand($hello, $hello . ' ' . $host, 250); + $this->heloRply = $this->lastReply; + + if ($status) { + $this->parseHelloFields($hello); + } else { + $this->serverCaps = []; + } + + return $status; + } + + /** + * Parse a reply to HELO/EHLO command to discover server extensions. + * + * @param string $type `HELO` or `EHLO` + * + * @return void + * + * @since 1.0.0 + */ + protected function parseHelloFields(string $type) : void + { + $this->serverCaps = []; + $lines = \explode("\n", $this->heloRply); + + foreach ($lines as $n => $s) { + //First 4 chars contain response code followed by - or space + $s = \trim(\substr($s, 4)); + if (empty($s)) { + continue; + } + + $fields = \explode(' ', $s); + if (!empty($fields)) { + if (!$n) { + $name = $type; + $fields = $fields[0]; + } else { + $name = \array_shift($fields); + switch ($name) { + case 'SIZE': + $fields = ($fields ? $fields[0] : 0); + break; + case 'AUTH': + if (!\is_array($fields)) { + $fields = []; + } + break; + default: + $fields = true; + } + } + + $this->serverCaps[$name] = $fields; + } + } + } + + /** + * Send an SMTP MAIL command. + * + * @param string $from Source address of this message + * + * @return bool + * + * @since 1.0.0 + */ + public function mail(string $from) : bool + { + $useVerp = ($this->doVerp ? ' XVERP' : ''); + + return $this->sendCommand('MAIL FROM', 'MAIL FROM:<' . $from . '>' . $useVerp, 250); + } + + /** + * Send an SMTP QUIT command. + * + * @return bool + * + * @since 1.0.0 + */ + public function quit() : bool + { + $status = $this->sendCommand('QUIT', 'QUIT', 221); + if ($status) { + $this->close(); + } + + return $status; + } + + /** + * Send an SMTP RCPT command. + * + * @param string $address The address the message is being sent to + * @param string $dsn Comma separated list of DSN notifications + * + * @return bool + * + * @since 1.0.0 + */ + public function recipient(string $address, string $dsn = DsnNotificationType::NONE) : bool + { + if ($dsn === '') { + $rcpt = 'RCPT TO:<' . $address . '>'; + } else { + $notify = []; + + if (\strpos($dsn, 'NEVER') !== false) { + $notify[] = 'NEVER'; + } else { + foreach (['SUCCESS', 'FAILURE', 'DELAY'] as $value) { + if (\strpos($dsn, $value) !== false) { + $notify[] = $value; + } + } + } + + $rcpt = 'RCPT TO:<' . $address . '> NOTIFY=' . \implode(',', $notify); + } + + return $this->sendCommand('RCPT TO', $rcpt, [250, 251]); + } + + /** + * Send an SMTP RSET command. + * + * @return bool s + * + * @since 1.0.0 + */ + public function reset() : bool + { + return $this->sendCommand('RSET', 'RSET', 250); + } + + /** + * Send a command to an SMTP server and check its return code. + * + * @param string $command The command name - not sent to the server + * @param string $commandstring The actual command to send + * @param int|array $expect One or more expected integer success codes + * + * @return bool + * + * @since 1.0.0 + */ + protected function sendCommand(string $command, string $commandstring, int|array $expect) : bool + { + if (!$this->isConnected()) { + return false; + } + + if ((\strpos($commandstring, "\n") !== false) + || (\strpos($commandstring, "\r") !== false) + ) { + return false; + } + + $this->clientSend($commandstring . self::$LE, $command); + + $this->lastReply = $this->getLines(); + + $matches = []; + if (\preg_match('/^([\d]{3})[ -](?:([\d]\\.[\d]\\.[\d]{1,2}) )?/', $this->lastReply, $matches)) { + $code = (int) $matches[1]; + $codeEx = \count($matches) > 2 ? $matches[2] : null; + + // Cut off error code from each response line + $detail = \preg_replace( + "/{$code}[ -]" . + ($codeEx ? \str_replace('.', '\\.', $codeEx) . ' ' : '') . '/m', + '', + $this->lastReply + ); + } else { + // Fall back to simple parsing if regex fails + $code = (int) \substr($this->lastReply, 0, 3); + $detail = \substr($this->lastReply, 4); + } + + if (!\in_array($code, (array) $expect, true)) { + return false; + } + + return true; + } + + /** + * Send an SMTP SAML command. + * Starts a mail transaction from the email address specified in $from. + * + * @param string $from The address the message is from + * + * @return bool + * + * @since 1.0.0 + */ + public function sendAndMail(string $from) : bool + { + return $this->sendCommand('SAML', "SAML FROM:$from", 250); + } + + /** + * Send an SMTP VRFY command. + * + * @param string $name The name to verify + * + * @return bool + * + * @since 1.0.0 + */ + public function verify(string $name) : bool + { + return $this->sendCommand('VRFY', "VRFY $name", [250, 251]); + } + + /** + * Send an SMTP NOOP command. + * Used to keep keep-alives alive. + * + * @return bool + * + * @since 1.0.0 + */ + public function noop() : bool + { + return $this->sendCommand('NOOP', 'NOOP', 250); + } + + /** + * Send raw data to the server. + * + * @param string $data The data to send + * @param string $command Optionally, the command this is part of, used only for controlling debug output + * + * @return int + * + * @since 1.0.0 + */ + public function clientSend(string $data, string $command = '') : int + { + $result = \fwrite($this->con, $data); + + return $result === false ? -1 : $result; + } + + /** + * Get SMTP extensions available on the server. + * + * @return array + * + * @since 1.0.0 + */ + public function getServerExtList() : array + { + return $this->serverCaps; + } + + /** + * Get metadata about the SMTP server from its HELO/EHLO response. + * + * @param string $name Name of SMTP extension + * + * @return bool + * + * @since 1.0.0 + */ + public function getServerExt(string $name) : bool + { + if (empty($this->serverCaps)) { + // HELO/EHLO has not been sent + return false; + } + + if (!isset($this->serverCaps[$name])) { + if ($name === 'HELO') { + // Server name + //return $this->serverCaps['EHLO']; + } + + return false; + } + + return isset($this->serverCaps[$name]); + } + + /** + * Get the last reply from the server. + * + * @return string + * + * @since 1.0.0 + */ + public function getLastReply() : string + { + return $this->lastReply; + } + + /** + * Read the SMTP server's response. + * + * @return string + * + * @since 1.0.0 + */ + protected function getLines() : string + { + if (!\is_resource($this->con)) { + return ''; + } + + $data = ''; + $endTime = 0; + + \stream_set_timeout($this->con, $this->timeout); + if ($this->timeLimit > 0) { + $endTime = \time() + $this->timeLimit; + } + + $selR = [$this->con]; + $selW = null; + $tries = 0; + + while (\is_resource($this->con) && !\feof($this->con)) { + $n = \stream_select($selR, $selW, $selW, $this->timeLimit); + if ($n === false) { + if ($tries < 3) { + ++$tries; + continue; + } else { + break; + } + } + + $str = \fgets($this->con, self::MAX_REPLY_LENGTH); + $data .= $str; + + // If response is only 3 chars (not valid, but RFC5321 S4.2 says it must be handled), + // or 4th character is a space or a line break char, we are done reading, break the loop. + // String array access is a significant micro-optimisation over strlen + if (!isset($str[3]) || $str[3] === ' ' || $str[3] === "\r" || $str[3] === "\n") { + break; + } + + $info = \stream_get_meta_data($this->con); + if ($info['timed_out']) { + break; + } + + // Now check if reads took too long + if ($endTime && \time() > $endTime) { + break; + } + } + + return $data; + } + + /** + * Extract and return the ID of the last SMTP transaction + * + * @return string + * + * @since 1.0.0 + */ + protected function recordLastTransactionId() : string + { + $reply = $this->getLastReply(); + + if ($reply === '') { + $this->lastSmtpTransactionId = ''; + } else { + $this->lastSmtpTransactionId = ''; + $patterns = SmtpTransactionPattern::getConstants(); + + foreach ($patterns as $pattern) { + $matches = []; + if (\preg_match($pattern, $reply, $matches)) { + $this->lastSmtpTransactionId = \trim($matches[1]); + break; + } + } + } + + return $this->lastSmtpTransactionId; + } + + /** + * Get the queue/transaction ID of the last SMTP transaction + * + * @return string + * + * @since 1.0.0 + */ + public function getLastTransactionId() : string + { + return $this->lastSmtpTransactionId; + } +} diff --git a/Message/Mail/SmtpTransactionPattern.php b/Message/Mail/SmtpTransactionPattern.php new file mode 100644 index 000000000..1050488b9 --- /dev/null +++ b/Message/Mail/SmtpTransactionPattern.php @@ -0,0 +1,42 @@ + '', 'basename' => '', 'extension' => '', 'filename' => '']; + $pathinfo = []; + + if (\preg_match('#^(.*?)[\\\\/]*(([^/\\\\]*?)(\.([^.\\\\/]+?)|))[\\\\/.]*$#m', $path, $pathinfo)) { + if (isset($pathinfo[1])) { + $ret['dirname'] = $pathinfo[1]; + } + if (isset($pathinfo[2])) { + $ret['basename'] = $pathinfo[2]; + } + if (isset($pathinfo[5])) { + $ret['extension'] = $pathinfo[5]; + } + if (isset($pathinfo[3])) { + $ret['filename'] = $pathinfo[3]; + } + } + + switch ($options) { + case \PATHINFO_DIRNAME: + case 'dirname': + return $ret['dirname']; + case \PATHINFO_BASENAME: + case 'basename': + return $ret['basename']; + case \PATHINFO_EXTENSION: + case 'extension': + return $ret['extension']; + case \PATHINFO_FILENAME: + case 'filename': + return $ret['filename']; + default: + return $ret; + } + } + + /** + * Check whether a file path is safe, accessible, and readable. + * + * @param string $path A relative or absolute path + * + * @return bool + * + * @since 1.0.0 + */ + public static function isAccessible(string $path) : bool + { + $readable = \is_file($path); + if (\strpos($path, '\\\\') !== 0) { + $readable = $readable && \is_readable($path); + } + + return self::isPermittedPath($path) && $readable; + } + + /** + * Check whether a file path is of a permitted type. + * + * @param string $path Path + * + * @return bool + * + * @since 1.0.0 + */ + public static function isPermittedPath(string $path) : bool + { + return !\preg_match('#^[a-z]+://#i', $path); + } } diff --git a/System/MimeType.php b/System/MimeType.php index bb37fdd58..4e063ecca 100644 --- a/System/MimeType.php +++ b/System/MimeType.php @@ -2009,4 +2009,22 @@ abstract class MimeType extends Enum public const M_ZMM = 'application/vnd.handheld-entertainment+xml'; public const M_123 = 'application/vnd.lotus-1-2-3'; + + /** + * Get mime from file extension + * + * @param string $extension Extension + * + * @return string + * + * @since 1.0.0 + */ + public static function extensionToMime(string $extension) : string + { + try { + return self::getByName('M_' . \strtoupper($extension)) ?? 'application/octet-stream'; + } catch (\Throwable $t) { + return 'application/octet-stream'; + } + } } diff --git a/System/SystemUtils.php b/System/SystemUtils.php index 50fb5e544..3c411bd84 100644 --- a/System/SystemUtils.php +++ b/System/SystemUtils.php @@ -126,4 +126,24 @@ final class SystemUtils return (int) $cpuUsage; } + + /** + * Get the server hostname. + * + * @return string + * + * @since 1.0.0 + */ + public static function getHostname() : string + { + if (isset($_SERVER['SERVER_NAME'])) { + return $_SERVER['SERVER_NAME']; + } elseif (($result = gethostname()) !== false) { + return $result; + } elseif (\php_uname('n') !== false) { + return \php_uname('n'); + } + + return 'localhost.localdomain'; + } } diff --git a/Utils/StringUtils.php b/Utils/StringUtils.php index 9f2647da6..c94bce119 100644 --- a/Utils/StringUtils.php +++ b/Utils/StringUtils.php @@ -399,4 +399,34 @@ final class StringUtils return (int) $res; } + + /** + * Fix CVE-2016-10033 and CVE-2016-10045 by disallowing potentially unsafe shell characters. + * + * @param string $string String to check + * + * @return bool + * + * @since 1.0.0 + */ + public static function isShellSafe(string $string) + { + if (\escapeshellcmd($string) !== $string + || !\in_array(\escapeshellarg($string), ["'$string'", "\"$string\""]) + ) { + return false; + } + + $length = \strlen($string); + + for ($i = 0; $i < $length; ++$i) { + $c = $string[$i]; + + if (!\ctype_alnum($c) && \strpos('@_-.', $c) === false) { + return false; + } + } + + return true; + } } diff --git a/Validation/Network/Hostname.php b/Validation/Network/Hostname.php index c8a282f42..8638452f7 100644 --- a/Validation/Network/Hostname.php +++ b/Validation/Network/Hostname.php @@ -41,6 +41,21 @@ abstract class Hostname extends ValidatorAbstract */ public static function isValid(mixed $value, array $constraints = null) : bool { - return \filter_var(\gethostbyname($value), \FILTER_VALIDATE_IP) !== false; + //return \filter_var(\gethostbyname($value), \FILTER_VALIDATE_IP) !== false; + + if (empty($value) + || \strlen($value) > 256 + || !\preg_match('/^([a-zA-Z\d.-]*|\[[a-fA-F\d:]+])$/', $value) + ) { + return false; + } elseif (\strlen($value) > 2 && \substr($value, 0, 1) === '[' && \substr($value, -1, 1) === ']') { + return \filter_var(\substr($value, 1, -1), \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false; + } elseif (\is_numeric(str_replace('.', '', $value))) { + return \filter_var($value, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) !== false; + } elseif (\filter_var('http://' . $value, FILTER_VALIDATE_URL) !== false) { + return true; + } + + return false; } } diff --git a/tests/Message/Mail/ImapTest.php b/tests/Message/Mail/ImapTest.php deleted file mode 100644 index d4b510f5f..000000000 --- a/tests/Message/Mail/ImapTest.php +++ /dev/null @@ -1,67 +0,0 @@ -isConnected()); - self::assertTrue($email->connect( - $GLOBALS['CONFIG']['mail']['imap']['user'], - $GLOBALS['CONFIG']['mail']['imap']['password'] - )); - - self::assertTrue($email->isConnected()); - self::assertEquals([], $email->getBoxes()); - self::assertEquals([], $email->getQuota()); - self::assertInstanceOf('\phpOMS\Message\Mail\Mail', $email->getEmail('1')); - self::assertEquals([], $email->getInboxAll()); - self::assertEquals([], $email->getInboxOverview()); - self::assertEquals([], $email->getInboxNew()); - self::assertEquals([], $email->getInboxFrom('')); - self::assertEquals([], $email->getInboxTo('')); - self::assertEquals([], $email->getInboxCc('')); - self::assertEquals([], $email->getInboxBcc('')); - self::assertEquals([], $email->getInboxAnswered()); - self::assertEquals([], $email->getInboxSubject('')); - self::assertEquals([], $email->getInboxSince(new \DateTime('now'))); - self::assertEquals([], $email->getInboxUnseen()); - self::assertEquals([], $email->getInboxSeen()); - self::assertEquals([], $email->getInboxDeleted()); - self::assertEquals([], $email->getInboxText('')); - self::assertEquals([], $email->getMessageOverview(1, 1)); - self::assertEquals(0, $email->countMessages()); - self::assertEquals('', $email->getMessageHeader(1)); - } -} diff --git a/tests/Message/Mail/MailHandlerTest.php b/tests/Message/Mail/MailHandlerTest.php new file mode 100644 index 000000000..6cd884b1a --- /dev/null +++ b/tests/Message/Mail/MailHandlerTest.php @@ -0,0 +1,53 @@ +setMailer(SubmitType::MAIL); + + $mail = new Email(); + $mail->setFrom('d.eichhorn@orange-management.org', 'Dennis Eichhorn'); + $mail->addTo('coyle.maguire@googlemail.com', 'Donald Duck'); + $mail->subject = 'Test email'; + $mail->body = 'This is some content'; + + self::assertTrue($mailer->send($mail)); + } + + public function testReceiveMailWithImap() : void + { + $mailer = new Imap(); + $mailer->connectInbox(); + + var_dump($mailer->getBoxes()); + } +} diff --git a/tests/Message/Mail/MailTest.php b/tests/Message/Mail/MailTest.php index 21bcdeea2..e69de29bb 100644 --- a/tests/Message/Mail/MailTest.php +++ b/tests/Message/Mail/MailTest.php @@ -1,26 +0,0 @@ -