Merge pull request #592 from wrongecho/email-parsing-lib

Email ticket parsing enhancements
This commit is contained in:
Johnny 2023-01-31 14:01:08 -05:00 committed by GitHub
commit 17f88f88a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 2021 additions and 109 deletions

View File

@ -66,6 +66,7 @@
* MariaDB
* PHPMailer
* HTML Purifier
* PHP Mime Mail Parser
* CSS
* Bootstrap
@ -88,7 +89,7 @@ ITFlow is self-hosted. There is a full installation guide in the [docs](https://
1. Install a LAMP stack (Linux, Apache, MariaDB, PHP)
```sh
sudo apt install git apache2 php libapache2-mod-php php-intl php-imap php-mysqli php-curl mariadb-server
sudo apt install git apache2 php libapache2-mod-php php-intl php-imap php-mailparse php-mysqli php-curl mariadb-server
```
2. Clone the repo
```sh

View File

@ -10,58 +10,73 @@ TODO:
- Process unregistered contacts/clients into an inbox to allow a ticket to be created/ignored
- Better handle replying to closed tickets
- Support for authenticating with OAuth
- Documentation
- Separate Mailbox Account for tickets 2022-12-14 - JQ
- Properly parse base64 encoded emails (if an Outlook user sends a smiley everything breaks :( - https://electrictoolbox.com/php-imap-message-parts/)
Relate PRs to https://github.com/itflow-org/itflow/issues/225 & https://forum.itflow.org/d/11-road-map & https://forum.itflow.org/d/31-tickets-from-email
*/
// Get ITFlow config & helper functions
include_once("config.php");
include_once("functions.php");
require_once("config.php");
require_once("functions.php");
// Get settings for the "default" company
$company_id = 1;
$session_company_id = 1;
include_once("get_settings.php");
require_once("get_settings.php");
// Check setting enabled
if ($config_ticket_email_parse == 0) {
exit("Feature is not enabled - see Settings > Ticketing > Email-to-ticket parsing");
exit("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://wiki.itflow.org/doku.php?id=wiki:ticket_email_parse -- Quitting..");
}
// Check IMAP function exists
// Check IMAP extension works/installed
if (!function_exists('imap_open')) {
echo "PHP IMAP extension is not installed, quitting..";
exit();
exit("Email Parser: PHP IMAP extension is not installed. See https://wiki.itflow.org/doku.php?id=wiki:ticket_email_parse -- Quitting..");
}
// Check mailparse extension works/installed
if (!function_exists('mailparse_msg_parse_file')) {
exit("Email Parser: PHP mailparse extension is not installed. See https://wiki.itflow.org/doku.php?id=wiki:ticket_email_parse -- Quitting..");
}
// PHP Mail Parser
require_once("plugins/php-mime-mail-parser/src/Contracts/CharsetManager.php");
require_once("plugins/php-mime-mail-parser/src/Contracts/Middleware.php");
require_once("plugins/php-mime-mail-parser/src/Attachment.php");
require_once("plugins/php-mime-mail-parser/src/Charset.php");
require_once("plugins/php-mime-mail-parser/src/Exception.php");
require_once("plugins/php-mime-mail-parser/src/Middleware.php");
require_once("plugins/php-mime-mail-parser/src/MiddlewareStack.php");
require_once("plugins/php-mime-mail-parser/src/MimePart.php");
require_once("plugins/php-mime-mail-parser/src/Parser.php");
// Function to raise a new ticket for a given contact and email them confirmation (if configured)
function createTicket($contact_id, $contact_name, $contact_email, $client_id, $company_id, $date, $subject, $message) {
function addTicket($contact_id, $contact_name, $contact_email, $client_id, $company_id, $date, $subject, $message)
{
// Access global variables
global $mysqli, $config_ticket_next_number, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_smtp_host, $config_smtp_port, $config_smtp_encryption, $config_smtp_username, $config_smtp_password;
global $mysqli, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_smtp_host, $config_smtp_port, $config_smtp_encryption, $config_smtp_username, $config_smtp_password;
// Get the next Ticket Number and add 1 for the new ticket number
$ticket_number_sql = mysqli_fetch_array(mysqli_query($mysqli, "SELECT config_ticket_next_number FROM settings WHERE company_id = $company_id"));
$ticket_number = intval($ticket_number_sql['config_ticket_next_number']);
$new_config_ticket_next_number = $ticket_number + 1;
mysqli_query($mysqli, "UPDATE settings SET config_ticket_next_number = $new_config_ticket_next_number WHERE company_id = $company_id");
// Prep ticket details
$message = nl2br(htmlentities(strip_tags($message)));
$message = trim(mysqli_real_escape_string($mysqli,"<i>Email from: $contact_email at $date:-</i> <br><br>$message"));
$message = trim(mysqli_real_escape_string($mysqli, "<i>Email from: $contact_email at $date:-</i> <br><br>$message"));
// Get the next Ticket Number and add 1 for the new ticket number
$ticket_number = $config_ticket_next_number;
$new_config_ticket_next_number = $config_ticket_next_number + 1;
mysqli_query($mysqli,"UPDATE settings SET config_ticket_next_number = $new_config_ticket_next_number WHERE company_id = $company_id");
mysqli_query($mysqli,"INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_subject = '$subject', ticket_details = '$message', ticket_priority = 'Low', ticket_status = 'Open', ticket_created_at = NOW(), ticket_created_by = '0', ticket_contact_id = $contact_id, ticket_client_id = $client_id, company_id = $company_id");
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_subject = '$subject', ticket_details = '$message', ticket_priority = 'Low', ticket_status = 'Open', ticket_created_at = NOW(), ticket_created_by = '0', ticket_contact_id = $contact_id, ticket_client_id = $client_id, company_id = $company_id");
$id = mysqli_insert_id($mysqli);
// Logging
echo "Created new ticket.<br>";
mysqli_query($mysqli,"INSERT INTO logs SET log_type = 'Ticket', log_action = 'Create', log_description = 'Email parser: Client contact $contact_email created ticket $config_ticket_prefix$ticket_number ($subject)', log_created_at = NOW(), log_client_id = $client_id, company_id = $company_id");
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Ticket', log_action = 'Create', log_description = 'Email parser: Client contact $contact_email created ticket $config_ticket_prefix$ticket_number ($subject) ($id)', log_created_at = NOW(), log_client_id = $client_id, company_id = $company_id");
// Get company name & phone
$sql = mysqli_query($mysqli,"SELECT company_name, company_phone FROM companies WHERE company_id = $company_id");
$sql = mysqli_query($mysqli, "SELECT company_name, company_phone FROM companies WHERE company_id = $company_id");
$row = mysqli_fetch_array($sql);
$company_phone = formatPhoneNumber($row['company_phone']);
$company_name = $row['company_name'];
@ -79,8 +94,8 @@ function createTicket($contact_id, $contact_name, $contact_email, $client_id, $c
$email_subject, $email_body);
if ($mail !== true) {
mysqli_query($mysqli,"INSERT INTO notifications SET notification_type = 'Mail', notification = 'Failed to send email to $contact_email', notification_timestamp = NOW(), company_id = $company_id");
mysqli_query($mysqli,"INSERT INTO logs SET log_type = 'Mail', log_action = 'Error', log_description = 'Failed to send email to $contact_email regarding $subject. $mail', company_id = $company_id");
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Mail', notification = 'Failed to send email to $contact_email', notification_timestamp = NOW(), company_id = $company_id");
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Mail', log_action = 'Error', log_description = 'Failed to send email to $contact_email regarding $subject. $mail', company_id = $company_id");
}
}
@ -89,6 +104,83 @@ function createTicket($contact_id, $contact_name, $contact_email, $client_id, $c
}
function addReply($from_email, $date, $subject, $ticket_number, $message)
{
// Add email as a comment/reply to an existing ticket
// Access global variables
global $mysqli, $config_ticket_prefix, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $config_smtp_host, $config_smtp_port, $config_smtp_encryption, $config_smtp_username, $config_smtp_password;
// Set default reply type
$ticket_reply_type = 'Client';
// Capture just the latest/most recent email reply content
// based off the "#--itflow#" line that we prepend the outgoing emails with (similar to the old school --reply above this line--)
$message = explode("#--itflow--#", $message);
$message = nl2br(htmlentities(strip_tags($message[0])));
$message = "<i>Email from: $from_email at $date:-</i> <br><br>$message";
// Lookup the ticket ID
$row = mysqli_fetch_array(mysqli_query($mysqli, "SELECT ticket_id, ticket_subject, ticket_status, ticket_contact_id, ticket_client_id, tickets.company_id, contact_email
FROM tickets
LEFT JOIN contacts on tickets.ticket_contact_id = contacts.contact_id
WHERE ticket_number = '$ticket_number' LIMIT 1"));
if ($row) {
// Get ticket details
$ticket_id = $row['ticket_id'];
$ticket_status = $row['ticket_status'];
$ticket_reply_contact = $row['ticket_contact_id'];
$ticket_contact_email = $row['contact_email'];
$client_id = $row['ticket_client_id'];
$company_id = $row['company_id'];
// Check ticket isn't closed
if ($ticket_status == "Closed") {
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Ticket', notification = 'Email parser: $from_email attempted to re-open ticket $config_ticket_prefix$ticket_number (ID $ticket_id) - check inbox manually to see email', notification_timestamp = NOW(), notification_client_id = '$client_id', company_id = '$company_id'");
return false;
}
// Check WHO replied (was it the owner of the ticket or someone else on CC?)
if (empty($ticket_contact_email) || $ticket_contact_email !== $from_email) {
// It wasn't the contact currently assigned to the ticket, check if it's another registered contact for that client
$row = mysqli_fetch_array(mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_email = '$from_email' AND contact_client_id = $client_id LIMIT 1"));
if ($row) {
// Contact is known - we can keep the reply type as client
$ticket_reply_contact = $row['contact_id'];
} else {
// Mark the reply as internal as we don't recognise the contact (so the actual contact doesn't see it, and the tech can edit/delete if needed)
$ticket_reply_type = 'Internal';
$ticket_reply_contact = '0';
$message = "<b>WARNING: Contact email mismatch</b><br>$message"; // Add a warning at the start of the message - for the techs benefit (think phishing/scams)
}
}
// Sanitize ticket reply
$comment = trim(mysqli_real_escape_string($mysqli, $message));
// Add the comment
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$comment', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '00:00:00', ticket_reply_created_at = NOW(), ticket_reply_by = '$ticket_reply_contact', ticket_reply_ticket_id = '$ticket_id', company_id = '$company_id'");
// Update Ticket Last Response Field & set ticket to open as client has replied
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 'Open', ticket_updated_at = NOW() WHERE ticket_id = $ticket_id AND ticket_client_id = '$client_id' LIMIT 1");
echo "Updated existing ticket.<br>";
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Ticket', log_action = 'Update', log_description = 'Email parser: Client contact $from_email updated ticket $config_ticket_prefix$ticket_number ($subject)', log_created_at = NOW(), log_client_id = $client_id, company_id = $company_id");
return true;
} else {
// Invalid ticket number
return false;
}
}
// Prepare connection string with encryption (TLS/SSL/<blank>)
$imap_mailbox = "$config_imap_host:$config_imap_port/imap/$config_imap_encryption";
@ -99,7 +191,7 @@ $imap = imap_open("{{$imap_mailbox}}INBOX", $config_smtp_username, $config_smtp_
if (!$imap) {
// Logging
$extended_log_description = var_export(imap_errors(), true);
mysqli_query($mysqli,"INSERT INTO logs SET log_type = 'Mail', log_action = 'Error', log_description = 'Email parser: Failed to connect to IMAP. Details: $extended_log_description', company_id = $company_id");
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Mail', log_action = 'Error', log_description = 'Email parser: Failed to connect to IMAP. Details: $extended_log_description', company_id = $company_id");
exit("Could not connect to IMAP");
}
@ -124,108 +216,66 @@ if ($emails) {
// Default false
$email_processed = false;
// Get message details
$metadata = imap_fetch_overview($imap, $email,0); // Date, Subject, Size
$header = imap_headerinfo($imap, $email); // To get the From as an email, not a contact name
$message = (imap_fetchbody($imap, $email, 1)); // Body
// Get details from message and invoke PHP Mime Mail Parser
$msg_to_parse = imap_fetchheader($imap, $email, FT_PREFETCHTEXT) . imap_body($imap, $email);
$parser = new PhpMimeMailParser\Parser();
$parser->setText($msg_to_parse);
$from = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($header->from[0]->mailbox . "@" . $header->from[0]->host))));
$subject = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($metadata[0]->subject))));
$date = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($metadata[0]->date))));
// Process message attributes
$from_array = $parser->getAddresses('from')[0];
$from_name = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($from_array['display']))));
$from_email = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($from_array['address']))));
$from_domain = explode("@", $from_array['address']);
$from_domain = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags(end($from_domain))))); // Use the final element in the array (as technically legal to have multiple @'s)
$subject = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($parser->getHeader('subject')))));
$date = trim(mysqli_real_escape_string($mysqli, htmlentities(strip_tags($parser->getHeader('date')))));
$message = $parser->getMessageBody('text');
$domain = trim(mysqli_real_escape_string($mysqli, $header->from[0]->host));
$from_name = trim(mysqli_real_escape_string($mysqli, $header->from[0]->mailbox));
// Check if we can identify a ticket number (in square brackets)
if (preg_match("/\[$config_ticket_prefix\d+\]/", $subject, $ticket_number)) {
// Looks like there's a ticket number in the subject line (e.g. [TCK-091]
// Process as a ticket reply
// Get the actual ticket number (without the brackets)
preg_match('/\d+/', $ticket_number[0], $ticket_number);
$ticket_number = intval($ticket_number[0]);
// Split the email into just the latest reply, with some metadata
// We base this off the string "#--itflow--#" that we prepend the outgoing emails with (similar to the old school --reply above this line--)
$message = explode("#--itflow--#", $message);
$message = nl2br(htmlentities(strip_tags($message[0])));
$message = "<i>Email from: $from at $date:-</i> <br><br>$message";
// Lookup the ticket ID to add the reply to (just to check in-case the ID is different from the number).
$ticket_sql = mysqli_query($mysqli, "SELECT * FROM tickets WHERE ticket_number = '$ticket_number' LIMIT 1");
$row = mysqli_fetch_array($ticket_sql);
$ticket_id = $row['ticket_id'];
$ticket_reply_contact = $row['ticket_contact_id'];
$ticket_assigned_to = $row['ticket_assigned_to'];
$client_id = $row['ticket_client_id'];
$company_id = $row['company_id'];
$ticket_reply_type = 'Client'; // Setting to client as a default value
// Check the ticket ID is valid
if (intval($ticket_id) && $ticket_id !== '0') {
// Check that ticket is open
if ($row['ticket_status'] == "Closed") {
// It's closed - let's notify someone that a client tried to reply
mysqli_query($mysqli,"INSERT INTO notifications SET notification_type = 'Ticket', notification = 'Email parser: $from attempted to re-open ticket ID $ticket_id ($config_ticket_prefix$ticket_number) - check inbox manually to see email', notification_timestamp = NOW(), notification_client_id = '$client_id', company_id = '$company_id'");
} else {
// Ticket is open, proceed.
// Check the email matches the contact's email - if it doesn't then mark the reply as internal (so the contact doesn't see it, and the tech can edit/delete if needed)
// Niche edge case - possibly where CC's on an email reply to a ticket?
$contact_sql = mysqli_query($mysqli, "SELECT contact_email FROM contacts WHERE contact_id = '$ticket_reply_contact'");
$row = mysqli_fetch_array($contact_sql);
if ($from !== $row['contact_email']) {
$ticket_reply_type = 'Internal';
$ticket_reply_contact = '0';
$message = "<b>WARNING: Contact email mismatch</b><br>$message"; // Add a warning at the start of the message - for the techs benefit (think phishing/scams)
}
// Sanitize ticket reply
$comment = trim(mysqli_real_escape_string($mysqli, $message));
// Add the comment
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$comment', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '00:00:00', ticket_reply_created_at = NOW(), ticket_reply_by = '$ticket_reply_contact', ticket_reply_ticket_id = '$ticket_id', company_id = '$company_id'");
// Update Ticket Last Response Field & set ticket to open as client has replied
mysqli_query($mysqli,"UPDATE tickets SET ticket_status = 'Open', ticket_updated_at = NOW() WHERE ticket_id = $ticket_id AND ticket_client_id = '$client_id' LIMIT 1");
echo "Updated existing ticket.<br>";
mysqli_query($mysqli,"INSERT INTO logs SET log_type = 'Ticket', log_action = 'Update', log_description = 'Email parser: Client contact $from updated ticket $config_ticket_prefix$ticket_number ($subject)', log_created_at = NOW(), log_client_id = $client_id, company_id = $company_id");
$email_processed = true;
}
if (addReply($from_email, $date, $subject, $ticket_number, $message)) {
$email_processed = true;
}
} else {
// Couldn't match this email to an existing ticket
// Check if we can match the sender to a pre-existing contact
$any_contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from' LIMIT 1");
$any_contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email' LIMIT 1");
$row = mysqli_fetch_array($any_contact_sql);
$contact_name = $row['contact_name'];
$contact_id = $row['contact_id'];
$contact_email = $row['contact_email'];
$client_id = $row['contact_client_id'];
$company_id = $row['company_id'];
if ($row) {
// Sender exists as a contact
$contact_name = $row['contact_name'];
$contact_id = $row['contact_id'];
$contact_email = $row['contact_email'];
$client_id = $row['contact_client_id'];
$company_id = $row['company_id'];
if ($from == $contact_email) {
createTicket($contact_id, $contact_name, $contact_email, $client_id, $company_id, $date, $subject, $message);
$email_processed = true;
if (addTicket($contact_id, $contact_name, $contact_email, $client_id, $company_id, $date, $subject, $message)) {
$email_processed = true;
}
} else {
// Couldn't match this email to an existing ticket or an existing client contact
// Checking to see if the sender domain matches a client website
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM clients WHERE client_website = '$domain' LIMIT 1"));
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM clients WHERE client_website = '$from_domain' LIMIT 1"));
if ($row && $domain == $row['client_website']) {
if ($row && $from_domain == $row['client_website']) {
// We found a match - create a contact under this client and raise a ticket for them
@ -236,22 +286,22 @@ if ($emails) {
// Contact details
$password = password_hash(randomString(), PASSWORD_DEFAULT);
$contact_name = $from_name;
$contact_email = $from;
mysqli_query($mysqli,"INSERT INTO contacts SET contact_name = '$contact_name', contact_email = '$contact_email', contact_notes = 'Added automatically via email parsing.', contact_password_hash = '$password', contact_client_id = $client_id, company_id = $company_id");
$contact_email = $from_email;
mysqli_query($mysqli, "INSERT INTO contacts SET contact_name = '$contact_name', contact_email = '$contact_email', contact_notes = 'Added automatically via email parsing.', contact_password_hash = '$password', contact_client_id = $client_id, company_id = $company_id");
$contact_id = mysqli_insert_id($mysqli);
// Logging for contact creation
echo "Created new contact.<br>";
mysqli_query($mysqli,"INSERT INTO logs SET log_type = 'Contact', log_action = 'Create', log_description = 'Email parser: created contact $contact_name', log_client_id = $client_id, company_id = $company_id");
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Contact', log_action = 'Create', log_description = 'Email parser: created contact $contact_name', log_client_id = $client_id, company_id = $company_id");
createTicket($contact_id, $contact_name, $contact_email, $client_id, $company_id, $date, $subject, $message);
$email_processed = true;
if (addTicket($contact_id, $contact_name, $contact_email, $client_id, $company_id, $date, $subject, $message)) {
$email_processed = true;
}
} else {
// Couldn't match this email to an existing ticket, existing contact or an existing client via the "from" domain
// In the future we might make a page where these can be nicely viewed / managed, but for now we'll just flag them as needing attention
// In the future we might make a page where these can be nicely viewed / managed, but for now we'll just flag them in the Inbox as needing attention
}
@ -259,10 +309,11 @@ if ($emails) {
}
// Deal with the message
// Deal with the message (move it if processed, flag it if not)
if ($email_processed) {
imap_mail_move($imap, $email, $imap_folder);
} else {
echo "Failed to process email - flagging for manual review.";
imap_setflag_full($imap, $email, "\\Flagged");
}
@ -271,6 +322,5 @@ if ($emails) {
}
imap_expunge($imap);
imap_close($imap);

View File

@ -0,0 +1,276 @@
<?php
namespace PhpMimeMailParser;
use function var_dump;
/**
* Attachment of php-mime-mail-parser
*
* Fully Tested Mailparse Extension Wrapper for PHP 5.4+
*
*/
class Attachment
{
/**
* @var string $filename Filename
*/
protected $filename;
/**
* @var string $contentType Mime Type
*/
protected $contentType;
/**
* @var string $content File Content
*/
protected $content;
/**
* @var string $contentDisposition Content-Disposition (attachment or inline)
*/
protected $contentDisposition;
/**
* @var string $contentId Content-ID
*/
protected $contentId;
/**
* @var array $headers An Array of the attachment headers
*/
protected $headers;
/**
* @var resource $stream
*/
protected $stream;
/**
* @var string $mimePartStr
*/
protected $mimePartStr;
/**
* @var integer $maxDuplicateNumber
*/
public $maxDuplicateNumber = 100;
/**
* Attachment constructor.
*
* @param string $filename
* @param string $contentType
* @param resource $stream
* @param string $contentDisposition
* @param string $contentId
* @param array $headers
* @param string $mimePartStr
*/
public function __construct(
$filename,
$contentType,
$stream,
$contentDisposition = 'attachment',
$contentId = '',
$headers = [],
$mimePartStr = ''
) {
$this->filename = $filename;
$this->contentType = $contentType;
$this->stream = $stream;
$this->content = null;
$this->contentDisposition = $contentDisposition;
$this->contentId = $contentId;
$this->headers = $headers;
$this->mimePartStr = $mimePartStr;
}
/**
* retrieve the attachment filename
*
* @return string
*/
public function getFilename()
{
return $this->filename;
}
/**
* Retrieve the Attachment Content-Type
*
* @return string
*/
public function getContentType()
{
return $this->contentType;
}
/**
* Retrieve the Attachment Content-Disposition
*
* @return string
*/
public function getContentDisposition()
{
return $this->contentDisposition;
}
/**
* Retrieve the Attachment Content-ID
*
* @return string
*/
public function getContentID()
{
return $this->contentId;
}
/**
* Retrieve the Attachment Headers
*
* @return array
*/
public function getHeaders()
{
return $this->headers;
}
/**
* Get a handle to the stream
*
* @return resource
*/
public function getStream()
{
return $this->stream;
}
/**
* Rename a file if it already exists at its destination.
* Renaming is done by adding a duplicate number to the file name. E.g. existingFileName_1.ext.
* After a max duplicate number, renaming the file will switch over to generating a random suffix.
*
* @param string $fileName Complete path to the file.
* @return string The suffixed file name.
*/
protected function suffixFileName(string $fileName): string
{
$pathInfo = pathinfo($fileName);
$dirname = $pathInfo['dirname'].DIRECTORY_SEPARATOR;
$filename = $pathInfo['filename'];
$extension = empty($pathInfo['extension']) ? '' : '.'.$pathInfo['extension'];
$i = 0;
do {
$i++;
if ($i > $this->maxDuplicateNumber) {
$duplicateExtension = uniqid();
} else {
$duplicateExtension = $i;
}
$resultName = $dirname.$filename."_$duplicateExtension".$extension;
} while (file_exists($resultName));
return $resultName;
}
/**
* Read the contents a few bytes at a time until completed
* Once read to completion, it always returns false
*
* @param int $bytes (default: 2082)
*
* @return string|bool
*/
public function read($bytes = 2082)
{
return feof($this->stream) ? false : fread($this->stream, $bytes);
}
/**
* Retrieve the file content in one go
* Once you retrieve the content you cannot use MimeMailParser_attachment::read()
*
* @return string
*/
public function getContent()
{
if ($this->content === null) {
fseek($this->stream, 0);
while (($buf = $this->read()) !== false) {
$this->content .= $buf;
}
}
return $this->content;
}
/**
* Get mime part string for this attachment
*
* @return string
*/
public function getMimePartStr()
{
return $this->mimePartStr;
}
/**
* Save the attachment individually
*
* @param string $attach_dir
* @param string $filenameStrategy
*
* @return string
*/
public function save(
$attach_dir,
$filenameStrategy = Parser::ATTACHMENT_DUPLICATE_SUFFIX
) {
$attach_dir = rtrim($attach_dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (!is_dir($attach_dir)) {
mkdir($attach_dir);
}
// Determine filename
switch ($filenameStrategy) {
case Parser::ATTACHMENT_RANDOM_FILENAME:
$fileInfo = pathinfo($this->getFilename());
$extension = empty($fileInfo['extension']) ? '' : '.'.$fileInfo['extension'];
$attachment_path = $attach_dir.uniqid().$extension;
break;
case Parser::ATTACHMENT_DUPLICATE_THROW:
case Parser::ATTACHMENT_DUPLICATE_SUFFIX:
$attachment_path = $attach_dir.$this->getFilename();
break;
default:
throw new Exception('Invalid filename strategy argument provided.');
}
// Handle duplicate filename
if (file_exists($attachment_path)) {
switch ($filenameStrategy) {
case Parser::ATTACHMENT_DUPLICATE_THROW:
throw new Exception('Could not create file for attachment: duplicate filename.');
case Parser::ATTACHMENT_DUPLICATE_SUFFIX:
$attachment_path = $this->suffixFileName($attachment_path);
break;
}
}
/** @var resource $fp */
if ($fp = fopen($attachment_path, 'w')) {
while ($bytes = $this->read()) {
fwrite($fp, $bytes);
}
fclose($fp);
return realpath($attachment_path);
} else {
throw new Exception('Could not write attachments. Your directory may be unwritable by PHP.');
}
}
}

View File

@ -0,0 +1,370 @@
<?php namespace PhpMimeMailParser;
use PhpMimeMailParser\Contracts\CharsetManager;
class Charset implements CharsetManager
{
/**
* Charset Aliases
*/
private $charsetAlias = [
'ascii' => 'us-ascii',
'us-ascii' => 'us-ascii',
'ansi_x3.4-1968' => 'us-ascii',
'646' => 'us-ascii',
'iso-8859-1' => 'iso-8859-1',
'iso-8859-2' => 'iso-8859-2',
'iso-8859-3' => 'iso-8859-3',
'iso-8859-4' => 'iso-8859-4',
'iso-8859-5' => 'iso-8859-5',
'iso-8859-6' => 'iso-8859-6',
'iso-8859-6-i' => 'iso-8859-6-i',
'iso-8859-6-e' => 'iso-8859-6-e',
'iso-8859-7' => 'iso-8859-7',
'iso-8859-8' => 'iso-8859-8',
'iso-8859-8-i' => 'iso-8859-8',
'iso-8859-8-e' => 'iso-8859-8-e',
'iso-8859-9' => 'iso-8859-9',
'iso-8859-10' => 'iso-8859-10',
'iso-8859-11' => 'iso-8859-11',
'iso-8859-13' => 'iso-8859-13',
'iso-8859-14' => 'iso-8859-14',
'iso-8859-15' => 'iso-8859-15',
'iso-8859-16' => 'iso-8859-16',
'iso-ir-111' => 'iso-ir-111',
'iso-2022-cn' => 'iso-2022-cn',
'iso-2022-cn-ext' => 'iso-2022-cn',
'iso-2022-kr' => 'iso-2022-kr',
'iso-2022-jp' => 'iso-2022-jp',
'utf-16be' => 'utf-16be',
'utf-16le' => 'utf-16le',
'utf-16' => 'utf-16',
'windows-1250' => 'windows-1250',
'windows-1251' => 'windows-1251',
'windows-1252' => 'windows-1252',
'windows-1253' => 'windows-1253',
'windows-1254' => 'windows-1254',
'windows-1255' => 'windows-1255',
'windows-1256' => 'windows-1256',
'windows-1257' => 'windows-1257',
'windows-1258' => 'windows-1258',
'ibm866' => 'ibm866',
'ibm850' => 'ibm850',
'ibm852' => 'ibm852',
'ibm855' => 'ibm855',
'ibm857' => 'ibm857',
'ibm862' => 'ibm862',
'ibm864' => 'ibm864',
'utf-8' => 'utf-8',
'utf-7' => 'utf-7',
'shift_jis' => 'shift_jis',
'big5' => 'big5',
'euc-jp' => 'euc-jp',
'euc-kr' => 'euc-kr',
'gb2312' => 'gb2312',
'gb18030' => 'gb18030',
'viscii' => 'viscii',
'koi8-r' => 'koi8-r',
'koi8_r' => 'koi8-r',
'cskoi8r' => 'koi8-r',
'koi' => 'koi8-r',
'koi8' => 'koi8-r',
'koi8-u' => 'koi8-u',
'tis-620' => 'tis-620',
't.61-8bit' => 't.61-8bit',
'hz-gb-2312' => 'hz-gb-2312',
'big5-hkscs' => 'big5-hkscs',
'gbk' => 'gbk',
'cns11643' => 'x-euc-tw',
'x-imap4-modified-utf7' => 'x-imap4-modified-utf7',
'x-euc-tw' => 'x-euc-tw',
'x-mac-ce' => 'macce',
'x-mac-turkish' => 'macturkish',
'x-mac-greek' => 'macgreek',
'x-mac-icelandic' => 'macicelandic',
'x-mac-croatian' => 'maccroatian',
'x-mac-romanian' => 'macromanian',
'x-mac-cyrillic' => 'maccyrillic',
'x-mac-ukrainian' => 'macukrainian',
'x-mac-hebrew' => 'machebrew',
'x-mac-arabic' => 'macarabic',
'x-mac-farsi' => 'macfarsi',
'x-mac-devanagari' => 'macdevanagari',
'x-mac-gujarati' => 'macgujarati',
'x-mac-gurmukhi' => 'macgurmukhi',
'armscii-8' => 'armscii-8',
'x-viet-tcvn5712' => 'x-viet-tcvn5712',
'x-viet-vps' => 'x-viet-vps',
'iso-10646-ucs-2' => 'utf-16be',
'x-iso-10646-ucs-2-be' => 'utf-16be',
'x-iso-10646-ucs-2-le' => 'utf-16le',
'x-user-defined' => 'x-user-defined',
'x-johab' => 'x-johab',
'latin1' => 'iso-8859-1',
'iso_8859-1' => 'iso-8859-1',
'iso8859-1' => 'iso-8859-1',
'iso8859-2' => 'iso-8859-2',
'iso8859-3' => 'iso-8859-3',
'iso8859-4' => 'iso-8859-4',
'iso8859-5' => 'iso-8859-5',
'iso8859-6' => 'iso-8859-6',
'iso8859-7' => 'iso-8859-7',
'iso8859-8' => 'iso-8859-8',
'iso8859-9' => 'iso-8859-9',
'iso8859-10' => 'iso-8859-10',
'iso8859-11' => 'iso-8859-11',
'iso8859-13' => 'iso-8859-13',
'iso8859-14' => 'iso-8859-14',
'iso8859-15' => 'iso-8859-15',
'iso_8859-1:1987' => 'iso-8859-1',
'iso-ir-100' => 'iso-8859-1',
'l1' => 'iso-8859-1',
'ibm819' => 'iso-8859-1',
'cp819' => 'iso-8859-1',
'csisolatin1' => 'iso-8859-1',
'latin2' => 'iso-8859-2',
'iso_8859-2' => 'iso-8859-2',
'iso_8859-2:1987' => 'iso-8859-2',
'iso-ir-101' => 'iso-8859-2',
'l2' => 'iso-8859-2',
'csisolatin2' => 'iso-8859-2',
'latin3' => 'iso-8859-3',
'iso_8859-3' => 'iso-8859-3',
'iso_8859-3:1988' => 'iso-8859-3',
'iso-ir-109' => 'iso-8859-3',
'l3' => 'iso-8859-3',
'csisolatin3' => 'iso-8859-3',
'latin4' => 'iso-8859-4',
'iso_8859-4' => 'iso-8859-4',
'iso_8859-4:1988' => 'iso-8859-4',
'iso-ir-110' => 'iso-8859-4',
'l4' => 'iso-8859-4',
'csisolatin4' => 'iso-8859-4',
'cyrillic' => 'iso-8859-5',
'iso_8859-5' => 'iso-8859-5',
'iso_8859-5:1988' => 'iso-8859-5',
'iso-ir-144' => 'iso-8859-5',
'csisolatincyrillic' => 'iso-8859-5',
'arabic' => 'iso-8859-6',
'iso_8859-6' => 'iso-8859-6',
'iso_8859-6:1987' => 'iso-8859-6',
'iso-ir-127' => 'iso-8859-6',
'ecma-114' => 'iso-8859-6',
'asmo-708' => 'iso-8859-6',
'csisolatinarabic' => 'iso-8859-6',
'csiso88596i' => 'iso-8859-6-i',
'csiso88596e' => 'iso-8859-6-e',
'greek' => 'iso-8859-7',
'greek8' => 'iso-8859-7',
'sun_eu_greek' => 'iso-8859-7',
'iso_8859-7' => 'iso-8859-7',
'iso_8859-7:1987' => 'iso-8859-7',
'iso-ir-126' => 'iso-8859-7',
'elot_928' => 'iso-8859-7',
'ecma-118' => 'iso-8859-7',
'csisolatingreek' => 'iso-8859-7',
'hebrew' => 'iso-8859-8',
'iso_8859-8' => 'iso-8859-8',
'visual' => 'iso-8859-8',
'iso_8859-8:1988' => 'iso-8859-8',
'iso-ir-138' => 'iso-8859-8',
'csisolatinhebrew' => 'iso-8859-8',
'csiso88598i' => 'iso-8859-8',
'iso-8859-8i' => 'iso-8859-8',
'logical' => 'iso-8859-8',
'csiso88598e' => 'iso-8859-8-e',
'latin5' => 'iso-8859-9',
'iso_8859-9' => 'iso-8859-9',
'iso_8859-9:1989' => 'iso-8859-9',
'iso-ir-148' => 'iso-8859-9',
'l5' => 'iso-8859-9',
'csisolatin5' => 'iso-8859-9',
'unicode-1-1-utf-8' => 'utf-8',
'utf8' => 'utf-8',
'x-sjis' => 'shift_jis',
'shift-jis' => 'shift_jis',
'ms_kanji' => 'shift_jis',
'csshiftjis' => 'shift_jis',
'windows-31j' => 'shift_jis',
'cp932' => 'shift_jis',
'sjis' => 'shift_jis',
'cseucpkdfmtjapanese' => 'euc-jp',
'x-euc-jp' => 'euc-jp',
'csiso2022jp' => 'iso-2022-jp',
'iso-2022-jp-2' => 'iso-2022-jp',
'csiso2022jp2' => 'iso-2022-jp',
'csbig5' => 'big5',
'cn-big5' => 'big5',
'x-x-big5' => 'big5',
'zh_tw-big5' => 'big5',
'cseuckr' => 'euc-kr',
'ks_c_5601-1987' => 'euc-kr',
'iso-ir-149' => 'euc-kr',
'ks_c_5601-1989' => 'euc-kr',
'ksc_5601' => 'euc-kr',
'ksc5601' => 'euc-kr',
'korean' => 'euc-kr',
'csksc56011987' => 'euc-kr',
'5601' => 'euc-kr',
'windows-949' => 'euc-kr',
'gb_2312-80' => 'gb2312',
'iso-ir-58' => 'gb2312',
'chinese' => 'gb2312',
'csiso58gb231280' => 'gb2312',
'csgb2312' => 'gb2312',
'zh_cn.euc' => 'gb2312',
'gb_2312' => 'gb2312',
'x-cp1250' => 'windows-1250',
'x-cp1251' => 'windows-1251',
'x-cp1252' => 'windows-1252',
'x-cp1253' => 'windows-1253',
'x-cp1254' => 'windows-1254',
'x-cp1255' => 'windows-1255',
'x-cp1256' => 'windows-1256',
'x-cp1257' => 'windows-1257',
'x-cp1258' => 'windows-1258',
'windows-874' => 'windows-874',
'ibm874' => 'windows-874',
'dos-874' => 'windows-874',
'macintosh' => 'macintosh',
'x-mac-roman' => 'macintosh',
'mac' => 'macintosh',
'csmacintosh' => 'macintosh',
'cp866' => 'ibm866',
'cp-866' => 'ibm866',
'866' => 'ibm866',
'csibm866' => 'ibm866',
'cp850' => 'ibm850',
'850' => 'ibm850',
'csibm850' => 'ibm850',
'cp852' => 'ibm852',
'852' => 'ibm852',
'csibm852' => 'ibm852',
'cp855' => 'ibm855',
'855' => 'ibm855',
'csibm855' => 'ibm855',
'cp857' => 'ibm857',
'857' => 'ibm857',
'csibm857' => 'ibm857',
'cp862' => 'ibm862',
'862' => 'ibm862',
'csibm862' => 'ibm862',
'cp864' => 'ibm864',
'864' => 'ibm864',
'csibm864' => 'ibm864',
'ibm-864' => 'ibm864',
't.61' => 't.61-8bit',
'iso-ir-103' => 't.61-8bit',
'csiso103t618bit' => 't.61-8bit',
'x-unicode-2-0-utf-7' => 'utf-7',
'unicode-2-0-utf-7' => 'utf-7',
'unicode-1-1-utf-7' => 'utf-7',
'csunicode11utf7' => 'utf-7',
'csunicode' => 'utf-16be',
'csunicode11' => 'utf-16be',
'iso-10646-ucs-basic' => 'utf-16be',
'csunicodeascii' => 'utf-16be',
'iso-10646-unicode-latin1' => 'utf-16be',
'csunicodelatin1' => 'utf-16be',
'iso-10646' => 'utf-16be',
'iso-10646-j-1' => 'utf-16be',
'latin6' => 'iso-8859-10',
'iso-ir-157' => 'iso-8859-10',
'l6' => 'iso-8859-10',
'csisolatin6' => 'iso-8859-10',
'iso_8859-15' => 'iso-8859-15',
'csisolatin9' => 'iso-8859-15',
'l9' => 'iso-8859-15',
'ecma-cyrillic' => 'iso-ir-111',
'csiso111ecmacyrillic' => 'iso-ir-111',
'csiso2022kr' => 'iso-2022-kr',
'csviscii' => 'viscii',
'zh_tw-euc' => 'x-euc-tw',
'iso88591' => 'iso-8859-1',
'iso88592' => 'iso-8859-2',
'iso88593' => 'iso-8859-3',
'iso88594' => 'iso-8859-4',
'iso88595' => 'iso-8859-5',
'iso88596' => 'iso-8859-6',
'iso88597' => 'iso-8859-7',
'iso88598' => 'iso-8859-8',
'iso88599' => 'iso-8859-9',
'iso885910' => 'iso-8859-10',
'iso885911' => 'iso-8859-11',
'iso885912' => 'iso-8859-12',
'iso885913' => 'iso-8859-13',
'iso885914' => 'iso-8859-14',
'iso885915' => 'iso-8859-15',
'tis620' => 'tis-620',
'cp1250' => 'windows-1250',
'cp1251' => 'windows-1251',
'cp1252' => 'windows-1252',
'cp1253' => 'windows-1253',
'cp1254' => 'windows-1254',
'cp1255' => 'windows-1255',
'cp1256' => 'windows-1256',
'cp1257' => 'windows-1257',
'cp1258' => 'windows-1258',
'x-gbk' => 'gbk',
'windows-936' => 'gbk',
'ansi-1251' => 'windows-1251',
];
/**
* {@inheritdoc}
*/
public function decodeCharset($encodedString, $charset)
{
$charset = $this->getCharsetAlias($charset);
if ($charset == 'utf-8' || $charset == 'us-ascii') {
return $encodedString;
}
if (function_exists('mb_convert_encoding')) {
if ($charset == 'iso-2022-jp') {
return mb_convert_encoding($encodedString, 'utf-8', 'iso-2022-jp-ms');
}
if (array_search($charset, $this->getSupportedEncodings())) {
return mb_convert_encoding($encodedString, 'utf-8', $charset);
}
}
return iconv($charset, 'utf-8//translit//ignore', $encodedString);
}
/**
* {@inheritdoc}
*/
public function getCharsetAlias($charset)
{
$charset = strtolower($charset);
if (array_key_exists($charset, $this->charsetAlias)) {
return $this->charsetAlias[$charset];
}
return 'us-ascii';
}
private function getSupportedEncodings()
{
return
array_map(
'strtolower',
array_unique(
array_merge(
$enc = mb_list_encodings(),
call_user_func_array(
'array_merge',
array_map(
"mb_encoding_aliases",
$enc
)
)
)
)
);
}
}

View File

@ -0,0 +1,24 @@
<?php namespace PhpMimeMailParser\Contracts;
interface CharsetManager
{
/**
* Decode the string from Charset
*
* @param string $encodedString The string in its original encoded state
* @param string $charset The Charset header of the part.
*
* @return string The decoded string
*/
public function decodeCharset($encodedString, $charset);
/**
* Get charset alias
*
* @param string $charset .
*
* @return string The charset alias
*/
public function getCharsetAlias($charset);
}

View File

@ -0,0 +1,23 @@
<?php
namespace PhpMimeMailParser\Contracts;
use PhpMimeMailParser\MimePart;
use PhpMimeMailParser\MiddlewareStack;
/**
* Process Mime parts by either:
* processing the part or calling the $next MiddlewareStack
*/
interface Middleware
{
/**
* Process a mime part, optionally delegating parsing to the $next MiddlewareStack
*
* @param MimePart $part
* @param MiddlewareStack $next
*
* @return MimePart
*/
public function parse(MimePart $part, MiddlewareStack $next);
}

View File

@ -0,0 +1,8 @@
<?php
namespace PhpMimeMailParser;
class Exception extends \RuntimeException
{
}

View File

@ -0,0 +1,29 @@
<?php
namespace PhpMimeMailParser;
/**
* Wraps a callable as a Middleware
*/
class Middleware implements Contracts\Middleware
{
protected $parser;
/**
* Create a middleware using a callable $fn
*
* @param callable $fn
*/
public function __construct(callable $fn)
{
$this->parser = $fn;
}
/**
* Process a mime part, optionally delegating parsing to the $next MiddlewareStack
*/
public function parse(MimePart $part, MiddlewareStack $next)
{
return call_user_func($this->parser, $part, $next);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace PhpMimeMailParser;
use PhpMimeMailParser\Contracts\MiddleWare as MiddleWareContracts;
/**
* A stack of middleware chained together by (MiddlewareStack $next)
*/
class MiddlewareStack
{
/**
* Next MiddlewareStack in chain
*
* @var MiddlewareStack
*/
protected $next;
/**
* Middleware in this MiddlewareStack
*
* @var Middleware
*/
protected $middleware;
/**
* Construct the first middleware in this MiddlewareStack
* The next middleware is chained through $MiddlewareStack->add($Middleware)
*
* @param Middleware $middleware
*/
public function __construct(MiddleWareContracts $middleware = null)
{
$this->middleware = $middleware;
}
/**
* Creates a chained middleware in MiddlewareStack
*
* @param Middleware $middleware
* @return MiddlewareStack Immutable MiddlewareStack
*/
public function add(MiddleWareContracts $middleware)
{
$stack = new static($middleware);
$stack->next = $this;
return $stack;
}
/**
* Parses the MimePart by passing it through the Middleware
* @param MimePart $part
* @return MimePart
*/
public function parse(MimePart $part)
{
if (!$this->middleware) {
return $part;
}
$part = call_user_func(array($this->middleware, 'parse'), $part, $this->next);
return $part;
}
/**
* Creates a MiddlewareStack based on an array of middleware
*
* @param Middleware[] $middlewares
* @return MiddlewareStack
*/
public static function factory(array $middlewares = array())
{
$stack = new static;
foreach ($middlewares as $middleware) {
$stack = $stack->add($middleware);
}
return $stack;
}
/**
* Allow calling MiddlewareStack instance directly to invoke parse()
*
* @param MimePart $part
* @return MimePart
*/
public function __invoke(MimePart $part)
{
return $this->parse($part);
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace PhpMimeMailParser;
/**
* Mime Part
* Represents the results of mailparse_msg_get_part_data()
*
* Note ArrayAccess::offsetSet() cannot modify deeply nestated arrays.
* When modifying use getPart() and setPart() for deep nested data modification
*
* @example
*
* $MimePart['headers']['from'] = 'modified@example.com' // fails
*
* // correct
* $part = $MimePart->getPart();
* $part['headers']['from'] = 'modified@example.com';
* $MimePart->setPart($part);
*/
class MimePart implements \ArrayAccess
{
/**
* Internal mime part
*
* @var array
*/
protected $part = array();
/**
* Immutable Part Id
*
* @var string
*/
private $id;
/**
* Create a mime part
*
* @param array $part
* @param string $id
*/
public function __construct($id, array $part)
{
$this->part = $part;
$this->id = $id;
}
/**
* Retrieve the part Id
*
* @return string
*/
public function getId()
{
return $this->id;
}
/**
* Retrieve the part data
*
* @return array
*/
public function getPart()
{
return $this->part;
}
/**
* Set the mime part data
*
* @param array $part
* @return void
*/
public function setPart(array $part)
{
$this->part = $part;
}
/**
* ArrayAccess
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
if (is_null($offset)) {
$this->part[] = $value;
return;
}
$this->part[$offset] = $value;
}
/**
* ArrayAccess
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->part[$offset]);
}
/**
* ArrayAccess
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
unset($this->part[$offset]);
}
/**
* ArrayAccess
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return isset($this->part[$offset]) ? $this->part[$offset] : null;
}
}

View File

@ -0,0 +1,923 @@
<?php
namespace PhpMimeMailParser;
use PhpMimeMailParser\Contracts\CharsetManager;
/**
* Parser of php-mime-mail-parser
*
* Fully Tested Mailparse Extension Wrapper for PHP 5.4+
*
*/
class Parser
{
/**
* Attachment filename argument option for ->saveAttachments().
*/
const ATTACHMENT_DUPLICATE_THROW = 'DuplicateThrow';
const ATTACHMENT_DUPLICATE_SUFFIX = 'DuplicateSuffix';
const ATTACHMENT_RANDOM_FILENAME = 'RandomFilename';
/**
* PHP MimeParser Resource ID
*
* @var resource $resource
*/
protected $resource;
/**
* A file pointer to email
*
* @var resource $stream
*/
protected $stream;
/**
* A text of an email
*
* @var string $data
*/
protected $data;
/**
* Parts of an email
*
* @var array $parts
*/
protected $parts;
/**
* @var CharsetManager object
*/
protected $charset;
/**
* Valid stream modes for reading
*
* @var array
*/
protected static $readableModes = [
'r', 'r+', 'w+', 'a+', 'x+', 'c+', 'rb', 'r+b', 'w+b', 'a+b',
'x+b', 'c+b', 'rt', 'r+t', 'w+t', 'a+t', 'x+t', 'c+t'
];
/**
* Stack of middleware registered to process data
*
* @var MiddlewareStack
*/
protected $middlewareStack;
/**
* Parser constructor.
*
* @param CharsetManager|null $charset
*/
public function __construct(CharsetManager $charset = null)
{
if ($charset == null) {
$charset = new Charset();
}
$this->charset = $charset;
$this->middlewareStack = new MiddlewareStack();
}
/**
* Free the held resources
*
* @return void
*/
public function __destruct()
{
// clear the email file resource
if (is_resource($this->stream)) {
fclose($this->stream);
}
// clear the MailParse resource
if (is_resource($this->resource)) {
mailparse_msg_free($this->resource);
}
}
/**
* Set the file path we use to get the email text
*
* @param string $path File path to the MIME mail
*
* @return Parser MimeMailParser Instance
*/
public function setPath($path)
{
if (is_writable($path)) {
$file = fopen($path, 'a+');
fseek($file, -1, SEEK_END);
if (fread($file, 1) != "\n") {
fwrite($file, PHP_EOL);
}
fclose($file);
}
// should parse message incrementally from file
$this->resource = mailparse_msg_parse_file($path);
$this->stream = fopen($path, 'r');
$this->parse();
return $this;
}
/**
* Set the Stream resource we use to get the email text
*
* @param resource $stream
*
* @return Parser MimeMailParser Instance
* @throws Exception
*/
public function setStream($stream)
{
// streams have to be cached to file first
$meta = @stream_get_meta_data($stream);
if (!$meta || !$meta['mode'] || !in_array($meta['mode'], self::$readableModes, true) || $meta['eof']) {
throw new Exception(
'setStream() expects parameter stream to be readable stream resource.'
);
}
/** @var resource $tmp_fp */
$tmp_fp = tmpfile();
if ($tmp_fp) {
while (!feof($stream)) {
fwrite($tmp_fp, fread($stream, 2028));
}
if (fread($tmp_fp, 1) != "\n") {
fwrite($tmp_fp, PHP_EOL);
}
fseek($tmp_fp, 0);
$this->stream = &$tmp_fp;
} else {
throw new Exception(
'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.'
);
}
fclose($stream);
$this->resource = mailparse_msg_create();
// parses the message incrementally (low memory usage but slower)
while (!feof($this->stream)) {
mailparse_msg_parse($this->resource, fread($this->stream, 2082));
}
$this->parse();
return $this;
}
/**
* Set the email text
*
* @param string $data
*
* @return Parser MimeMailParser Instance
*/
public function setText($data)
{
if (empty($data)) {
throw new Exception('You must not call MimeMailParser::setText with an empty string parameter');
}
if (substr($data, -1) != "\n") {
$data = $data.PHP_EOL;
}
$this->resource = mailparse_msg_create();
// does not parse incrementally, fast memory hog might explode
mailparse_msg_parse($this->resource, $data);
$this->data = $data;
$this->parse();
return $this;
}
/**
* Parse the Message into parts
*
* @return void
*/
protected function parse()
{
$structure = mailparse_msg_get_structure($this->resource);
$this->parts = [];
foreach ($structure as $part_id) {
$part = mailparse_msg_get_part($this->resource, $part_id);
$part_data = mailparse_msg_get_part_data($part);
$mimePart = new MimePart($part_id, $part_data);
// let each middleware parse the part before saving
$this->parts[$part_id] = $this->middlewareStack->parse($mimePart)->getPart();
}
}
/**
* Retrieve a specific Email Header, without charset conversion.
*
* @param string $name Header name (case-insensitive)
*
* @return string|bool
* @throws Exception
*/
public function getRawHeader($name)
{
$name = strtolower($name);
if (isset($this->parts[1])) {
$headers = $this->getPart('headers', $this->parts[1]);
return isset($headers[$name]) ? $headers[$name] : false;
} else {
throw new Exception(
'setPath() or setText() or setStream() must be called before retrieving email headers.'
);
}
}
/**
* Retrieve a specific Email Header
*
* @param string $name Header name (case-insensitive)
*
* @return string|bool
*/
public function getHeader($name)
{
$rawHeader = $this->getRawHeader($name);
if ($rawHeader === false) {
return false;
}
return $this->decodeHeader($rawHeader);
}
/**
* Retrieve all mail headers
*
* @return array
* @throws Exception
*/
public function getHeaders()
{
if (isset($this->parts[1])) {
$headers = $this->getPart('headers', $this->parts[1]);
foreach ($headers as &$value) {
if (is_array($value)) {
foreach ($value as &$v) {
$v = $this->decodeSingleHeader($v);
}
} else {
$value = $this->decodeSingleHeader($value);
}
}
return $headers;
} else {
throw new Exception(
'setPath() or setText() or setStream() must be called before retrieving email headers.'
);
}
}
/**
* Retrieve the raw mail headers as a string
*
* @return string
* @throws Exception
*/
public function getHeadersRaw()
{
if (isset($this->parts[1])) {
return $this->getPartHeader($this->parts[1]);
} else {
throw new Exception(
'setPath() or setText() or setStream() must be called before retrieving email headers.'
);
}
}
/**
* Retrieve the raw Header of a MIME part
*
* @return String
* @param $part Object
* @throws Exception
*/
protected function getPartHeader(&$part)
{
$header = '';
if ($this->stream) {
$header = $this->getPartHeaderFromFile($part);
} elseif ($this->data) {
$header = $this->getPartHeaderFromText($part);
}
return $header;
}
/**
* Retrieve the Header from a MIME part from file
*
* @return String Mime Header Part
* @param $part Array
*/
protected function getPartHeaderFromFile(&$part)
{
$start = $part['starting-pos'];
$end = $part['starting-pos-body'];
fseek($this->stream, $start, SEEK_SET);
$header = fread($this->stream, $end - $start);
return $header;
}
/**
* Retrieve the Header from a MIME part from text
*
* @return String Mime Header Part
* @param $part Array
*/
protected function getPartHeaderFromText(&$part)
{
$start = $part['starting-pos'];
$end = $part['starting-pos-body'];
$header = substr($this->data, $start, $end - $start);
return $header;
}
/**
* Checks whether a given part ID is a child of another part
* eg. an RFC822 attachment may have one or more text parts
*
* @param string $partId
* @param string $parentPartId
* @return bool
*/
protected function partIdIsChildOfPart($partId, $parentPartId)
{
$parentPartId = $parentPartId.'.';
return substr($partId, 0, strlen($parentPartId)) == $parentPartId;
}
/**
* Whether the given part ID is a child of any attachment part in the message.
*
* @param string $checkPartId
* @return bool
*/
protected function partIdIsChildOfAnAttachment($checkPartId)
{
foreach ($this->parts as $partId => $part) {
if ($this->getPart('content-disposition', $part) == 'attachment') {
if ($this->partIdIsChildOfPart($checkPartId, $partId)) {
return true;
}
}
}
return false;
}
/**
* Returns the email message body in the specified format
*
* @param string $type text, html or htmlEmbedded
*
* @return string Body
* @throws Exception
*/
public function getMessageBody($type = 'text')
{
$mime_types = [
'text' => 'text/plain',
'html' => 'text/html',
'htmlEmbedded' => 'text/html',
];
if (in_array($type, array_keys($mime_types))) {
$part_type = $type === 'htmlEmbedded' ? 'html' : $type;
$inline_parts = $this->getInlineParts($part_type);
$body = empty($inline_parts) ? '' : $inline_parts[0];
} else {
throw new Exception(
'Invalid type specified for getMessageBody(). Expected: text, html or htmlEmbeded.'
);
}
if ($type == 'htmlEmbedded') {
$attachments = $this->getAttachments();
foreach ($attachments as $attachment) {
if ($attachment->getContentID() != '') {
$body = str_replace(
'"cid:'.$attachment->getContentID().'"',
'"'.$this->getEmbeddedData($attachment->getContentID()).'"',
$body
);
}
}
}
return $body;
}
/**
* Returns the embedded data structure
*
* @param string $contentId Content-Id
*
* @return string
*/
protected function getEmbeddedData($contentId)
{
foreach ($this->parts as $part) {
if ($this->getPart('content-id', $part) == $contentId) {
$embeddedData = 'data:';
$embeddedData .= $this->getPart('content-type', $part);
$embeddedData .= ';'.$this->getPart('transfer-encoding', $part);
$embeddedData .= ','.$this->getPartBody($part);
return $embeddedData;
}
}
return '';
}
/**
* Return an array with the following keys display, address, is_group
*
* @param string $name Header name (case-insensitive)
*
* @return array
*/
public function getAddresses($name)
{
$value = $this->getRawHeader($name);
$value = (is_array($value)) ? $value[0] : $value;
$addresses = mailparse_rfc822_parse_addresses($value);
foreach ($addresses as $i => $item) {
$addresses[$i]['display'] = $this->decodeHeader($item['display']);
}
return $addresses;
}
/**
* Returns the attachments contents in order of appearance
*
* @return Attachment[]
*/
public function getInlineParts($type = 'text')
{
$inline_parts = [];
$mime_types = [
'text' => 'text/plain',
'html' => 'text/html',
];
if (!in_array($type, array_keys($mime_types))) {
throw new Exception('Invalid type specified for getInlineParts(). "type" can either be text or html.');
}
foreach ($this->parts as $partId => $part) {
if ($this->getPart('content-type', $part) == $mime_types[$type]
&& $this->getPart('content-disposition', $part) != 'attachment'
&& !$this->partIdIsChildOfAnAttachment($partId)
) {
$headers = $this->getPart('headers', $part);
$encodingType = array_key_exists('content-transfer-encoding', $headers) ?
$headers['content-transfer-encoding'] : '';
$undecoded_body = $this->decodeContentTransfer($this->getPartBody($part), $encodingType);
$inline_parts[] = $this->charset->decodeCharset($undecoded_body, $this->getPartCharset($part));
}
}
return $inline_parts;
}
/**
* Returns the attachments contents in order of appearance
*
* @return Attachment[]
*/
public function getAttachments($include_inline = true)
{
$attachments = [];
$dispositions = $include_inline ? ['attachment', 'inline'] : ['attachment'];
$non_attachment_types = ['text/plain', 'text/html'];
$nonameIter = 0;
foreach ($this->parts as $part) {
$disposition = $this->getPart('content-disposition', $part);
$filename = 'noname';
if (isset($part['disposition-filename'])) {
$filename = $this->decodeHeader($part['disposition-filename']);
} elseif (isset($part['content-name'])) {
// if we have no disposition but we have a content-name, it's a valid attachment.
// we simulate the presence of an attachment disposition with a disposition filename
$filename = $this->decodeHeader($part['content-name']);
$disposition = 'attachment';
} elseif (in_array($part['content-type'], $non_attachment_types, true)
&& $disposition !== 'attachment') {
// it is a message body, no attachment
continue;
} elseif (substr($part['content-type'], 0, 10) !== 'multipart/'
&& $part['content-type'] !== 'text/plain; (error)') {
// if we cannot get it by getMessageBody(), we assume it is an attachment
$disposition = 'attachment';
}
if (in_array($disposition, ['attachment', 'inline']) === false && !empty($disposition)) {
$disposition = 'attachment';
}
if (in_array($disposition, $dispositions) === true) {
if ($filename == 'noname') {
$nonameIter++;
$filename = 'noname'.$nonameIter;
} else {
// Escape all potentially unsafe characters from the filename
$filename = preg_replace('((^\.)|\/|[\n|\r|\n\r]|(\.$))', '_', $filename);
}
$headersAttachments = $this->getPart('headers', $part);
$contentidAttachments = $this->getPart('content-id', $part);
$attachmentStream = $this->getAttachmentStream($part);
$mimePartStr = $this->getPartComplete($part);
$attachments[] = new Attachment(
$filename,
$this->getPart('content-type', $part),
$attachmentStream,
$disposition,
$contentidAttachments,
$headersAttachments,
$mimePartStr
);
}
}
return $attachments;
}
/**
* Save attachments in a folder
*
* @param string $attach_dir directory
* @param bool $include_inline
* @param string $filenameStrategy How to generate attachment filenames
*
* @return array Saved attachments paths
* @throws Exception
*/
public function saveAttachments(
$attach_dir,
$include_inline = true,
$filenameStrategy = self::ATTACHMENT_DUPLICATE_SUFFIX
) {
$attachments = $this->getAttachments($include_inline);
$attachments_paths = [];
foreach ($attachments as $attachment) {
$attachments_paths[] = $attachment->save($attach_dir, $filenameStrategy);
}
return $attachments_paths;
}
/**
* Read the attachment Body and save temporary file resource
*
* @param array $part
*
* @return resource Mime Body Part
* @throws Exception
*/
protected function getAttachmentStream(&$part)
{
/** @var resource $temp_fp */
$temp_fp = tmpfile();
$headers = $this->getPart('headers', $part);
$encodingType = array_key_exists('content-transfer-encoding', $headers) ?
$headers['content-transfer-encoding'] : '';
if ($temp_fp) {
if ($this->stream) {
$start = $part['starting-pos-body'];
$end = $part['ending-pos-body'];
fseek($this->stream, $start, SEEK_SET);
$len = $end - $start;
$written = 0;
while ($written < $len) {
$write = $len;
$data = fread($this->stream, $write);
fwrite($temp_fp, $this->decodeContentTransfer($data, $encodingType));
$written += $write;
}
} elseif ($this->data) {
$attachment = $this->decodeContentTransfer($this->getPartBodyFromText($part), $encodingType);
fwrite($temp_fp, $attachment, strlen($attachment));
}
fseek($temp_fp, 0, SEEK_SET);
} else {
throw new Exception(
'Could not create temporary files for attachments. Your tmp directory may be unwritable by PHP.'
);
}
return $temp_fp;
}
/**
* Decode the string from Content-Transfer-Encoding
*
* @param string $encodedString The string in its original encoded state
* @param string $encodingType The encoding type from the Content-Transfer-Encoding header of the part.
*
* @return string The decoded string
*/
protected function decodeContentTransfer($encodedString, $encodingType)
{
if (is_array($encodingType)) {
$encodingType = $encodingType[0];
}
$encodingType = strtolower($encodingType);
if ($encodingType == 'base64') {
return base64_decode($encodedString);
} elseif ($encodingType == 'quoted-printable') {
return quoted_printable_decode($encodedString);
} else {
return $encodedString;
}
}
/**
* $input can be a string or array
*
* @param string|array $input
*
* @return string
*/
protected function decodeHeader($input)
{
//Sometimes we have 2 label From so we take only the first
if (is_array($input)) {
return $this->decodeSingleHeader($input[0]);
}
return $this->decodeSingleHeader($input);
}
/**
* Decodes a single header (= string)
*
* @param string $input
*
* @return string
*/
protected function decodeSingleHeader($input)
{
// For each encoded-word...
while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)((\s+)=\?)?/i', $input, $matches)) {
$encoded = $matches[1];
$charset = $matches[2];
$encoding = $matches[3];
$text = $matches[4];
$space = isset($matches[6]) ? $matches[6] : '';
switch (strtolower($encoding)) {
case 'b':
$text = $this->decodeContentTransfer($text, 'base64');
break;
case 'q':
$text = str_replace('_', ' ', $text);
preg_match_all('/=([a-f0-9]{2})/i', $text, $matches);
foreach ($matches[1] as $value) {
$text = str_replace('='.$value, chr(hexdec($value)), $text);
}
break;
}
$text = $this->charset->decodeCharset($text, $this->charset->getCharsetAlias($charset));
$input = str_replace($encoded.$space, $text, $input);
}
return $input;
}
/**
* Return the charset of the MIME part
*
* @param array $part
*
* @return string
*/
protected function getPartCharset($part)
{
if (isset($part['charset'])) {
return $this->charset->getCharsetAlias($part['charset']);
} else {
return 'us-ascii';
}
}
/**
* Retrieve a specified MIME part
*
* @param string $type
* @param array $parts
*
* @return string|array
*/
protected function getPart($type, $parts)
{
return (isset($parts[$type])) ? $parts[$type] : false;
}
/**
* Retrieve the Body of a MIME part
*
* @param array $part
*
* @return string
*/
protected function getPartBody(&$part)
{
$body = '';
if ($this->stream) {
$body = $this->getPartBodyFromFile($part);
} elseif ($this->data) {
$body = $this->getPartBodyFromText($part);
}
return $body;
}
/**
* Retrieve the Body from a MIME part from file
*
* @param array $part
*
* @return string Mime Body Part
*/
protected function getPartBodyFromFile(&$part)
{
$start = $part['starting-pos-body'];
$end = $part['ending-pos-body'];
$body = '';
if ($end - $start > 0) {
fseek($this->stream, $start, SEEK_SET);
$body = fread($this->stream, $end - $start);
}
return $body;
}
/**
* Retrieve the Body from a MIME part from text
*
* @param array $part
*
* @return string Mime Body Part
*/
protected function getPartBodyFromText(&$part)
{
$start = $part['starting-pos-body'];
$end = $part['ending-pos-body'];
return substr($this->data, $start, $end - $start);
}
/**
* Retrieve the content of a MIME part
*
* @param array $part
*
* @return string
*/
protected function getPartComplete(&$part)
{
$body = '';
if ($this->stream) {
$body = $this->getPartFromFile($part);
} elseif ($this->data) {
$body = $this->getPartFromText($part);
}
return $body;
}
/**
* Retrieve the content from a MIME part from file
*
* @param array $part
*
* @return string Mime Content
*/
protected function getPartFromFile(&$part)
{
$start = $part['starting-pos'];
$end = $part['ending-pos'];
$body = '';
if ($end - $start > 0) {
fseek($this->stream, $start, SEEK_SET);
$body = fread($this->stream, $end - $start);
}
return $body;
}
/**
* Retrieve the content from a MIME part from text
*
* @param array $part
*
* @return string Mime Content
*/
protected function getPartFromText(&$part)
{
$start = $part['starting-pos'];
$end = $part['ending-pos'];
return substr($this->data, $start, $end - $start);
}
/**
* Retrieve the resource
*
* @return resource resource
*/
public function getResource()
{
return $this->resource;
}
/**
* Retrieve the file pointer to email
*
* @return resource stream
*/
public function getStream()
{
return $this->stream;
}
/**
* Retrieve the text of an email
*
* @return string data
*/
public function getData()
{
return $this->data;
}
/**
* Retrieve the parts of an email
*
* @return array parts
*/
public function getParts()
{
return $this->parts;
}
/**
* Retrieve the charset manager object
*
* @return CharsetManager charset
*/
public function getCharset()
{
return $this->charset;
}
/**
* Add a middleware to the parser MiddlewareStack
* Each middleware is invoked when:
* a MimePart is retrieved by mailparse_msg_get_part_data() during $this->parse()
* The middleware will receive MimePart $part and the next MiddlewareStack $next
*
* Eg:
*
* $Parser->addMiddleware(function(MimePart $part, MiddlewareStack $next) {
* // do something with the $part
* return $next($part);
* });
*
* @param callable $middleware Plain Function or Middleware Instance to execute
* @return void
*/
public function addMiddleware(callable $middleware)
{
if (!$middleware instanceof Middleware) {
$middleware = new Middleware($middleware);
}
$this->middlewareStack = $this->middlewareStack->add($middleware);
}
}