Added back the old cron ticket email parser as legacy_cront_ticket_email_parser.php and added back the old libraries php-mime-mail-parser in plugins note if you recently installed itflow within the last 2 weeks you will need to install and enable the php libraries php-imap and php-mailparse if you decide to use the legacy mailer

This commit is contained in:
johnnyq 2024-06-29 13:40:47 -04:00
parent f133981b52
commit cc38c642c5
10 changed files with 2417 additions and 0 deletions

View File

@ -0,0 +1,556 @@
<?php
/*
* CRON - Email Parser
* Process emails and create/update tickets
*/
/*
TODO:
- Process unregistered contacts/clients into an inbox to allow a ticket to be created/ignored
- Support for authenticating with OAuth
- Separate Mailbox Account for tickets 2022-12-14 - JQ
*/
// Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__));
// Get ITFlow config & helper functions
require_once "config.php";
// Set Timezone
require_once "inc_set_timezone.php";
require_once "functions.php";
// Get settings for the "default" company
require_once "get_settings.php";
$config_ticket_prefix = sanitizeInput($config_ticket_prefix);
$config_ticket_from_name = sanitizeInput($config_ticket_from_name);
// Get company name & phone & timezone
$sql = mysqli_query($mysqli, "SELECT * FROM companies, settings WHERE companies.company_id = settings.company_id AND companies.company_id = 1");
$row = mysqli_fetch_array($sql);
$company_name = sanitizeInput($row['company_name']);
$company_phone = sanitizeInput(formatPhoneNumber($row['company_phone']));
// Check setting enabled
if ($config_ticket_email_parse == 0) {
exit("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
}
$argv = $_SERVER['argv'];
// Check Cron Key
if ( $argv[1] !== $config_cron_key ) {
exit("Cron Key invalid -- Quitting..");
}
// Check IMAP extension works/installed
if (!function_exists('imap_open')) {
exit("Email Parser: PHP IMAP extension is not installed. See https://docs.itflow.org/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://docs.itflow.org/ticket_email_parse -- Quitting..");
}
// Get system temp directory
$temp_dir = sys_get_temp_dir();
// Create the path for the lock file using the temp directory
$lock_file_path = "{$temp_dir}/itflow_legacy_email_parser_{$installation_id}.lock";
// Check for lock file to prevent concurrent script runs
if (file_exists($lock_file_path)) {
$file_age = time() - filemtime($lock_file_path);
// If file is older than 3 minutes (180 seconds), delete and continue
if ($file_age > 300) {
unlink($lock_file_path);
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Cron-Email-Parser', log_action = 'Delete', log_description = 'Cron Email Parser detected a lock file was present but was over 10 minutes old so it removed it'");
} else {
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Cron-Email-Parser', log_action = 'Locked', log_description = 'Cron Email Parser attempted to execute but was already executing, so instead it terminated.'");
exit("Script is already running. Exiting.");
}
}
// Create a lock file
file_put_contents($lock_file_path, "Locked");
// PHP Mail Parser
use PhpMimeMailParser\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";
// Allowed attachment extensions
$allowed_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp', 'pdf', 'txt', 'md', 'doc', 'docx', 'csv', 'xls', 'xlsx', 'xlsm', 'zip', 'tar', 'gz');
// Function to raise a new ticket for a given contact and email them confirmation (if configured)
function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file) {
// Access global variables
global $mysqli,$config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_ticket_client_general_notifications, $config_ticket_new_ticket_notification_email, $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, $allowed_extensions;
// 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 = 1"));
$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 = 1");
// Prep ticket details
$message = nl2br($message);
$message = mysqli_escape_string($mysqli, "<i>Email from: $contact_email at $date:-</i> <br><br>$message");
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 = 1, ticket_created_by = 0, ticket_contact_id = $contact_id, ticket_client_id = $client_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) ($id)', log_client_id = $client_id");
// -- Process attachments (after ticket is logged as created because we save to the folder named after the ticket ID) --
mkdirMissing('uploads/tickets/'); // Create tickets dir
// Setup directory for this ticket ID
$att_dir = "uploads/tickets/" . $id . "/";
mkdirMissing($att_dir);
// Save original email message as ticket attachment
rename("uploads/tmp/{$original_message_file}", "{$att_dir}/{$original_message_file}");
mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = 'Original-parsed-email.eml', ticket_attachment_reference_name = '$original_message_file', ticket_attachment_ticket_id = $id");
// Process each attachment
foreach ($attachments as $attachment) {
// Get name and extension
$att_name = $attachment->getFileName();
$att_extarr = explode('.', $att_name);
$att_extension = strtolower(end($att_extarr));
// Check the extension is allowed
if (in_array($att_extension, $allowed_extensions)) {
// Save attachment with a random name
$att_saved_path = $attachment->save($att_dir, Parser::ATTACHMENT_RANDOM_FILENAME);
// Access the random name to add into the database (this won't work on Windows)
$att_tmparr = explode($att_dir, $att_saved_path);
$ticket_attachment_name = sanitizeInput($att_name);
$ticket_attachment_reference_name = sanitizeInput(end($att_tmparr));
mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name', ticket_attachment_reference_name = '$ticket_attachment_reference_name', ticket_attachment_ticket_id = $id");
} else {
$ticket_attachment_name = sanitizeInput($att_name);
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Ticket', log_action = 'Update', log_description = 'Email parser: Blocked attachment $ticket_attachment_name from Client contact $contact_email for ticket $config_ticket_prefix$ticket_number', log_client_id = $client_id");
}
}
$data = [];
// E-mail client notification that ticket has been created
if ($config_ticket_client_general_notifications == 1) {
$subject_email = "Ticket created - [$config_ticket_prefix$ticket_number] - $subject";
$body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>Thank you for your email. A ticket regarding \"$subject\" has been automatically created for you.<br><br>Ticket: $config_ticket_prefix$ticket_number<br>Subject: $subject<br>Status: New<br>https://$config_base_url/portal/ticket.php?id=$id<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone";
$data[] = [
'from' => $config_ticket_from_email,
'from_name' => $config_ticket_from_name,
'recipient' => $contact_email,
'recipient_name' => $contact_name,
'subject' => $subject_email,
'body' => $body
];
}
// Notify agent DL of the new ticket, if populated with a valid email
if ($config_ticket_new_ticket_notification_email) {
// Get client info
$client_sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id");
$client_row = mysqli_fetch_array($client_sql);
$client_name = sanitizeInput($client_row['client_name']);
$email_subject = "$config_app_name - New Ticket - $client_name: $subject";
$email_body = "Hello, <br><br>This is a notification that a new ticket has been raised in ITFlow. <br>Client: $client_name<br>Priority: Low (email parsed)<br>Link: https://$config_base_url/ticket.php?ticket_id=$id <br><br>--------------------------------<br><br><b>$subject</b><br>$details";
$data[] = [
'from' => $config_ticket_from_email,
'from_name' => $config_ticket_from_name,
'recipient' => $config_ticket_new_ticket_notification_email,
'recipient_name' => $config_ticket_from_name,
'subject' => $email_subject,
'body' => $email_body
];
}
addToMailQueue($mysqli, $data);
return true;
}
// End Add Ticket Function
// Add Reply Function
function addReply($from_email, $date, $subject, $ticket_number, $message, $attachments) {
// Add email as a comment/reply to an existing ticket
// Access global variables
global $mysqli, $config_app_name, $company_name, $company_phone, $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, $allowed_extensions;
// Set default reply type
$ticket_reply_type = 'Client';
// Capture just the latest/most recent email reply content
// based off the "##- Please type your reply above this line -##" line that we prepend the outgoing emails with
$message = explode("##- Please type your reply above this line -##", $message);
$message = nl2br($message[0]);
$message = mysqli_escape_string($mysqli, "<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, contact_email, client_name
FROM tickets
LEFT JOIN contacts on tickets.ticket_contact_id = contacts.contact_id
LEFT JOIN clients on tickets.ticket_client_id = clients.client_id
WHERE ticket_number = $ticket_number LIMIT 1"));
if ($row) {
// Get ticket details
$ticket_id = intval($row['ticket_id']);
$ticket_subject = sanitizeInput($row['ticket_subject']);
$ticket_status = sanitizeInput($row['ticket_status']);
$ticket_reply_contact = intval($row['ticket_contact_id']);
$ticket_contact_email = sanitizeInput($row['contact_email']);
$client_id = intval($row['ticket_client_id']);
$client_name = sanitizeInput($row['client_name']);
// Check ticket isn't closed - tickets can't be re-opened
if ($ticket_status == 5) {
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_action = 'ticket.php?ticket_id=$ticket_id', notification_client_id = $client_id");
$email_subject = "Action required: This ticket is already closed";
$email_body = "Hi there, <br><br>You\'ve tried to reply to a ticket that is closed - we won\'t see your response. <br><br>Please raise a new ticket by sending a fresh e-mail to our support address below. <br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone";
$data = [
[
'from' => $config_ticket_from_email,
'from_name' => $config_ticket_from_name,
'recipient' => $from_email,
'recipient_name' => $from_email,
'subject' => $email_subject,
'body' => $email_body
]
];
addToMailQueue($mysqli, $data);
return true;
}
// 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 = intval($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)
}
}
// Add the comment
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$message', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '00:00:00', ticket_reply_by = $ticket_reply_contact, ticket_reply_ticket_id = $ticket_id");
$reply_id = mysqli_insert_id($mysqli);
// Process attachments
mkdirMissing('uploads/tickets/');
foreach ($attachments as $attachment) {
// Get name and extension
$att_name = $attachment->getFileName();
$att_extarr = explode('.', $att_name);
$att_extension = strtolower(end($att_extarr));
// Check the extension is allowed
if (in_array($att_extension, $allowed_extensions)) {
// Setup directory for this ticket ID
$att_dir = "uploads/tickets/" . $ticket_id . "/";
mkdirMissing($att_dir);
// Save attachment with a random name
$att_saved_path = $attachment->save($att_dir, Parser::ATTACHMENT_RANDOM_FILENAME);
// Access the random name to add into the database (this won't work on Windows)
$att_tmparr = explode($att_dir, $att_saved_path);
$ticket_attachment_name = sanitizeInput($att_name);
$ticket_attachment_reference_name = sanitizeInput(end($att_tmparr));
mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name', ticket_attachment_reference_name = '$ticket_attachment_reference_name', ticket_attachment_reply_id = $reply_id, ticket_attachment_ticket_id = $ticket_id");
} else {
$ticket_attachment_name = sanitizeInput($att_name);
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Ticket', log_action = 'Update', log_description = 'Email parser: Blocked attachment $ticket_attachment_name from Client contact $from_email for ticket $config_ticket_prefix$ticket_number', log_client_id = $client_id");
}
}
// E-mail techs assigned to the ticket to notify them of the reply
$ticket_assigned_to = mysqli_query($mysqli, "SELECT ticket_assigned_to FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
if ($ticket_assigned_to) {
$row = mysqli_fetch_array($ticket_assigned_to);
$ticket_assigned_to = intval($row['ticket_assigned_to']);
if ($ticket_assigned_to) {
// Get tech details
$tech_sql = mysqli_query($mysqli, "SELECT user_email, user_name FROM users WHERE user_id = $ticket_assigned_to LIMIT 1");
$tech_row = mysqli_fetch_array($tech_sql);
$tech_email = sanitizeInput($tech_row['user_email']);
$tech_name = sanitizeInput($tech_row['user_name']);
$email_subject = "$config_app_name - Ticket updated - [$config_ticket_prefix$ticket_number] $ticket_subject";
$email_body = "Hello $tech_name,<br><br>A new reply has been added to the below ticket, check ITFlow for full details.<br><br>Client: $client_name<br>Ticket: $config_ticket_prefix$ticket_number<br>Subject: $ticket_subject<br><br>https://$config_base_url/ticket.php?ticket_id=$ticket_id";
$data = [
[
'from' => $config_ticket_from_email,
'from_name' => $config_ticket_from_name,
'recipient' => $tech_email,
'recipient_name' => $tech_name,
'subject' => $email_subject,
'body' => $email_body
]
];
addToMailQueue($mysqli, $data);
}
}
// Update Ticket Last Response Field & set ticket to open as client has replied
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2 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_client_id = $client_id");
return true;
} else {
// Invalid ticket number
return false;
}
}
// END ADD REPLY FUNCTION -------------------------------------------------
// Prepare connection string with encryption (TLS/SSL/<blank>)
$imap_mailbox = "$config_imap_host:$config_imap_port/imap/$config_imap_encryption";
// Connect to host via IMAP
$imap = imap_open("{{$imap_mailbox}}INBOX", $config_imap_username, $config_imap_password);
// Check connection
if (!$imap) {
// Logging
//$extended_log_description = var_export(imap_errors(), true);
// Remove the lock file
unlink($lock_file_path);
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Mail', log_action = 'Error', log_description = 'Email parser: Failed to connect to IMAP. Details'");
exit("Could not connect to IMAP");
}
// Check for the ITFlow mailbox that we move messages to once processed
$imap_folder = 'ITFlow';
$list = imap_list($imap, "{{$imap_mailbox}}", "*");
if (array_search("{{$imap_mailbox}}$imap_folder", $list) === false) {
imap_createmailbox($imap, imap_utf7_encode("{{$imap_mailbox}}$imap_folder"));
imap_subscribe($imap, imap_utf7_encode("{{$imap_mailbox}}$imap_folder"));
}
// Search for unread ("UNSEEN") emails
$emails = imap_search($imap, 'UNSEEN');
if ($emails) {
// Sort
rsort($emails);
// Loop through each email
foreach ($emails as $email) {
// Default false
$email_processed = false;
// Save the original email (to be moved later)
mkdirMissing('uploads/tmp/'); // Create tmp dir
$original_message_file = "processed-eml-" . randomString(200) . ".eml";
imap_savebody($imap, "uploads/tmp/{$original_message_file}", $email);
// Get details from message and invoke PHP Mime Mail Parser
$msg_to_parse = imap_fetchheader($imap, $email, FT_PREFETCHTEXT) . imap_body($imap, $email, FT_PEEK);
$parser = new PhpMimeMailParser\Parser();
$parser->setText($msg_to_parse);
// Process message attributes
$from_array = $parser->getAddresses('from')[0];
$from_name = sanitizeInput($from_array['display']);
// Handle blank 'From' emails
$from_email = "itflow-guest@example.com";
if (filter_var($from_array['address'], FILTER_VALIDATE_EMAIL)) {
$from_email = sanitizeInput($from_array['address']);
}
$from_domain = explode("@", $from_array['address']);
$from_domain = sanitizeInput(end($from_domain));
$subject = sanitizeInput($parser->getHeader('subject'));
$date = sanitizeInput($parser->getHeader('date'));
$attachments = $parser->getAttachments();
// Get the message content
// (first try HTML parsing, but switch to plain text if the email is empty/plain-text only)
// $message = $parser->getMessageBody('htmlEmbedded');
// if (empty($message)) {
// echo "DEBUG: Switching to plain text parsing for this message ($subject)";
// $message = $parser->getMessageBody('text');
// }
// TODO: Default to getting HTML and fallback to plaintext, but HTML emails seem to break the forward/agent notifications
$message = $parser->getMessageBody('text');
// 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]);
if (addReply($from_email, $date, $subject, $ticket_number, $message, $attachments)) {
$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_email' LIMIT 1");
$row = mysqli_fetch_array($any_contact_sql);
if ($row) {
// Sender exists as a contact
$contact_name = sanitizeInput($row['contact_name']);
$contact_id = intval($row['contact_id']);
$contact_email = sanitizeInput($row['contact_email']);
$client_id = intval($row['contact_client_id']);
if (addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file)) {
$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 domains WHERE domain_name = '$from_domain' LIMIT 1"));
if ($row && $from_domain == $row['domain_name']) {
// We found a match - create a contact under this client and raise a ticket for them
// Client details
$client_id = intval($row['domain_client_id']);
// Contact details
$password = password_hash(randomString(), PASSWORD_DEFAULT);
$contact_name = $from_name; // This was already Sanitized above
$contact_email = $from_email; // This was already Sanitized above
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");
$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");
if (addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file)) {
$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 in the Inbox as needing attention
}
}
}
// Deal with the message (move it if processed, flag it if not)
if ($email_processed) {
imap_setflag_full($imap, $email, "\\Seen");
imap_mail_move($imap, $email, $imap_folder);
} else {
// Basically just flags all emails to be manually checked
echo "Failed to process email - flagging for manual review.";
imap_setflag_full($imap, $email, "\\Flagged");
}
// Remove temp original message if still there
if (file_exists("uploads/tmp/{$original_message_file}")) {
unlink("uploads/tmp/{$original_message_file}");
}
}
}
imap_expunge($imap);
imap_close($imap);
// Remove the lock file
unlink($lock_file_path);

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 = array_diff(mb_list_encodings(), ['BASE64', 'UUENCODE', 'HTML-ENTITIES', 'Quoted-Printable']),
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|array|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|false
*/
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 of associative arrays with the following keys `display`, `address`, `is_group`
*
* @param string $name Header name (case-insensitive)
*
* @return array<int, array{'display':string, 'address':string, 'is_group':bool}>
*/
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)' && $disposition != 'inline') {
// 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);
}
}