Revert "Add new optional beta email parser thats based on ImapEngine instead of Webklex"

This reverts commit 9cb1ff7330.
This commit is contained in:
johnnyq
2026-02-26 16:44:49 -05:00
parent ac4bb32081
commit 92fba49a91
682 changed files with 8 additions and 101834 deletions

View File

@@ -1,994 +0,0 @@
<?php
/*
* CRON - Email Parser (ImapEngine v1.22.4)
* Process emails and create/update tickets using DirectoryTree/ImapEngine (no PHP IMAP extension).
*
* This is a full rewrite of your Webklex version to match behavior:
* - Supports Standard IMAP, Google OAuth IMAP, Microsoft OAuth IMAP
* - Pulls UNSEEN messages from INBOX
* - Saves original email to .eml in uploads/tmp and attaches it to new tickets
* - Parses From/Subject/CC/Date
* - Prefers HTML body, falls back to text
* - Inlines CID images into HTML (data: URIs) using body structure
* - Stores non-inline attachments (extension allowlist)
* - Handles replies by [PREFIX123] in subject, fuzzy duplicate subject match, known contact/domain, unknown sender option
* - Moves processed mail to "ITFlow" folder, flags unprocessed
*/
// Start the timer
$script_start_time = microtime(true);
// Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__));
// Ensure we're running from command line
if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Autoload (ImapEngine & any composer deps)
require_once "../plugins/vendor/autoload.php";
// Get ITFlow config & helper functions
require_once "../config.php";
// Set Timezone
require_once "../includes/inc_set_timezone.php";
require_once "../functions.php";
// Get settings for the "default" company
require_once "../includes/load_global_settings.php";
$config_ticket_prefix = sanitizeInput($config_ticket_prefix);
$config_ticket_from_name = sanitizeInput($config_ticket_from_name);
$config_ticket_email_parse_unknown_senders = intval($row['config_ticket_email_parse_unknown_senders']);
// 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_assoc($sql);
$company_name = sanitizeInput($row['company_name']);
$company_phone = sanitizeInput(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
// Check setting enabled
if ($config_ticket_email_parse == 0) {
logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - not enabled in admin settings.");
exit("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
}
// System temp directory & lock
$temp_dir = sys_get_temp_dir();
$lock_file_path = "{$temp_dir}/itflow_email_parser_{$installation_id}.lock";
if (file_exists($lock_file_path)) {
$file_age = time() - filemtime($lock_file_path);
if ($file_age > 300) {
unlink($lock_file_path);
logApp("Cron-Email-Parser", "warning", "Cron Email Parser detected a lock file was present but was over 5 minutes old so it removed it.");
} else {
logApp("Cron-Email-Parser", "warning", "Lock file present. Cron Email Parser attempted to execute but was already executing, so instead it terminated.");
exit("Script is already running. Exiting.");
}
}
file_put_contents($lock_file_path, "Locked");
// Ensure lock gets removed even on fatal error
register_shutdown_function(function() use ($lock_file_path) {
if (file_exists($lock_file_path)) {
@unlink($lock_file_path);
}
});
// Allowed attachment extensions
$allowed_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp', 'svg', 'pdf', 'txt', 'md', 'doc', 'docx', 'csv', 'xls', 'xlsx', 'xlsm', 'zip', 'tar', 'gz');
/** ------------------------------------------------------------------
* Ticket / Reply helpers (UNCHANGED from your Webklex version)
* ------------------------------------------------------------------ */
function addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message, $attachments, $original_message_file, $ccs) {
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_ticket_default_billable, $allowed_extensions;
$bad_pattern = "/do[\W_]*not[\W_]*reply|no[\W_]*reply/i"; // Email addresses to ignore
// Atomically increment and get the new ticket number
mysqli_query($mysqli, "
UPDATE settings
SET
config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number),
config_ticket_next_number = config_ticket_next_number + 1
WHERE company_id = 1
");
$ticket_number = mysqli_insert_id($mysqli);
// Clean up the message
$message = trim($message);
// Remove DOCTYPE and meta tags
$message = preg_replace('/<!DOCTYPE[^>]*>/i', '', $message);
$message = preg_replace('/<meta[^>]*>/i', '', $message);
// Remove <html>, <head>, <body> and their closing tags
$message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
// Collapse excess whitespace
$message = preg_replace('/\s+/', ' ', $message);
// Convert newlines to <br>
$message = nl2br($message);
// Wrap final formatted message
$message = "<i>Email from: <b>$contact_name</b> &lt;$contact_email&gt; at $date:-</i> <br><br><div style='line-height:1.5;'>$message</div>";
$ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
$message_esc = mysqli_real_escape_string($mysqli, $message);
$contact_email_esc = mysqli_real_escape_string($mysqli, $contact_email);
$client_id = intval($client_id);
$url_key = randomString(32);
mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$ticket_prefix_esc', ticket_number = $ticket_number, ticket_source = 'Email', ticket_subject = '$subject', ticket_details = '$message_esc', ticket_priority = 'Low', ticket_status = 1, ticket_billable = $config_ticket_default_billable, ticket_created_by = 0, ticket_contact_id = $contact_id, ticket_url_key = '$url_key', ticket_client_id = $client_id");
$id = mysqli_insert_id($mysqli);
// Logging
logAction("Ticket", "Create", "Email parser: Client contact $contact_email_esc created ticket $ticket_prefix_esc$ticket_number ($subject) ($id)", $client_id, $id);
mkdirMissing('../uploads/tickets/');
$att_dir = "../uploads/tickets/" . $id . "/";
mkdirMissing($att_dir);
// Move original .eml into the ticket folder
rename("../uploads/tmp/{$original_message_file}", "{$att_dir}/{$original_message_file}");
$original_message_file_esc = mysqli_real_escape_string($mysqli, $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_esc', ticket_attachment_ticket_id = $id");
// Save non-inline attachments
foreach ($attachments as $attachment) {
$att_name = $attachment['name'];
$att_extension = strtolower(pathinfo($att_name, PATHINFO_EXTENSION));
if (in_array($att_extension, $allowed_extensions)) {
$att_saved_filename = md5(uniqid(rand(), true)) . '.' . $att_extension;
$att_saved_path = $att_dir . $att_saved_filename;
file_put_contents($att_saved_path, $attachment['content']);
$ticket_attachment_name = sanitizeInput($att_name);
$ticket_attachment_reference_name = sanitizeInput($att_saved_filename);
$ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_name);
$ticket_attachment_reference_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_reference_name);
mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name_esc', ticket_attachment_reference_name = '$ticket_attachment_reference_name_esc', ticket_attachment_ticket_id = $id");
} else {
$ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $att_name);
logAction("Ticket", "Edit", "Email parser: Blocked attachment $ticket_attachment_name_esc from Client contact $contact_email_esc for ticket $ticket_prefix_esc$ticket_number", $client_id, $id);
}
}
// Add unknown guests as ticket watcher
if ($client_id == 0 && !preg_match($bad_pattern, $contact_email_esc)) {
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$contact_email_esc', watcher_ticket_id = $id");
}
// Add CCs as ticket watchers
foreach ($ccs as $cc) {
if (filter_var($cc, FILTER_VALIDATE_EMAIL) && !preg_match($bad_pattern, $cc)) {
$cc_esc = mysqli_real_escape_string($mysqli, $cc);
mysqli_query($mysqli, "INSERT INTO ticket_watchers SET watcher_email = '$cc_esc', watcher_ticket_id = $id");
}
}
// External email
$data = [];
if ($config_ticket_client_general_notifications == 1 && !preg_match($bad_pattern, $contact_email)) {
$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>Portal: <a href='https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$id&url_key=$url_key'>View ticket</a><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' => mysqli_real_escape_string($mysqli, $body)
];
}
// Internal email
if ($config_ticket_new_ticket_notification_email) {
if ($client_id == 0) {
$client_name = "Guest";
$client_uri = '';
} else {
$client_sql = mysqli_query($mysqli, "SELECT client_name FROM clients WHERE client_id = $client_id");
$client_row = mysqli_fetch_assoc($client_sql);
$client_name = sanitizeInput($client_row['client_name']);
$client_uri = "&client_id=$client_id";
}
$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/agent/ticket.php?ticket_id=$id$client_uri <br><br>--------------------------------<br><br><b>$subject</b><br>$message";
$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' => mysqli_real_escape_string($mysqli, $email_body)
];
}
addToMailQueue($data);
customAction('ticket_create', $id);
return true;
}
function addReply($from_email, $date, $subject, $ticket_number, $message, $attachments) {
global $mysqli, $config_app_name, $company_name, $company_phone, $config_ticket_prefix, $config_base_url, $config_ticket_from_name, $config_ticket_from_email, $allowed_extensions;
$ticket_reply_type = 'Client';
// 1) Remove the reply separator and everything below it (HTML-aware)
$message = preg_replace(
'/<i[^>]*>##-\s*Please\s+type\s+your\s+reply\s+above\s+this\s+line\s*-##<\/i>.*$/is',
'',
$message
);
// 2) Clean up the remaining message
$message = preg_replace('/<!DOCTYPE[^>]*>/i', '', $message);
$message = preg_replace('/<meta[^>]*>/i', '', $message);
$message = preg_replace('/<\/?(html|head|body)[^>]*>/i', '', $message);
$message = trim($message);
$message = preg_replace('/\r\n|\r|\n/', ' ', $message);
$message = nl2br($message);
// 3) Final wrapper
$message = "<i>Email from: $from_email at $date:-</i><br><br><div style='line-height:1.5;'>$message</div>";
$ticket_number_esc = intval($ticket_number);
$message_esc = mysqli_real_escape_string($mysqli, $message);
$from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
$row = mysqli_fetch_assoc(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_esc LIMIT 1"));
if ($row) {
$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_uri = $client_id ? "&client_id=$client_id" : '';
$client_name = sanitizeInput($row['client_name']);
if ($ticket_status == 5) {
$config_ticket_prefix_esc = mysqli_real_escape_string($mysqli, $config_ticket_prefix);
$ticket_number_esc2 = mysqli_real_escape_string($mysqli, $ticket_number);
appNotify("Ticket", "Email parser: $from_email attempted to re-open ticket $config_ticket_prefix_esc$ticket_number_esc2 (ID $ticket_id) - check inbox manually to see email", "/agent/ticket.php?ticket_id=$ticket_id$client_uri", $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 new 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' => mysqli_real_escape_string($mysqli, $email_body)
]
];
addToMailQueue($data);
return true;
}
if (empty($ticket_contact_email) || $ticket_contact_email !== $from_email) {
$from_email_esc2 = mysqli_real_escape_string($mysqli, $from_email);
$row2 = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_email = '$from_email_esc2' AND contact_client_id = $client_id LIMIT 1"));
if ($row2) {
$ticket_reply_contact = intval($row2['contact_id']);
} else {
$ticket_reply_type = 'Internal';
$ticket_reply_contact = '0';
$message = "<b>WARNING: Contact email mismatch</b><br>$message";
$message_esc = mysqli_real_escape_string($mysqli, $message);
}
}
mysqli_query($mysqli, "INSERT INTO ticket_replies SET ticket_reply = '$message_esc', 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);
$ticket_dir = "../uploads/tickets/" . $ticket_id . "/";
mkdirMissing($ticket_dir);
foreach ($attachments as $attachment) {
$att_name = $attachment['name'];
$att_extension = strtolower(pathinfo($att_name, PATHINFO_EXTENSION));
if (in_array($att_extension, $allowed_extensions)) {
$att_saved_filename = md5(uniqid(rand(), true)) . '.' . $att_extension;
$att_saved_path = $ticket_dir . $att_saved_filename;
file_put_contents($att_saved_path, $attachment['content']);
$ticket_attachment_name = sanitizeInput($att_name);
$ticket_attachment_reference_name = sanitizeInput($att_saved_filename);
$ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_name);
$ticket_attachment_reference_name_esc = mysqli_real_escape_string($mysqli, $ticket_attachment_reference_name);
mysqli_query($mysqli, "INSERT INTO ticket_attachments SET ticket_attachment_name = '$ticket_attachment_name_esc', ticket_attachment_reference_name = '$ticket_attachment_reference_name_esc', ticket_attachment_reply_id = $reply_id, ticket_attachment_ticket_id = $ticket_id");
} else {
$ticket_attachment_name_esc = mysqli_real_escape_string($mysqli, $att_name);
logAction("Ticket", "Edit", "Email parser: Blocked attachment $ticket_attachment_name_esc from Client contact $from_email_esc for ticket $config_ticket_prefix$ticket_number_esc", $client_id, $ticket_id);
}
}
$ticket_assigned_to_sql = mysqli_query($mysqli, "SELECT ticket_assigned_to FROM tickets WHERE ticket_id = $ticket_id LIMIT 1");
if ($ticket_assigned_to_sql) {
$row3 = mysqli_fetch_assoc($ticket_assigned_to_sql);
$ticket_assigned_to = intval($row3['ticket_assigned_to']);
if ($ticket_assigned_to) {
$tech_sql = mysqli_query($mysqli, "SELECT user_email, user_name FROM users WHERE user_id = $ticket_assigned_to LIMIT 1");
$tech_row = mysqli_fetch_assoc($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.<br><br>Client: $client_name<br>Ticket: $config_ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Link: https://$config_base_url/agent/ticket.php?ticket_id=$ticket_id$client_uri<br><br>--------------------------------<br>$message_esc";
$data = [
[
'from' => $config_ticket_from_email,
'from_name' => $config_ticket_from_name,
'recipient' => $tech_email,
'recipient_name' => $tech_name,
'subject' => mysqli_real_escape_string($mysqli, $email_subject),
'body' => mysqli_real_escape_string($mysqli, $email_body)
]
];
addToMailQueue($data);
}
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_status = 2, ticket_resolved_at = NULL WHERE ticket_id = $ticket_id AND ticket_client_id = $client_id LIMIT 1");
logAction("Ticket", "Edit", "Email parser: Client contact $from_email_esc updated ticket $config_ticket_prefix$ticket_number_esc ($subject)", $client_id, $ticket_id);
customAction('ticket_reply_client', $ticket_id);
return true;
} else {
return false;
}
}
/** ------------------------------------------------------------------
* OAuth helpers + provider guard (UNCHANGED)
* ------------------------------------------------------------------ */
// returns true if expires_at ('Y-m-d H:i:s') is in the past (or missing)
function tokenExpired(?string $expires_at): bool {
if (empty($expires_at)) return true;
$ts = strtotime($expires_at);
if ($ts === false) return true;
// refresh a little early (60s) to avoid race
return ($ts - 60) <= time();
}
// very small form-encoded POST helper using curl
function httpFormPost(string $url, array $fields): array {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$raw = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['ok' => ($raw !== false && $code >= 200 && $code < 300), 'body' => $raw, 'code' => $code, 'err' => $err];
}
function getGoogleAccessToken(string $username): ?string {
global $mysqli,
$config_mail_oauth_client_id,
$config_mail_oauth_client_secret,
$config_mail_oauth_refresh_token,
$config_mail_oauth_access_token,
$config_mail_oauth_access_token_expires_at;
if (!empty($config_mail_oauth_access_token) && !tokenExpired($config_mail_oauth_access_token_expires_at)) {
return $config_mail_oauth_access_token;
}
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token)) {
return null;
}
$resp = httpFormPost(
'https://oauth2.googleapis.com/token',
[
'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret,
'refresh_token' => $config_mail_oauth_refresh_token,
'grant_type' => 'refresh_token',
]
);
if (!$resp['ok']) return null;
$json = json_decode($resp['body'], true);
if (!is_array($json) || empty($json['access_token'])) return null;
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$config_mail_oauth_access_token = $json['access_token'];
$config_mail_oauth_access_token_expires_at = $expires_at;
$at_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token);
$exp_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token_expires_at);
mysqli_query($mysqli, "UPDATE settings SET
config_mail_oauth_access_token = '{$at_esc}',
config_mail_oauth_access_token_expires_at = '{$exp_esc}'
WHERE company_id = 1
");
return $config_mail_oauth_access_token;
}
function getMicrosoftAccessToken(string $username): ?string {
global $mysqli,
$config_mail_oauth_client_id,
$config_mail_oauth_client_secret,
$config_mail_oauth_tenant_id,
$config_mail_oauth_refresh_token,
$config_mail_oauth_access_token,
$config_mail_oauth_access_token_expires_at;
if (!empty($config_mail_oauth_access_token) && !tokenExpired($config_mail_oauth_access_token_expires_at)) {
return $config_mail_oauth_access_token;
}
if (empty($config_mail_oauth_client_id) || empty($config_mail_oauth_client_secret) || empty($config_mail_oauth_refresh_token) || empty($config_mail_oauth_tenant_id)) {
return null;
}
$url = "https://login.microsoftonline.com/".rawurlencode($config_mail_oauth_tenant_id)."/oauth2/v2.0/token";
$resp = httpFormPost($url, [
'client_id' => $config_mail_oauth_client_id,
'client_secret' => $config_mail_oauth_client_secret,
'refresh_token' => $config_mail_oauth_refresh_token,
'grant_type' => 'refresh_token',
]);
if (!$resp['ok']) return null;
$json = json_decode($resp['body'], true);
if (!is_array($json) || empty($json['access_token'])) return null;
$expires_at = date('Y-m-d H:i:s', time() + (int)($json['expires_in'] ?? 3600));
$config_mail_oauth_access_token = $json['access_token'];
$config_mail_oauth_access_token_expires_at = $expires_at;
$at_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token);
$exp_esc = mysqli_real_escape_string($mysqli, $config_mail_oauth_access_token_expires_at);
mysqli_query($mysqli, "UPDATE settings SET
config_mail_oauth_access_token = '{$at_esc}',
config_mail_oauth_access_token_expires_at = '{$exp_esc}'
WHERE company_id = 1
");
return $config_mail_oauth_access_token;
}
// Provider from settings (may be NULL/empty to disable IMAP polling)
$imap_provider = $config_imap_provider ?? '';
if ($imap_provider === null) $imap_provider = '';
if ($imap_provider === '') {
logApp("Cron-Email-Parser", "info", "IMAP polling skipped: provider not configured.");
@unlink($lock_file_path);
exit(0);
}
/** ------------------------------------------------------------------
* ImapEngine setup (Standard / Google OAuth / Microsoft OAuth)
* ------------------------------------------------------------------ */
use DirectoryTree\ImapEngine\Mailbox;
// Defaults from settings (standard IMAP)
$validate_cert = true;
$host = $config_imap_host;
$port = (int)$config_imap_port;
$encr = !empty($config_imap_encryption) ? $config_imap_encryption : null; // 'ssl'|'starttls'|null
$user = $config_imap_username;
$pass = $config_imap_password;
$auth = 'plain'; // 'oauth' for OAuth providers
if ($imap_provider === 'google_oauth') {
$host = 'imap.gmail.com';
$port = 993;
$encr = 'ssl';
$auth = 'oauth';
$pass = getGoogleAccessToken($user);
if (empty($pass)) {
logApp("Cron-Email-Parser", "error", "Google OAuth: no usable access token (check refresh token/client credentials).");
@unlink($lock_file_path);
exit(1);
}
} elseif ($imap_provider === 'microsoft_oauth') {
$host = 'outlook.office365.com';
$port = 993;
$encr = 'ssl';
$auth = 'oauth';
$pass = getMicrosoftAccessToken($user);
if (empty($pass)) {
logApp("Cron-Email-Parser", "error", "Microsoft OAuth: no usable access token (check refresh token/client credentials/tenant).");
@unlink($lock_file_path);
exit(1);
}
} else {
// standard_imap (username/password)
if (empty($host) || empty($port) || empty($user)) {
logApp("Cron-Email-Parser", "error", "Standard IMAP: missing host/port/username.");
@unlink($lock_file_path);
exit(1);
}
// Map "tls" / "notls" from your old settings into ImapEngine's expected values
// ImapEngine uses: 'ssl', 'starttls', or null.
$e = strtolower((string)$encr);
if ($e === 'tls') $encr = 'starttls';
if ($e === 'notls' || $e === '') $encr = null;
}
$mailbox = new Mailbox([
'host' => $host,
'port' => $port,
'username' => $user,
'password' => $pass, // OAuth token when authentication = oauth
'encryption' => $encr, // 'ssl'|'starttls'|null
'validate_cert' => (bool)$validate_cert,
'authentication'=> $auth, // 'plain'|'oauth'
'timeout' => 30,
'debug' => false,
]);
try {
$mailbox->connect();
} catch (\Throwable $e) {
echo "Error connecting to IMAP server: " . $e->getMessage() . "\n";
@unlink($lock_file_path);
exit(1);
}
// INBOX + ensure target folder exists
$inbox = $mailbox->inbox();
$targetFolderPath = 'ITFlow';
$targetFolder = $mailbox->folders()->find($targetFolderPath);
if (!$targetFolder) {
try {
$targetFolder = $mailbox->folders()->create($targetFolderPath);
} catch (\Throwable $e) {
// Race: if another process created it, find again
$targetFolder = $mailbox->folders()->find($targetFolderPath);
if (!$targetFolder) {
logApp("Cron-Email-Parser", "error", "Failed to create/find target folder [$targetFolderPath]: ".$e->getMessage());
$mailbox->disconnect();
@unlink($lock_file_path);
exit(1);
}
}
}
// Fetch unseen messages (match Webklex behavior: iterate messages, then explicitly mark/move)
$messages = $inbox->messages()
->unseen()
->withHeaders()
->withFlags()
->withBody()
->withBodyStructure()
->get();
// Counters
$processed_count = 0;
$unprocessed_count = 0;
// Process messages
foreach ($messages as $message) {
$email_processed = false;
$original_message_file = null;
// Save original message as .eml
try {
mkdirMissing('../uploads/tmp/');
$original_message_file = "processed-eml-" . randomString(200) . ".eml";
$rawHead = $message->head() ?? '';
$rawBody = $message->body() ?? '';
// Ensure header/body separator
$raw_message = (string)$rawHead;
if ($raw_message !== '' && !preg_match("/\r?\n\r?\n$/", $raw_message)) {
$raw_message .= "\r\n\r\n";
}
$raw_message .= (string)$rawBody;
// Fallback if body() wasn't fetched / empty
if (trim($raw_message) === '') {
$raw_message = "Subject: ".($message->subject() ?? '')."\r\n\r\n".($message->html() ?: $message->text() ?: '');
}
file_put_contents("../uploads/tmp/{$original_message_file}", $raw_message);
} catch (\Throwable $e) {
logApp("Cron-Email-Parser", "warning", "Failed saving .eml (uid=".$message->uid()."): ".$e->getMessage());
// Keep processing anyway
$original_message_file = "processed-eml-" . randomString(200) . ".eml";
@file_put_contents("../uploads/tmp/{$original_message_file}", "");
}
// From
$fromAddr = $message->from(); // Address|null
$from_email = sanitizeInput($fromAddr ? ($fromAddr->email() ?? 'itflow-guest@example.com') : 'itflow-guest@example.com');
$from_name = sanitizeInput($fromAddr ? ($fromAddr->name() ?? 'Unknown') : 'Unknown');
$from_domain = explode("@", $from_email);
$from_domain = sanitizeInput(end($from_domain));
// Subject
$subject = sanitizeInput((string)($message->subject() ?: 'No Subject'));
// CC list
$ccs = [];
try {
foreach (($message->cc() ?? []) as $ccAddr) {
if ($ccAddr && $ccAddr->email()) {
$ccs[] = $ccAddr->email();
}
}
} catch (\Throwable $e) {
// ignore
}
// Date
try {
$dt = $message->date();
$date = sanitizeInput($dt ? $dt->format('Y-m-d H:i:s') : date('Y-m-d H:i:s'));
} catch (\Throwable $e) {
$date = sanitizeInput(date('Y-m-d H:i:s'));
}
// Body (prefer HTML)
$message_body_html = '';
$message_body_text = '';
try { $message_body_html = (string)($message->html() ?? ''); } catch (\Throwable $e) {}
try { $message_body_text = (string)($message->text() ?? ''); } catch (\Throwable $e) {}
if (!empty($message_body_html)) {
$message_body = $message_body_html;
} elseif (!empty($message_body_text)) {
$message_body = nl2br(htmlspecialchars($message_body_text));
} else {
// Final fallback to raw body (may be MIME-y, but keeps parity with your old fallback)
$message_body = nl2br(htmlspecialchars((string)($message->body() ?? '')));
}
// Inline CID handling + attachments collection
$attachments = [];
// Track inline CIDs we embedded so we don't save them as attachments too
$inline_cids = [];
// Use body structure to find inline attachment parts and replace cid: refs
try {
if ($message->hasBodyStructure()) {
$structure = $message->bodyStructure();
$parts = $structure ? $structure->flatten() : [];
foreach ($parts as $part) {
// We want inline parts with a Content-ID
if (!$part) continue;
$isInline = false;
try { $isInline = (bool)$part->isInline(); } catch (\Throwable $e) { $isInline = false; }
$cid = null;
try { $cid = $part->id(); } catch (\Throwable $e) { $cid = null; }
if (!$isInline || empty($cid)) continue;
// Fetch this inline part and embed into HTML
$cid_trim = trim((string)$cid, "<> \t\r\n");
if ($cid_trim === '') continue;
$mime = 'application/octet-stream';
try { $mime = (string)($part->contentType() ?: $mime); } catch (\Throwable $e) {}
$content = null;
try {
$content = $message->bodyPart($part->partNumber()); // default peek (does not mark seen)
} catch (\Throwable $e) {
$content = null;
}
if ($content === null || $content === '') continue;
$dataUri = "data:$mime;base64," . base64_encode($content);
$message_body = str_replace(["cid:$cid_trim", "cid:<$cid_trim>", "cid:$cid"], $dataUri, $message_body);
$inline_cids[$cid_trim] = true;
}
}
} catch (\Throwable $e) {
// ignore inline replacement errors
}
// Collect non-inline attachments from the high-level Attachment API
try {
foreach ($message->attachments() as $att) {
if (!$att) continue;
$name = $att->filename() ?: 'attachment';
$content = $att->contents();
$cid = null;
try { $cid = $att->contentId(); } catch (\Throwable $e) { $cid = null; }
$cid_trim = $cid ? trim((string)$cid, "<> \t\r\n") : null;
// Skip inline ones we already embedded
if ($cid_trim && isset($inline_cids[$cid_trim])) {
continue;
}
if ($content !== null && $content !== '') {
$attachments[] = [
'name' => $name,
'content' => $content,
];
}
}
} catch (\Throwable $e) {
// ignore
}
// 1. Reply to existing ticket with the number in subject
if (preg_match("/\[$config_ticket_prefix(\d+)\]/", $subject, $ticket_number_matches)) {
$ticket_number = intval($ticket_number_matches[1]);
$email_processed = addReply($from_email, $date, $subject, $ticket_number, $message_body, $attachments);
}
// 2. Fuzzy duplicate check using a known contact/domain and similar_text subject
if (!$email_processed && strlen(trim($subject)) > 10) {
$contact_id = 0;
$client_id = 0;
$from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
$contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email_esc' AND contact_archived_at IS NULL LIMIT 1");
$contact_row = mysqli_fetch_assoc($contact_sql);
if ($contact_row) {
$contact_id = intval($contact_row['contact_id']);
$client_id = intval($contact_row['contact_client_id']);
} else {
$from_domain_esc = mysqli_real_escape_string($mysqli, $from_domain);
$domain_sql = mysqli_query($mysqli, "SELECT * FROM domains WHERE domain_name = '$from_domain_esc' AND domain_archived_at IS NULL LIMIT 1");
$domain_row = mysqli_fetch_assoc($domain_sql);
if ($domain_row && $from_domain == $domain_row['domain_name']) {
$client_id = intval($domain_row['domain_client_id']);
}
}
if ($client_id) {
$recent_tickets_sql = mysqli_query($mysqli,
"SELECT ticket_id, ticket_number, ticket_subject
FROM tickets
WHERE ticket_client_id = $client_id AND ticket_resolved_at IS NULL
AND ticket_created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"
);
while ($rowt = mysqli_fetch_assoc($recent_tickets_sql)) {
$ticket_number = intval($rowt['ticket_number']);
$existing_subject = $rowt['ticket_subject'];
similar_text(strtolower($subject), strtolower($existing_subject), $percent);
if ($percent >= 95) {
$email_processed = addReply($from_email, $date, $subject, $ticket_number, $message_body, $attachments);
break;
}
}
}
}
// 3. A known, registered contact?
if (!$email_processed) {
$from_email_esc = mysqli_real_escape_string($mysqli, $from_email);
$any_contact_sql = mysqli_query($mysqli, "SELECT * FROM contacts WHERE contact_email = '$from_email_esc' AND contact_archived_at IS NULL LIMIT 1");
$rowc = mysqli_fetch_assoc($any_contact_sql);
if ($rowc) {
$contact_name = sanitizeInput($rowc['contact_name']);
$contact_id = intval($rowc['contact_id']);
$contact_email = sanitizeInput($rowc['contact_email']);
$client_id = intval($rowc['contact_client_id']);
$email_processed = addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message_body, $attachments, $original_message_file, $ccs);
}
}
// 4. A known domain?
if (!$email_processed) {
$from_domain_esc = mysqli_real_escape_string($mysqli, $from_domain);
$domain_sql = mysqli_query($mysqli, "SELECT * FROM domains WHERE domain_name = '$from_domain_esc' AND domain_archived_at IS NULL LIMIT 1");
$rowd = mysqli_fetch_assoc($domain_sql);
if ($rowd && $from_domain == $rowd['domain_name']) {
$client_id = intval($rowd['domain_client_id']);
// Create a new contact
$contact_name = $from_name;
$contact_email = $from_email;
mysqli_query($mysqli, "INSERT INTO contacts SET contact_name = '".mysqli_real_escape_string($mysqli, $contact_name)."', contact_email = '".mysqli_real_escape_string($mysqli, $contact_email)."', contact_notes = 'Added automatically via email parsing.', contact_client_id = $client_id");
$contact_id = mysqli_insert_id($mysqli);
logAction("Contact", "Create", "Email parser: created contact " . mysqli_real_escape_string($mysqli, $contact_name), $client_id, $contact_id);
customAction('contact_create', $contact_id);
$email_processed = addTicket($contact_id, $contact_name, $contact_email, $client_id, $date, $subject, $message_body, $attachments, $original_message_file, $ccs);
}
}
// 5. Unknown sender allowed?
if (!$email_processed && $config_ticket_email_parse_unknown_senders) {
$bad_from_pattern = "/daemon|postmaster|bounce|mta/i"; // Stop NDRs with bad subjects raising new tickets
if (!preg_match($bad_from_pattern, $from_email)) {
$email_processed = addTicket(0, $from_name, $from_email, 0, $date, $subject, $message_body, $attachments, $original_message_file, $ccs);
} else {
// Probably an NDR message without a ticket ref in the subject
$failed_recipient = null;
$diagnostic_code = null;
$status_code = null;
$original_subject = null;
$original_to = null;
// ImapEngine: check attachments for DSN blocks (similar to your Webklex approach)
try {
foreach ($message->attachments() as $attachment) {
$ctype = strtolower((string)$attachment->contentType());
$body = (string)$attachment->contents();
// 1. Delivery status block
if (strpos($ctype, 'delivery-status') !== false) {
if (preg_match('/Final-Recipient:\s*rfc822;\s*(.+)/i', $body, $m)) {
$failed_recipient = sanitizeInput(trim($m[1]));
}
if (preg_match('/Diagnostic-Code:\s*(.+)/i', $body, $m)) {
$diagnostic_code = sanitizeInput(trim($m[1]));
}
if (preg_match('/Status:\s*([0-9\.]+)/i', $body, $m)) {
$status_code = sanitizeInput(trim($m[1]));
}
}
// 2. Original message headers
if (strpos($ctype, 'message/rfc822') !== false) {
if (preg_match('/^To:\s*(.+)$/mi', $body, $m)) {
$original_to = sanitizeInput(trim($m[1]));
}
if (preg_match('/^Subject:\s*(.+)$/mi', $body, $m)) {
$original_subject = sanitizeInput(trim($m[1]));
}
}
}
} catch (\Throwable $e) {
// ignore
}
// 3. Fallback: extract diagnostic from human-readable text
if (!$diagnostic_code) {
$text = $message_body_text ?: strip_tags($message_body);
if (preg_match('/\n\s{2,}(.+)/', $text, $m)) {
$diagnostic_code = sanitizeInput(trim($m[1]));
}
}
$failed_recipient = $failed_recipient ?: 'unknown recipient';
$diagnostic_code = $diagnostic_code ?: 'unknown diagnostic code';
$status_code = $status_code ?: 'unknown status code';
$original_subject = $original_subject ?: $subject;
appNotify(
"Ticket",
"Email parser NDR: Message to $failed_recipient bounced. Subject: $original_subject Diagnostics: $status_code / $diagnostic_code - check ITFlow folder manually to see email",
"",
0
);
// If the original subject has a ticket, add the NDR there too
if (preg_match("/\[$config_ticket_prefix(\d+)\]/", $original_subject, $ticket_number_matches)) {
$ticket_number = intval($ticket_number_matches[1]);
$reply_body = "Email delivery failed.\n".
"Recipient: $failed_recipient\n".
"Status: $status_code\n".
"Diagnostic: $diagnostic_code\n";
addReply(
$from_email,
$date,
$original_subject,
$ticket_number,
$reply_body,
[]
);
}
$email_processed = true;
}
}
// Flag/move based on processing result
if ($email_processed) {
$processed_count++;
try {
$message->markSeen();
$message->move($targetFolderPath); // expunge later
} catch (\Throwable $e) {
$subj = (string)($message->subject() ?? '');
$uid = (int)$message->uid();
$path = $targetFolder ? $targetFolder->path() : $targetFolderPath;
logApp(
"Cron-Email-Parser",
"warning",
"Move failed (subject=\"$subj\", uid=$uid) to [$path]: ".$e->getMessage()
);
}
} else {
$unprocessed_count++;
try {
$message->markFlagged();
$message->unmarkSeen();
} catch (\Throwable $e) {
logApp("Cron-Email-Parser", "warning", "Flag update failed: ".$e->getMessage());
}
}
// Cleanup temp .eml if still present (e.g., reply path or addTicket moved it)
if (!empty($original_message_file)) {
$tmp_path = "../uploads/tmp/{$original_message_file}";
if (file_exists($tmp_path)) { @unlink($tmp_path); }
}
}
// Expunge inbox after moves (removes any \Deleted left by server-side MOVE implementation)
try {
$inbox->expunge();
} catch (\Throwable $e) {
// ignore
}
// Disconnect
try {
$mailbox->disconnect();
} catch (\Throwable $e) {
// ignore
}
// Execution timing (optional)
$script_end_time = microtime(true);
$execution_time = $script_end_time - $script_start_time;
$execution_time_formatted = number_format($execution_time, 2);
$processed_info = "Processed: $processed_count email(s), Unprocessed: $unprocessed_count email(s)";
// logAction("Cron-Email-Parser", "Execution", "Cron Email Parser executed in $execution_time_formatted seconds. $processed_info");
// Remove the lock file
unlink($lock_file_path);
// DEBUG
echo "\nLock File Path: $lock_file_path\n";
if (file_exists($lock_file_path)) {
echo "\nLock is present\n\n";
}
echo "Processed Emails: $processed_count\n";
echo "Unprocessed Emails: $unprocessed_count\n";

View File

@@ -1,6 +1,5 @@
{
"require": {
"webklex/php-imap": "^6.2",
"directorytree/imapengine": "^1.22"
"webklex/php-imap": "^6.2"
}
}

1265
plugins/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@ return array(
'DateRangeError' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateRangeError.php',
'Deprecated' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Deprecated.php',
'NoDiscard' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
'Normalizer' => $vendorDir . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php',
'Override' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/Override.php',
'ReflectionConstant' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
'SQLite3Exception' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',

View File

@@ -11,15 +11,10 @@ return array(
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'606a39d89246991a373564698c2d8383' => $vendorDir . '/symfony/polyfill-php85/bootstrap.php',
'2203a247e6fda86070a5e4e07aed533a' => $vendorDir . '/symfony/clock/Resources/now.php',
'a1105708a18b76903365ca1c4aa61b02' => $vendorDir . '/symfony/translation/Resources/functions.php',
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'a1105708a18b76903365ca1c4aa61b02' => $vendorDir . '/symfony/translation/Resources/functions.php',
'23f09fe3194f8c2f70923f90d6702129' => $vendorDir . '/illuminate/collections/functions.php',
'60799491728b879e74601d83e38b2cad' => $vendorDir . '/illuminate/collections/helpers.php',
'f598d06aa772fa33d905e87be6398fb1' => $vendorDir . '/symfony/polyfill-intl-idn/bootstrap.php',
'f625ee536139dfb962a398b200bdb2bd' => $vendorDir . '/illuminate/support/functions.php',
'72579e7bd17821bb1321b87411366eae' => $vendorDir . '/illuminate/support/helpers.php',
'7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
'def43f6c87e4f8dfd0c9e1b1bab14fe8' => $vendorDir . '/symfony/polyfill-iconv/bootstrap.php',
'b33e3d135e5d9e47d845c576147bda89' => $vendorDir . '/php-di/php-di/src/functions.php',
);

View File

@@ -7,38 +7,22 @@ $baseDir = dirname($vendorDir);
return array(
'voku\\' => array($vendorDir . '/voku/portable-ascii/src/voku'),
'ZBateson\\StreamDecorators\\' => array($vendorDir . '/zbateson/stream-decorators/src'),
'ZBateson\\MbWrapper\\' => array($vendorDir . '/zbateson/mb-wrapper/src'),
'ZBateson\\MailMimeParser\\' => array($vendorDir . '/zbateson/mail-mime-parser/src'),
'Webklex\\PHPIMAP\\' => array($vendorDir . '/webklex/php-imap/src'),
'Symfony\\Polyfill\\Php85\\' => array($vendorDir . '/symfony/polyfill-php85'),
'Symfony\\Polyfill\\Php84\\' => array($vendorDir . '/symfony/polyfill-php84'),
'Symfony\\Polyfill\\Php83\\' => array($vendorDir . '/symfony/polyfill-php83'),
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
'Symfony\\Polyfill\\Intl\\Normalizer\\' => array($vendorDir . '/symfony/polyfill-intl-normalizer'),
'Symfony\\Polyfill\\Intl\\Idn\\' => array($vendorDir . '/symfony/polyfill-intl-idn'),
'Symfony\\Polyfill\\Iconv\\' => array($vendorDir . '/symfony/polyfill-iconv'),
'Symfony\\Contracts\\Translation\\' => array($vendorDir . '/symfony/translation-contracts'),
'Symfony\\Component\\Translation\\' => array($vendorDir . '/symfony/translation'),
'Symfony\\Component\\Mime\\' => array($vendorDir . '/symfony/mime'),
'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'),
'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'),
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
'Psr\\Clock\\' => array($vendorDir . '/psr/clock/src'),
'Laravel\\SerializableClosure\\' => array($vendorDir . '/laravel/serializable-closure/src'),
'Invoker\\' => array($vendorDir . '/php-di/invoker/src'),
'Illuminate\\Support\\' => array($vendorDir . '/illuminate/collections', $vendorDir . '/illuminate/conditionable', $vendorDir . '/illuminate/macroable', $vendorDir . '/illuminate/support'),
'Illuminate\\Pagination\\' => array($vendorDir . '/illuminate/pagination'),
'Illuminate\\Contracts\\' => array($vendorDir . '/illuminate/contracts'),
'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'),
'Egulias\\EmailValidator\\' => array($vendorDir . '/egulias/email-validator/src'),
'Doctrine\\Inflector\\' => array($vendorDir . '/doctrine/inflector/src'),
'Doctrine\\Common\\Lexer\\' => array($vendorDir . '/doctrine/lexer/src'),
'DirectoryTree\\ImapEngine\\' => array($vendorDir . '/directorytree/imapengine/src'),
'DI\\' => array($vendorDir . '/php-di/php-di/src'),
'Carbon\\Doctrine\\' => array($vendorDir . '/carbonphp/carbon-doctrine-types/src/Carbon/Doctrine'),
'Carbon\\' => array($vendorDir . '/nesbot/carbon/src/Carbon'),
);

View File

@@ -12,17 +12,12 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
'606a39d89246991a373564698c2d8383' => __DIR__ . '/..' . '/symfony/polyfill-php85/bootstrap.php',
'2203a247e6fda86070a5e4e07aed533a' => __DIR__ . '/..' . '/symfony/clock/Resources/now.php',
'a1105708a18b76903365ca1c4aa61b02' => __DIR__ . '/..' . '/symfony/translation/Resources/functions.php',
'9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'a1105708a18b76903365ca1c4aa61b02' => __DIR__ . '/..' . '/symfony/translation/Resources/functions.php',
'23f09fe3194f8c2f70923f90d6702129' => __DIR__ . '/..' . '/illuminate/collections/functions.php',
'60799491728b879e74601d83e38b2cad' => __DIR__ . '/..' . '/illuminate/collections/helpers.php',
'f598d06aa772fa33d905e87be6398fb1' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/bootstrap.php',
'f625ee536139dfb962a398b200bdb2bd' => __DIR__ . '/..' . '/illuminate/support/functions.php',
'72579e7bd17821bb1321b87411366eae' => __DIR__ . '/..' . '/illuminate/support/helpers.php',
'7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php',
'def43f6c87e4f8dfd0c9e1b1bab14fe8' => __DIR__ . '/..' . '/symfony/polyfill-iconv/bootstrap.php',
'b33e3d135e5d9e47d845c576147bda89' => __DIR__ . '/..' . '/php-di/php-di/src/functions.php',
);
public static $prefixLengthsPsr4 = array (
@@ -30,12 +25,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
'voku\\' => 5,
),
'Z' =>
array (
'ZBateson\\StreamDecorators\\' => 26,
'ZBateson\\MbWrapper\\' => 19,
'ZBateson\\MailMimeParser\\' => 24,
),
'W' =>
array (
'Webklex\\PHPIMAP\\' => 16,
@@ -46,48 +35,26 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
'Symfony\\Polyfill\\Php84\\' => 23,
'Symfony\\Polyfill\\Php83\\' => 23,
'Symfony\\Polyfill\\Mbstring\\' => 26,
'Symfony\\Polyfill\\Intl\\Normalizer\\' => 33,
'Symfony\\Polyfill\\Intl\\Idn\\' => 26,
'Symfony\\Polyfill\\Iconv\\' => 23,
'Symfony\\Contracts\\Translation\\' => 30,
'Symfony\\Component\\Translation\\' => 30,
'Symfony\\Component\\Mime\\' => 23,
'Symfony\\Component\\HttpFoundation\\' => 33,
'Symfony\\Component\\Clock\\' => 24,
),
'P' =>
array (
'Psr\\SimpleCache\\' => 16,
'Psr\\Log\\' => 8,
'Psr\\Http\\Message\\' => 17,
'Psr\\Container\\' => 14,
'Psr\\Clock\\' => 10,
),
'L' =>
array (
'Laravel\\SerializableClosure\\' => 28,
),
'I' =>
array (
'Invoker\\' => 8,
'Illuminate\\Support\\' => 19,
'Illuminate\\Pagination\\' => 22,
'Illuminate\\Contracts\\' => 21,
),
'G' =>
array (
'GuzzleHttp\\Psr7\\' => 16,
),
'E' =>
array (
'Egulias\\EmailValidator\\' => 23,
),
'D' =>
array (
'Doctrine\\Inflector\\' => 19,
'Doctrine\\Common\\Lexer\\' => 22,
'DirectoryTree\\ImapEngine\\' => 25,
'DI\\' => 3,
),
'C' =>
array (
@@ -101,18 +68,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/voku/portable-ascii/src/voku',
),
'ZBateson\\StreamDecorators\\' =>
array (
0 => __DIR__ . '/..' . '/zbateson/stream-decorators/src',
),
'ZBateson\\MbWrapper\\' =>
array (
0 => __DIR__ . '/..' . '/zbateson/mb-wrapper/src',
),
'ZBateson\\MailMimeParser\\' =>
array (
0 => __DIR__ . '/..' . '/zbateson/mail-mime-parser/src',
),
'Webklex\\PHPIMAP\\' =>
array (
0 => __DIR__ . '/..' . '/webklex/php-imap/src',
@@ -133,18 +88,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
),
'Symfony\\Polyfill\\Intl\\Normalizer\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer',
),
'Symfony\\Polyfill\\Intl\\Idn\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-intl-idn',
),
'Symfony\\Polyfill\\Iconv\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-iconv',
),
'Symfony\\Contracts\\Translation\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/translation-contracts',
@@ -153,10 +96,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/symfony/translation',
),
'Symfony\\Component\\Mime\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/mime',
),
'Symfony\\Component\\HttpFoundation\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/http-foundation',
@@ -169,15 +108,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
),
'Psr\\Log\\' =>
array (
0 => __DIR__ . '/..' . '/psr/log/src',
),
'Psr\\Http\\Message\\' =>
array (
0 => __DIR__ . '/..' . '/psr/http-factory/src',
1 => __DIR__ . '/..' . '/psr/http-message/src',
),
'Psr\\Container\\' =>
array (
0 => __DIR__ . '/..' . '/psr/container/src',
@@ -186,14 +116,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/psr/clock/src',
),
'Laravel\\SerializableClosure\\' =>
array (
0 => __DIR__ . '/..' . '/laravel/serializable-closure/src',
),
'Invoker\\' =>
array (
0 => __DIR__ . '/..' . '/php-di/invoker/src',
),
'Illuminate\\Support\\' =>
array (
0 => __DIR__ . '/..' . '/illuminate/collections',
@@ -209,30 +131,10 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/illuminate/contracts',
),
'GuzzleHttp\\Psr7\\' =>
array (
0 => __DIR__ . '/..' . '/guzzlehttp/psr7/src',
),
'Egulias\\EmailValidator\\' =>
array (
0 => __DIR__ . '/..' . '/egulias/email-validator/src',
),
'Doctrine\\Inflector\\' =>
array (
0 => __DIR__ . '/..' . '/doctrine/inflector/src',
),
'Doctrine\\Common\\Lexer\\' =>
array (
0 => __DIR__ . '/..' . '/doctrine/lexer/src',
),
'DirectoryTree\\ImapEngine\\' =>
array (
0 => __DIR__ . '/..' . '/directorytree/imapengine/src',
),
'DI\\' =>
array (
0 => __DIR__ . '/..' . '/php-di/php-di/src',
),
'Carbon\\Doctrine\\' =>
array (
0 => __DIR__ . '/..' . '/carbonphp/carbon-doctrine-types/src/Carbon/Doctrine',
@@ -256,7 +158,6 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
'DateRangeError' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateRangeError.php',
'Deprecated' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Deprecated.php',
'NoDiscard' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
'Normalizer' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php',
'Override' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/Override.php',
'ReflectionConstant' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
'SQLite3Exception' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'reference' => '1ba19cc2492aa1397d8556f7442ad0c66513c2bf',
'reference' => '612041635d962d37f2f400ba1974bec5456ccd1e',
'name' => '__root__',
'dev' => true,
),
@@ -16,7 +16,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'reference' => '1ba19cc2492aa1397d8556f7442ad0c66513c2bf',
'reference' => '612041635d962d37f2f400ba1974bec5456ccd1e',
'dev_requirement' => false,
),
'carbonphp/carbon-doctrine-types' => array(
@@ -28,15 +28,6 @@
'reference' => '18ba5ddfec8976260ead6e866180bd5d2f71aa1d',
'dev_requirement' => false,
),
'directorytree/imapengine' => array(
'pretty_version' => 'v1.22.4',
'version' => '1.22.4.0',
'type' => 'library',
'install_path' => __DIR__ . '/../directorytree/imapengine',
'aliases' => array(),
'reference' => 'e41dd11f94bc9077a905de1e0c17bea87632ee64',
'dev_requirement' => false,
),
'doctrine/inflector' => array(
'pretty_version' => '2.1.0',
'version' => '2.1.0.0',
@@ -46,33 +37,6 @@
'reference' => '6d6c96277ea252fc1304627204c3d5e6e15faa3b',
'dev_requirement' => false,
),
'doctrine/lexer' => array(
'pretty_version' => '3.0.1',
'version' => '3.0.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../doctrine/lexer',
'aliases' => array(),
'reference' => '31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd',
'dev_requirement' => false,
),
'egulias/email-validator' => array(
'pretty_version' => '4.0.4',
'version' => '4.0.4.0',
'type' => 'library',
'install_path' => __DIR__ . '/../egulias/email-validator',
'aliases' => array(),
'reference' => 'd42c8731f0624ad6bdc8d3e5e9a4524f68801cfa',
'dev_requirement' => false,
),
'guzzlehttp/psr7' => array(
'pretty_version' => '2.8.0',
'version' => '2.8.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/psr7',
'aliases' => array(),
'reference' => '21dc724a0583619cd1652f673303492272778051',
'dev_requirement' => false,
),
'illuminate/collections' => array(
'pretty_version' => 'v12.28.1',
'version' => '12.28.1.0',
@@ -127,15 +91,6 @@
'reference' => '487bbe527806615b818e87c364d93ba91f27db9b',
'dev_requirement' => false,
),
'laravel/serializable-closure' => array(
'pretty_version' => 'v2.0.10',
'version' => '2.0.10.0',
'type' => 'library',
'install_path' => __DIR__ . '/../laravel/serializable-closure',
'aliases' => array(),
'reference' => '870fc81d2f879903dfc5b60bf8a0f94a1609e669',
'dev_requirement' => false,
),
'nesbot/carbon' => array(
'pretty_version' => '3.10.3',
'version' => '3.10.3.0',
@@ -145,24 +100,6 @@
'reference' => '8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f',
'dev_requirement' => false,
),
'php-di/invoker' => array(
'pretty_version' => '2.3.7',
'version' => '2.3.7.0',
'type' => 'library',
'install_path' => __DIR__ . '/../php-di/invoker',
'aliases' => array(),
'reference' => '3c1ddfdef181431fbc4be83378f6d036d59e81e1',
'dev_requirement' => false,
),
'php-di/php-di' => array(
'pretty_version' => '7.1.1',
'version' => '7.1.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../php-di/php-di',
'aliases' => array(),
'reference' => 'f88054cc052e40dbe7b383c8817c19442d480352',
'dev_requirement' => false,
),
'psr/clock' => array(
'pretty_version' => '1.0.0',
'version' => '1.0.0.0',
@@ -187,51 +124,6 @@
'reference' => 'c71ecc56dfe541dbd90c5360474fbc405f8d5963',
'dev_requirement' => false,
),
'psr/container-implementation' => array(
'dev_requirement' => false,
'provided' => array(
0 => '^1.0',
),
),
'psr/http-factory' => array(
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-factory',
'aliases' => array(),
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
'dev_requirement' => false,
),
'psr/http-factory-implementation' => array(
'dev_requirement' => false,
'provided' => array(
0 => '1.0',
),
),
'psr/http-message' => array(
'pretty_version' => '2.0',
'version' => '2.0.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-message',
'aliases' => array(),
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
'dev_requirement' => false,
),
'psr/http-message-implementation' => array(
'dev_requirement' => false,
'provided' => array(
0 => '1.0',
),
),
'psr/log' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/log',
'aliases' => array(),
'reference' => 'f16e1d5863e37f8d8c2a01719f5b34baa2b714d3',
'dev_requirement' => false,
),
'psr/simple-cache' => array(
'pretty_version' => '3.0.0',
'version' => '3.0.0.0',
@@ -241,15 +133,6 @@
'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
'dev_requirement' => false,
),
'ralouphie/getallheaders' => array(
'pretty_version' => '3.0.3',
'version' => '3.0.3.0',
'type' => 'library',
'install_path' => __DIR__ . '/../ralouphie/getallheaders',
'aliases' => array(),
'reference' => '120b605dfeb996808c31b6477290a714d356e822',
'dev_requirement' => false,
),
'spatie/once' => array(
'dev_requirement' => false,
'replaced' => array(
@@ -283,42 +166,6 @@
'reference' => 'db488a62f98f7a81d5746f05eea63a74e55bb7c4',
'dev_requirement' => false,
),
'symfony/mime' => array(
'pretty_version' => 'v8.0.6',
'version' => '8.0.6.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/mime',
'aliases' => array(),
'reference' => '632aef4f15ead4d48c16395e447f2da12543d201',
'dev_requirement' => false,
),
'symfony/polyfill-iconv' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-iconv',
'aliases' => array(),
'reference' => '5f3b930437ae03ae5dff61269024d8ea1b3774aa',
'dev_requirement' => false,
),
'symfony/polyfill-intl-idn' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-intl-idn',
'aliases' => array(),
'reference' => '9614ac4d8061dc257ecc64cba1b140873dce8ad3',
'dev_requirement' => false,
),
'symfony/polyfill-intl-normalizer' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-intl-normalizer',
'aliases' => array(),
'reference' => '3833d7255cc303546435cb650316bff708a1c75c',
'dev_requirement' => false,
),
'symfony/polyfill-mbstring' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
@@ -397,32 +244,5 @@
'reference' => '6b8ef85d621bbbaf52741b00cca8e9237e2b2e05',
'dev_requirement' => false,
),
'zbateson/mail-mime-parser' => array(
'pretty_version' => '3.0.5',
'version' => '3.0.5.0',
'type' => 'library',
'install_path' => __DIR__ . '/../zbateson/mail-mime-parser',
'aliases' => array(),
'reference' => 'ff054c8e05310c445c2028c6128a4319cc9f6aa8',
'dev_requirement' => false,
),
'zbateson/mb-wrapper' => array(
'pretty_version' => '2.0.1',
'version' => '2.0.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../zbateson/mb-wrapper',
'aliases' => array(),
'reference' => '50a14c0c9537f978a61cde9fdc192a0267cc9cff',
'dev_requirement' => false,
),
'zbateson/stream-decorators' => array(
'pretty_version' => '2.1.1',
'version' => '2.1.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../zbateson/stream-decorators',
'aliases' => array(),
'reference' => '32a2a62fb0f26313395c996ebd658d33c3f9c4e5',
'dev_requirement' => false,
),
),
);

View File

@@ -4,8 +4,8 @@
$issues = array();
if (!(PHP_VERSION_ID >= 80400)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.4.0". You are running ' . PHP_VERSION . '.';
if (!(PHP_VERSION_ID >= 80200)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {

View File

@@ -1,46 +0,0 @@
{
"name": "directorytree/imapengine",
"type": "library",
"description": "A fully-featured IMAP library -- without the PHP extension",
"keywords": [
"imap",
"mail",
"engine"
],
"homepage": "https://github.com/directorytree/imapengine",
"license": "MIT",
"authors": [
{
"name": "Steve Bauman",
"email": "steven_bauman@outlook.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"symfony/mime": ">=6.0",
"nesbot/carbon": ">=2.0",
"illuminate/collections": ">=9.0",
"zbateson/mail-mime-parser": "^3.0",
"egulias/email-validator": "^4.0"
},
"require-dev": {
"spatie/ray": "^1.0",
"pestphp/pest": "^2.0|^3.0|^4.0"
},
"autoload": {
"psr-4": {
"DirectoryTree\\ImapEngine\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests"
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}

View File

@@ -1,55 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class Address implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected string $email,
protected string $name,
) {
$this->name = Str::decodeMimeHeader($this->name);
}
/**
* Get the address's email.
*/
public function email(): string
{
return $this->email;
}
/**
* Get the address's name.
*/
public function name(): string
{
return $this->name;
}
/**
* Get the array representation of the address.
*/
public function toArray(): array
{
return [
'email' => $this->email,
'name' => $this->name,
];
}
/**
* Get the JSON representation of the address.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,114 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\Mime\MimeTypes;
class Attachment implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected ?string $filename,
protected ?string $contentId,
protected string $contentType,
protected ?string $contentDisposition,
protected StreamInterface $contentStream,
) {}
/**
* Get the attachment's filename.
*/
public function filename(): ?string
{
return $this->filename;
}
/**
* Get the attachment's content ID.
*/
public function contentId(): ?string
{
return $this->contentId;
}
/**
* Get the attachment's content type.
*/
public function contentType(): string
{
return $this->contentType;
}
/**
* Get the attachment's content disposition.
*/
public function contentDisposition(): string
{
return $this->contentDisposition;
}
/**
* Get the attachment's contents.
*/
public function contents(): string
{
return $this->contentStream->getContents();
}
/**
* Get the attachment's content stream.
*/
public function contentStream(): StreamInterface
{
return $this->contentStream;
}
/**
* Save the attachment to a file.
*/
public function save(string $path): false|int
{
return file_put_contents($path, $this->contents());
}
/**
* Get the attachment's extension.
*/
public function extension(): ?string
{
if ($ext = pathinfo($this->filename ?? '', PATHINFO_EXTENSION)) {
return $ext;
}
if ($ext = (MimeTypes::getDefault()->getExtensions($this->contentType)[0] ?? null)) {
return $ext;
}
return null;
}
/**
* Get the array representation of the attachment.
*/
public function toArray(): array
{
return [
'filename' => $this->filename,
'content_type' => $this->contentType,
'contents' => $this->contents(),
];
}
/**
* Get the JSON representation of the attachment.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,275 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Countable;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use IteratorAggregate;
use JsonSerializable;
use Traversable;
/**
* @implements IteratorAggregate<int, BodyStructurePart|BodyStructureCollection>
*/
class BodyStructureCollection implements Arrayable, Countable, IteratorAggregate, JsonSerializable
{
/**
* Constructor.
*
* @param array<BodyStructurePart|BodyStructureCollection> $parts
*/
public function __construct(
protected string $subtype = 'mixed',
protected array $parameters = [],
protected array $parts = [],
) {}
/**
* Parse a multipart BODYSTRUCTURE ListData into a BodyStructureCollection.
*/
public static function fromListData(ListData $data, ?string $partNumber = null): static
{
$tokens = $data->tokens();
$parts = [];
$childIndex = 1;
$subtypeIndex = null;
foreach ($tokens as $index => $token) {
if ($token instanceof Token && ! $token instanceof Nil) {
$subtypeIndex = $index;
break;
}
if (! $token instanceof ListData) {
continue;
}
$childPartNumber = $partNumber ? "{$partNumber}.{$childIndex}" : (string) $childIndex;
$parts[] = static::isMultipart($token)
? static::fromListData($token, $childPartNumber)
: BodyStructurePart::fromListData($token, $childPartNumber);
$childIndex++;
}
$parameters = [];
if ($subtypeIndex) {
foreach (array_slice($tokens, $subtypeIndex + 1) as $token) {
if ($token instanceof ListData && ! static::isDispositionList($token)) {
$parameters = $token->toKeyValuePairs();
break;
}
}
}
return new static(
$subtypeIndex ? strtolower($tokens[$subtypeIndex]->value) : 'mixed',
$parameters,
$parts
);
}
/**
* Determine if a ListData represents a multipart structure.
*/
protected static function isMultipart(ListData $data): bool
{
return head($data->tokens()) instanceof ListData;
}
/**
* Determine if a ListData represents a disposition (INLINE or ATTACHMENT).
*/
protected static function isDispositionList(ListData $data): bool
{
$tokens = $data->tokens();
if (count($tokens) < 2 || ! isset($tokens[0]) || ! $tokens[0] instanceof Token) {
return false;
}
return in_array(strtoupper($tokens[0]->value), ['INLINE', 'ATTACHMENT']);
}
/**
* Get the multipart subtype (mixed, alternative, related, etc.).
*/
public function subtype(): string
{
return $this->subtype;
}
/**
* Get the content type.
*/
public function contentType(): string
{
return "multipart/{$this->subtype}";
}
/**
* Get the parameters (e.g., boundary).
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get the boundary parameter.
*/
public function boundary(): ?string
{
return $this->parameters['boundary'] ?? null;
}
/**
* Get the direct child parts.
*
* @return array<BodyStructurePart|BodyStructureCollection>
*/
public function parts(): array
{
return $this->parts;
}
/**
* Get all parts flattened (including nested parts).
*
* @return BodyStructurePart[]
*/
public function flatten(): array
{
$flattened = [];
foreach ($this->parts as $part) {
if ($part instanceof self) {
$flattened = array_merge($flattened, $part->flatten());
} else {
$flattened[] = $part;
}
}
return $flattened;
}
/**
* Find a part by its part number.
*/
public function find(string $partNumber): BodyStructurePart|BodyStructureCollection|null
{
foreach ($this->parts as $part) {
if ($part instanceof self) {
if ($found = $part->find($partNumber)) {
return $found;
}
} elseif ($part->partNumber() === $partNumber) {
return $part;
}
}
return null;
}
/**
* Get the text/plain part if available.
*/
public function text(): ?BodyStructurePart
{
foreach ($this->flatten() as $part) {
if ($part->isText()) {
return $part;
}
}
return null;
}
/**
* Get the text/html part if available.
*/
public function html(): ?BodyStructurePart
{
foreach ($this->flatten() as $part) {
if ($part->isHtml()) {
return $part;
}
}
return null;
}
/**
* Get all attachment parts.
*
* @return BodyStructurePart[]
*/
public function attachments(): array
{
return array_values(array_filter(
$this->flatten(),
fn (BodyStructurePart $part) => $part->isAttachment()
));
}
/**
* Determine if the collection has attachments.
*/
public function hasAttachments(): bool
{
return count($this->attachments()) > 0;
}
/**
* Get the count of attachments.
*/
public function attachmentCount(): int
{
return count($this->attachments());
}
/**
* Get the count of parts.
*/
public function count(): int
{
return count($this->parts);
}
/**
* Get an iterator for the parts.
*/
public function getIterator(): Traversable
{
yield from $this->parts;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'subtype' => $this->subtype,
'parameters' => $this->parameters,
'content_type' => $this->contentType(),
'parts' => array_map(fn (Arrayable $part) => $part->toArray(), $this->parts),
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,243 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class BodyStructurePart implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected string $partNumber,
protected string $type,
protected string $subtype,
protected array $parameters = [],
protected ?string $id = null,
protected ?string $description = null,
protected ?string $encoding = null,
protected ?int $size = null,
protected ?int $lines = null,
protected ?ContentDisposition $disposition = null,
) {}
/**
* Parse a single part BODYSTRUCTURE ListData into a BodyStructurePart.
*/
public static function fromListData(ListData $data, string $partNumber = '1'): static
{
return static::parse($data->tokens(), $partNumber);
}
/**
* Parse a single (non-multipart) part.
*
* @param array<Token|ListData> $tokens
*/
protected static function parse(array $tokens, string $partNumber): static
{
return new static(
partNumber: $partNumber,
type: isset($tokens[0]) ? strtolower($tokens[0]->value) : 'text',
subtype: isset($tokens[1]) ? strtolower($tokens[1]->value) : 'plain',
parameters: isset($tokens[2]) && $tokens[2] instanceof ListData ? $tokens[2]->toKeyValuePairs() : [],
id: isset($tokens[3]) && ! $tokens[3] instanceof Nil ? $tokens[3]->value : null,
description: isset($tokens[4]) && ! $tokens[4] instanceof Nil ? $tokens[4]->value : null,
encoding: isset($tokens[5]) && ! $tokens[5] instanceof Nil ? $tokens[5]->value : null,
size: isset($tokens[6]) && ! $tokens[6] instanceof Nil ? (int) $tokens[6]->value : null,
lines: isset($tokens[7]) && ! $tokens[7] instanceof Nil ? (int) $tokens[7]->value : null,
disposition: ContentDisposition::parse($tokens),
);
}
/**
* Get the part number (e.g., "1", "1.2", "2.1.3").
*/
public function partNumber(): string
{
return $this->partNumber;
}
/**
* Get the MIME type (e.g., "text", "image", "multipart").
*/
public function type(): string
{
return $this->type;
}
/**
* Get the MIME subtype (e.g., "plain", "html", "jpeg", "mixed").
*/
public function subtype(): string
{
return $this->subtype;
}
/**
* Get the full content type (e.g., "text/plain", "multipart/alternative").
*/
public function contentType(): string
{
return "{$this->type}/{$this->subtype}";
}
/**
* Get the parameters (e.g., charset, boundary).
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get a specific parameter value.
*/
public function parameter(string $name): ?string
{
return $this->parameters[strtolower($name)] ?? null;
}
/**
* Get the content ID.
*/
public function id(): ?string
{
return $this->id;
}
/**
* Get the content description.
*/
public function description(): ?string
{
return $this->description;
}
/**
* Get the content transfer encoding.
*/
public function encoding(): ?string
{
return $this->encoding;
}
/**
* Get the size in bytes.
*/
public function size(): ?int
{
return $this->size;
}
/**
* Get the number of lines (for text parts).
*/
public function lines(): ?int
{
return $this->lines;
}
/**
* Get the content disposition.
*/
public function disposition(): ?ContentDisposition
{
return $this->disposition;
}
/**
* Get the filename from disposition parameters.
*/
public function filename(): ?string
{
return $this->disposition?->filename() ?? $this->parameters['name'] ?? null;
}
/**
* Get the charset from parameters.
*/
public function charset(): ?string
{
return $this->parameters['charset'] ?? null;
}
/**
* Determine if this is a text part.
*/
public function isText(): bool
{
return $this->type === 'text' && $this->subtype === 'plain';
}
/**
* Determine if this is an HTML part.
*/
public function isHtml(): bool
{
return $this->type === 'text' && $this->subtype === 'html';
}
/**
* Determine if this is an attachment.
*/
public function isAttachment(): bool
{
if ($this->disposition?->isAttachment()) {
return true;
}
// Inline parts are not attachments.
if ($this->disposition?->isInline()) {
return false;
}
// Consider non-text/html parts with filenames as attachments.
if ($this->filename() && ! $this->isText() && ! $this->isHtml()) {
return true;
}
return false;
}
/**
* Determine if this is an inline part.
*/
public function isInline(): bool
{
return $this->disposition?->isInline() ?? false;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'size' => $this->size,
'lines' => $this->lines,
'subtype' => $this->subtype,
'encoding' => $this->encoding,
'parameters' => $this->parameters,
'part_number' => $this->partNumber,
'description' => $this->description,
'content_type' => $this->contentType(),
'disposition' => $this->disposition?->toArray(),
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\FolderInterface;
use Illuminate\Support\Collection;
/**
* @template-extends Collection<array-key, FolderInterface>
*/
class FolderCollection extends Collection {}

View File

@@ -1,32 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Message;
use DirectoryTree\ImapEngine\MessageInterface;
/**
* @template-extends PaginatedCollection<array-key, MessageInterface|Message>
*/
class MessageCollection extends PaginatedCollection
{
/**
* Find a message by its UID.
*/
public function find(int $uid): ?MessageInterface
{
return $this->first(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
/**
* Find a message by its UID or throw an exception.
*/
public function findOrFail(int $uid): MessageInterface
{
return $this->firstOrFail(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
* @template TValue
*
* @template-extends Collection<TKey, TValue>
*/
class PaginatedCollection extends Collection
{
/**
* The total number of items.
*/
protected int $total = 0;
/**
* Paginate the current collection.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
public function paginate(int $perPage = 15, ?int $page = null, string $pageName = 'page', bool $prepaginated = false): LengthAwarePaginator
{
$total = $this->total ?: $this->count();
$results = ! $prepaginated && $total ? $this->forPage($page, $perPage) : $this;
return $this->paginator($results, $total, $perPage, $page, $pageName);
}
/**
* Create a new length-aware paginator instance.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
protected function paginator(Collection $items, int $total, int $perPage, ?int $currentPage, string $pageName): LengthAwarePaginator
{
return new LengthAwarePaginator($items, $total, $perPage, $currentPage, $pageName);
}
/**
* Get or set the total amount.
*/
public function total(?int $total = null): ?int
{
if (is_null($total)) {
return $this->total;
}
return $this->total = $total;
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
*
* @template-covariant TValue
*
* @extends Collection<array-key, TValue>
*/
class ResponseCollection extends Collection
{
/**
* Filter the collection to only tagged responses.
*
* @return self<array-key, TaggedResponse>
*/
public function tagged(): self
{
return $this->whereInstanceOf(TaggedResponse::class);
}
/**
* Filter the collection to only untagged responses.
*
* @return self<array-key, UntaggedResponse>
*/
public function untagged(): self
{
return $this->whereInstanceOf(UntaggedResponse::class);
}
/**
* Filter the collection to only continuation responses.
*
* @return self<array-key, ContinuationResponse>
*/
public function continuation(): self
{
return $this->whereInstanceOf(ContinuationResponse::class);
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
trait ComparesFolders
{
/**
* Determine if two folders are the same.
*/
protected function isSameFolder(FolderInterface $a, FolderInterface $b): bool
{
return $a->path() === $b->path()
&& $a->mailbox()->config('host') === $b->mailbox()->config('host')
&& $a->mailbox()->config('username') === $b->mailbox()->config('username');
}
}

View File

@@ -1,344 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use Generator;
interface ConnectionInterface
{
/**
* Open a new connection.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-state-and-flow-diagram
*/
public function connect(string $host, ?int $port = null, array $options = []): void;
/**
* Close the current connection.
*/
public function disconnect(): void;
/**
* Determine if the current session is connected.
*/
public function connected(): bool;
/**
* Send a "LOGIN" command.
*
* Login to a new session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-login-command
*/
public function login(string $user, string $password): TaggedResponse;
/**
* Send a "LOGOUT" command.
*
* Logout of the current server session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-logout-command
*/
public function logout(): void;
/**
* Send an "AUTHENTICATE" command.
*
* Authenticate the current session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-authenticate-command
*/
public function authenticate(string $user, string $token): TaggedResponse;
/**
* Send a "STARTTLS" command.
*
* Upgrade the current plaintext connection to a secure TLS-encrypted connection.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-starttls-command
*/
public function startTls(): void;
/**
* Send an "IDLE" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-idle-command
*/
public function idle(int $timeout): Generator;
/**
* Send a "DONE" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.13
*/
public function done(): void;
/**
* Send a "NOOP" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-noop-command
*/
public function noop(): TaggedResponse;
/**
* Send a "EXPUNGE" command.
*
* Apply session saved changes to the server.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command
*/
public function expunge(): ResponseCollection;
/**
* Send a "CAPABILITY" command.
*
* Get the mailbox's available capabilities.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-capability-command
*/
public function capability(): UntaggedResponse;
/**
* Send a "SEARCH" command.
*
* Execute a search request.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command
*/
public function search(array $params): UntaggedResponse;
/**
* Send a "SORT" command.
*
* Execute a sort request using RFC 5256.
*
* @see https://datatracker.ietf.org/doc/html/rfc5256
*/
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse;
/**
* Send a "FETCH" command.
*
* Exchange identification information.
*
* @see https://datatracker.ietf.org/doc/html/rfc2971.
*/
public function id(?array $ids = null): UntaggedResponse;
/**
* Send a "FETCH UID" command.
*
* Fetch message UIDs using the given message numbers.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-uid-command
*/
public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection;
/**
* Send a "FETCH BODY[TEXT]" command.
*
* Fetch message text contents.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyText(int|array $ids, bool $peek = true): ResponseCollection;
/**
* Send a "FETCH BODY[HEADER]" command.
*
* Fetch message headers.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection;
/**
* Send a "FETCH BODYSTRUCTURE" command.
*
* Fetch message body structure.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyStructure(int|array $ids): ResponseCollection;
/**
* Send a "FETCH BODY[i]" command.
*
* Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection;
/**
* Send a "FETCH FLAGS" command.
*
* Fetch a message flags.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17
*/
public function flags(int|array $ids): ResponseCollection;
/**
* Send a "FETCH" command.
*
* Fetch one or more items for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-fetch-command
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection;
/**
* Send a "RFC822.SIZE" command.
*
* Fetch message sizes for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21
*/
public function size(int|array $ids): ResponseCollection;
/**
* Send an IMAP command.
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void;
/**
* Send a "SELECT" command.
*
* Select the specified folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-select-command
*/
public function select(string $folder): ResponseCollection;
/**
* Send a "EXAMINE" command.
*
* Examine a given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command
*/
public function examine(string $folder): ResponseCollection;
/**
* Send a "LIST" command.
*
* Get a list of available folders.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-list-command
*/
public function list(string $reference = '', string $folder = '*'): ResponseCollection;
/**
* Send a "STATUS" command.
*
* Get the status of a given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-status-command
*/
public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse;
/**
* Send a "STORE" command.
*
* Set message flags.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-store-command
*/
public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection;
/**
* Send a "APPEND" command.
*
* Append a new message to given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-append-command
*/
public function append(string $folder, string $message, ?array $flags = null): TaggedResponse;
/**
* Send a "UID COPY" command.
*
* Copy message set from current folder to other folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-copy-command
*/
public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse;
/**
* Send a "UID MOVE" command.
*
* Move a message set from current folder to another folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-move-command
*/
public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse;
/**
* Send a "CREATE" command.
*
* Create a new folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-create-command
*/
public function create(string $folder): ResponseCollection;
/**
* Send a "DELETE" command.
*
* Delete a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-delete-command
*/
public function delete(string $folder): TaggedResponse;
/**
* Send a "RENAME" command.
*
* Rename an existing folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-rename-command
*/
public function rename(string $oldPath, string $newPath): TaggedResponse;
/**
* Send a "SUBSCRIBE" command.
*
* Subscribe to a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-subscribe-command
*/
public function subscribe(string $folder): TaggedResponse;
/**
* Send a "UNSUBSCRIBE" command.
*
* Unsubscribe from a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-unsubscribe-command
*/
public function unsubscribe(string $folder): TaggedResponse;
/**
* Send a "GETQUOTA" command.
*
* Retrieve quota information about a specific quota root.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquota
*/
public function quota(string $root): UntaggedResponse;
/**
* Send a "GETQUOTAROOT" command.
*
* Retrieve quota root information about a mailbox.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquotaroot
*/
public function quotaRoot(string $mailbox): ResponseCollection;
}

View File

@@ -1,105 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class ImapCommand implements Stringable
{
/**
* The compiled command lines.
*
* @var string[]
*/
protected ?array $compiled = null;
/**
* Constructor.
*/
public function __construct(
protected string $tag,
protected string $command,
protected array $tokens = [],
) {}
/**
* Get the IMAP tag.
*/
public function tag(): string
{
return $this->tag;
}
/**
* Get the IMAP command.
*/
public function command(): string
{
return $this->command;
}
/**
* Get the IMAP tokens.
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Compile the command into lines for transmission.
*
* @return string[]
*/
public function compile(): array
{
if (is_array($this->compiled)) {
return $this->compiled;
}
$lines = [];
$line = trim("{$this->tag} {$this->command}");
foreach ($this->tokens as $token) {
if (is_array($token)) {
// For tokens provided as arrays, the first element is a placeholder
// (for example, "{20}") that signals a literal value will follow.
// The second element holds the actual literal content.
[$placeholder, $literal] = $token;
$lines[] = "{$line} {$placeholder}";
$line = $literal;
} else {
$line .= " {$token}";
}
}
$lines[] = $line;
return $this->compiled = $lines;
}
/**
* Get a redacted version of the command for safe exposure.
*/
public function redacted(): ImapCommand
{
return new static($this->tag, $this->command, array_map(
function (mixed $token) {
return is_array($token)
? array_map(fn () => '[redacted]', $token)
: '[redacted]';
}, $this->tokens)
);
}
/**
* Get the command as a string.
*/
public function __toString(): string
{
return implode("\r\n", $this->compile());
}
}

View File

@@ -1,815 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Streams\FakeStream;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use DirectoryTree\ImapEngine\Exceptions\ImapResponseException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
use DirectoryTree\ImapEngine\Support\Str;
use Exception;
use Generator;
use LogicException;
use Throwable;
class ImapConnection implements ConnectionInterface
{
/**
* Sequence number used to generate unique command tags.
*/
protected int $sequence = 0;
/**
* The result instance.
*/
protected ?Result $result = null;
/**
* The parser instance.
*/
protected ?ImapParser $parser = null;
/**
* Constructor.
*/
public function __construct(
protected StreamInterface $stream,
protected ?LoggerInterface $logger = null,
) {}
/**
* Create a new connection with a fake stream.
*/
public static function fake(array $responses = []): static
{
$stream = new FakeStream;
$stream->open();
$stream->feed($responses);
return new static($stream);
}
/**
* Tear down the connection.
*/
public function __destruct()
{
if (! $this->connected()) {
return;
}
try {
@$this->logout();
} catch (Exception $e) {
// Do nothing.
}
}
/**
* {@inheritDoc}
*/
public function connect(string $host, ?int $port = null, array $options = []): void
{
$transport = strtolower($options['encryption'] ?? '') ?: 'tcp';
if (in_array($transport, ['ssl', 'tls'])) {
$port ??= 993;
} else {
$port ??= 143;
}
$this->setParser(
$this->newParser($this->stream)
);
$this->stream->open(
$transport === 'starttls' ? 'tcp' : $transport,
$host,
$port,
$options['timeout'] ?? 30,
$this->getDefaultSocketOptions(
$transport,
$options['proxy'] ?? [],
$options['validate_cert'] ?? true
)
);
$this->assertNextResponse(
fn (Response $response) => $response instanceof UntaggedResponse,
fn (UntaggedResponse $response) => $response->type()->is('OK'),
fn () => new ImapConnectionFailedException("Connection to $host:$port failed")
);
if ($transport === 'starttls') {
$this->startTls();
}
}
/**
* Get the default socket options for the given transport.
*
* @param 'ssl'|'tls'|'starttls'|'tcp' $transport
*/
protected function getDefaultSocketOptions(string $transport, array $proxy = [], bool $validateCert = true): array
{
$options = [];
$key = match ($transport) {
'ssl', 'tls' => 'ssl',
'starttls', 'tcp' => 'tcp',
};
if (in_array($transport, ['ssl', 'tls'])) {
$options[$key] = [
'verify_peer' => $validateCert,
'verify_peer_name' => $validateCert,
];
}
if (! isset($proxy['socket'])) {
return $options;
}
$options[$key]['proxy'] = $proxy['socket'];
$options[$key]['request_fulluri'] = $proxy['request_fulluri'] ?? false;
if (isset($proxy['username'])) {
$auth = base64_encode($proxy['username'].':'.$proxy['password']);
$options[$key]['header'] = ["Proxy-Authorization: Basic $auth"];
}
return $options;
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
$this->stream->close();
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return $this->stream->opened();
}
/**
* {@inheritDoc}
*/
public function login(string $user, string $password): TaggedResponse
{
$this->send('LOGIN', Str::literal([$user, $password]), $tag);
return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command()->redacted(), $response)
));
}
/**
* {@inheritDoc}
*/
public function logout(): void
{
$this->send('LOGOUT', tag: $tag);
}
/**
* {@inheritDoc}
*/
public function authenticate(string $user, string $token): TaggedResponse
{
$this->send('AUTHENTICATE', ['XOAUTH2', Str::credentials($user, $token)], $tag);
return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command()->redacted(), $response)
));
}
/**
* {@inheritDoc}
*/
public function startTls(): void
{
$this->send('STARTTLS', tag: $tag);
$this->assertTaggedResponse($tag);
$this->stream->setSocketSetCrypto(true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
}
/**
* {@inheritDoc}
*/
public function select(string $folder = 'INBOX'): ResponseCollection
{
return $this->examineOrSelect('SELECT', $folder);
}
/**
* {@inheritDoc}
*/
public function examine(string $folder = 'INBOX'): ResponseCollection
{
return $this->examineOrSelect('EXAMINE', $folder);
}
/**
* Examine and select have the same response.
*/
protected function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): ResponseCollection
{
$this->send($command, [Str::literal($folder)], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged();
}
/**
* {@inheritDoc}
*/
public function status(string $folder = 'INBOX', array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse
{
$this->send('STATUS', [
Str::literal($folder),
Str::list($arguments),
], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstWhere(
fn (UntaggedResponse $response) => $response->type()->is('STATUS')
);
}
/**
* {@inheritDoc}
*/
public function create(string $folder): ResponseCollection
{
$this->send('CREATE', [Str::literal($folder)], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('LIST')
);
}
/**
* {@inheritDoc}
*/
public function delete(string $folder): TaggedResponse
{
$this->send('DELETE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function rename(string $oldPath, string $newPath): TaggedResponse
{
$this->send('RENAME', Str::literal([$oldPath, $newPath]), tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function subscribe(string $folder): TaggedResponse
{
$this->send('SUBSCRIBE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function unsubscribe(string $folder): TaggedResponse
{
$this->send('UNSUBSCRIBE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function quota(string $root): UntaggedResponse
{
$this->send('GETQUOTA', [Str::literal($root)], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('QUOTA')
);
}
/**
* {@inheritDoc}
*/
public function quotaRoot(string $mailbox): ResponseCollection
{
$this->send('GETQUOTAROOT', [Str::literal($mailbox)], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('QUOTA')
);
}
/**
* {@inheritDoc}
*/
public function list(string $reference = '', string $folder = '*'): ResponseCollection
{
$this->send('LIST', Str::literal([$reference, $folder]), $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('LIST')
);
}
/**
* {@inheritDoc}
*/
public function append(string $folder, string $message, ?array $flags = null): TaggedResponse
{
$tokens = [];
$tokens[] = Str::literal($folder);
if ($flags) {
$tokens[] = Str::list($flags);
}
$tokens[] = Str::literal($message);
$this->send('APPEND', $tokens, tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse
{
$this->send('UID COPY', [
Str::set($from, $to),
Str::literal($folder),
], $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse
{
$this->send('UID MOVE', [
Str::set($from, $to),
Str::literal($folder),
], $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection
{
$set = Str::set($from, $to);
$flags = Str::list((array) $flags);
$item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : '');
$this->send('UID STORE', [$set, $item, $flags], tag: $tag);
$this->assertTaggedResponse($tag);
return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('FETCH')
);
}
/**
* {@inheritDoc}
*/
public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection
{
return $this->fetch(['UID'], (array) $ids, null, $identifier);
}
/**
* {@inheritDoc}
*/
public function bodyText(int|array $ids, bool $peek = true): ResponseCollection
{
return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection
{
return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids);
}
/**
* Fetch the BODYSTRUCTURE for the given message(s).
*/
public function bodyStructure(int|array $ids): ResponseCollection
{
return $this->fetch(['BODYSTRUCTURE'], (array) $ids);
}
/**
* Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
*/
public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection
{
$part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]";
return $this->fetch([$part], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function flags(int|array $ids): ResponseCollection
{
return $this->fetch(['FLAGS'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function size(int|array $ids): ResponseCollection
{
return $this->fetch(['RFC822.SIZE'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function search(array $params): UntaggedResponse
{
$this->send('UID SEARCH', $params, tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('SEARCH')
);
}
/**
* {@inheritDoc}
*/
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse
{
$sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value;
$this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('SORT')
);
}
/**
* {@inheritDoc}
*/
public function capability(): UntaggedResponse
{
$this->send('CAPABILITY', tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('CAPABILITY')
);
}
/**
* {@inheritDoc}
*/
public function id(?array $ids = null): UntaggedResponse
{
$token = 'NIL';
if (is_array($ids) && ! empty($ids)) {
$token = '(';
foreach ($ids as $id) {
$token .= '"'.Str::escape($id).'" ';
}
$token = rtrim($token).')';
}
$this->send('ID', [$token], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('ID')
);
}
/**
* {@inheritDoc}
*/
public function expunge(): ResponseCollection
{
$this->send('EXPUNGE', tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged();
}
/**
* {@inheritDoc}
*/
public function noop(): TaggedResponse
{
$this->send('NOOP', tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function idle(int $timeout): Generator
{
$this->stream->setTimeout($timeout);
$this->send('IDLE', tag: $tag);
$this->assertNextResponse(
fn (Response $response) => $response instanceof ContinuationResponse,
fn (ContinuationResponse $response) => true,
fn (ContinuationResponse $response) => ImapCommandException::make(new ImapCommand('', 'IDLE'), $response),
);
while ($response = $this->nextReply()) {
yield $response;
}
}
/**
* {@inheritDoc}
*/
public function done(): void
{
$this->write('DONE');
// After issuing a "DONE" command, the server must eventually respond with a
// tagged response to indicate that the IDLE command has been successfully
// terminated and the server is ready to accept further commands.
$this->assertNextResponse(
fn (Response $response) => $response instanceof TaggedResponse,
fn (TaggedResponse $response) => $response->successful(),
fn (TaggedResponse $response) => ImapCommandException::make(new ImapCommand('', 'DONE'), $response),
);
}
/**
* Send an IMAP command.
*
* @param-out string $tag
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void
{
if (! $tag) {
$this->sequence++;
$tag = 'TAG'.$this->sequence;
}
$command = new ImapCommand($tag, $name, $tokens);
// After every command, we'll overwrite any previous result
// with the new command and its responses, so that we can
// easily access the commands responses for assertion.
$this->setResult(new Result($command));
foreach ($command->compile() as $line) {
$this->write($line);
}
}
/**
* Write data to the connected stream.
*/
protected function write(string $data): void
{
if ($this->stream->fwrite($data."\r\n") === false) {
throw new ImapStreamException('Failed to write data to stream');
}
$this->logger?->sent($data);
}
/**
* Fetch one or more items for one or more messages.
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
{
$prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : '';
$this->send(trim($prefix.' FETCH'), [
Str::set($from, $to),
Str::list((array) $items),
], $tag);
$this->assertTaggedResponse($tag);
// Some IMAP servers can send unsolicited untagged responses along with fetch
// requests. We'll need to filter these out so that we can return only the
// responses that are relevant to the fetch command. For example:
// >> TAG123 FETCH (UID 456 BODY[TEXT])
// << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!)
// << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response
return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) {
// Skip over any untagged responses that are not FETCH responses.
// The third token should always be the list of data items.
if (! ($data = $response->tokenAt(3)) instanceof ListData) {
return false;
}
return match ($identifier) {
// If we're fetching UIDs, we can check if a UID token is contained in the list.
ImapFetchIdentifier::Uid => $data->contains('UID'),
// If we're fetching message numbers, we can check if the requested items are all contained in the list.
ImapFetchIdentifier::MessageNumber => $data->contains($items),
};
});
}
/**
* Set the current result instance.
*/
protected function setResult(Result $result): void
{
$this->result = $result;
}
/**
* Set the current parser instance.
*/
protected function setParser(ImapParser $parser): void
{
$this->parser = $parser;
}
/**
* Create a new parser instance.
*/
protected function newParser(StreamInterface $stream): ImapParser
{
return new ImapParser($this->newTokenizer($stream));
}
/**
* Create a new tokenizer instance.
*/
protected function newTokenizer(StreamInterface $stream): ImapTokenizer
{
return new ImapTokenizer($stream);
}
/**
* Assert the next response is a successful tagged response.
*/
protected function assertTaggedResponse(string $tag, ?callable $exception = null): TaggedResponse
{
/** @var TaggedResponse $response */
$response = $this->assertNextResponse(
fn (Response $response) => (
$response instanceof TaggedResponse && $response->tag()->is($tag)
),
fn (TaggedResponse $response) => (
$response->successful()
),
$exception ?? fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command(), $response)
),
);
return $response;
}
/**
* Assert the next response matches the given filter and assertion.
*
* @template T of Response
*
* @param callable(Response): bool $filter
* @param callable(T): bool $assertion
* @param callable(T): Throwable $exception
* @return T
*
* @throws ImapResponseException
*/
protected function assertNextResponse(callable $filter, callable $assertion, callable $exception): Response
{
while ($response = $this->nextResponse($filter)) {
if ($assertion($response)) {
return $response;
}
throw $exception($response);
}
throw new ImapResponseException('No matching response found');
}
/**
* Returns the next response matching the given filter.
*
* @template T of Response
*
* @param callable(T): bool $filter
* @return T|null
*/
protected function nextResponse(callable $filter): ?Response
{
if (! $this->parser) {
throw new LogicException('No parser instance set');
}
while ($response = $this->nextReply()) {
if (! $response instanceof Response) {
continue;
}
$this->result?->addResponse($response);
if ($filter($response)) {
return $response;
}
}
return null;
}
/**
* Read the next reply from the stream.
*/
protected function nextReply(): Data|Token|Response|null
{
if (! $reply = $this->parser->next()) {
$meta = $this->stream->meta();
throw match (true) {
$meta['timed_out'] ?? false => new ImapConnectionTimedOutException('Stream timed out, no response'),
$meta['eof'] ?? false => new ImapConnectionClosedException('Server closed the connection (EOF)'),
default => new ImapConnectionFailedException('Unknown stream error. Metadata: '.json_encode($meta)),
};
}
$this->logger?->received($reply);
return $reply;
}
}

View File

@@ -1,270 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
class ImapParser
{
/**
* The current token being parsed.
*
* Expected to be an associative array with keys like "type" and "value".
*/
protected ?Token $currentToken = null;
/**
* Constructor.
*/
public function __construct(
protected ImapTokenizer $tokenizer
) {}
/**
* Get the next response from the tokenizer.
*/
public function next(): Data|Token|Response|null
{
// Attempt to load the first token.
if (! $this->currentToken) {
$this->advance();
}
// No token was found, return null.
if (! $this->currentToken) {
return null;
}
// If the token indicates the beginning of a list, parse it.
if ($this->currentToken instanceof ListOpen) {
return $this->parseList();
}
// If the token is an Atom or Number, check its value for special markers.
if ($this->currentToken instanceof Atom || $this->currentToken instanceof Number) {
// '*' marks an untagged response.
if ($this->currentToken->value === '*') {
return $this->parseUntaggedResponse();
}
// '+' marks a continuation response.
if ($this->currentToken->value === '+') {
return $this->parseContinuationResponse();
}
// If it's an ATOM and not '*' or '+', it's likely a tagged response.
return $this->parseTaggedResponse();
}
return $this->parseElement();
}
/**
* Parse an untagged response.
*
* An untagged response begins with the '*' token. It may contain
* multiple elements, including lists and response codes.
*/
protected function parseUntaggedResponse(): UntaggedResponse
{
// Capture the initial '*' token.
$elements[] = clone $this->currentToken;
$this->advance();
// Collect all tokens until the end-of-response marker.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// If the end-of-response marker (CRLF) is present, consume it.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated untagged response');
}
return new UntaggedResponse($elements);
}
/**
* Parse a continuation response.
*
* A continuation response starts with a '+' token, indicating
* that the server expects additional data from the client.
*/
protected function parseContinuationResponse(): ContinuationResponse
{
// Capture the initial '+' token.
$elements[] = clone $this->currentToken;
$this->advance();
// Collect all tokens until the CRLF marker.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated continuation response');
}
return new ContinuationResponse($elements);
}
/**
* Parse a tagged response.
*
* A tagged response begins with a tag (which is not '*' or '+')
* and is followed by a status and optional data.
*/
protected function parseTaggedResponse(): TaggedResponse
{
// Capture the initial TAG token.
$tokens[] = clone $this->currentToken;
$this->advance();
// Collect tokens until the end-of-response marker is reached.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$tokens[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated tagged response');
}
return new TaggedResponse($tokens);
}
/**
* Parses a bracket group of elements delimited by '[' and ']'.
*
* Bracket groups are used to represent response codes.
*/
protected function parseBracketGroup(): ResponseCodeData
{
// Consume the opening '[' token.
$this->advance();
$elements = [];
while (
$this->currentToken
&& ! $this->currentToken instanceof ResponseCodeClose
) {
// Skip CRLF tokens that may appear inside bracket groups.
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$elements[] = $this->parseElement();
}
if ($this->currentToken === null) {
throw new ImapParserException('Unterminated bracket group in response');
}
// Consume the closing ']' token.
$this->advance();
return new ResponseCodeData($elements);
}
/**
* Parses a list of elements delimited by '(' and ')'.
*
* Lists are handled recursively, as a list may contain nested lists.
*/
protected function parseList(): ListData
{
// Consume the opening '(' token.
$this->advance();
$elements = [];
// Continue to parse elements until we find the corresponding ')'.
while (
$this->currentToken
&& ! $this->currentToken instanceof ListClose
) {
// Skip CRLF tokens that appear inside lists (after literals).
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$elements[] = $this->parseElement();
}
// If we reached the end without finding a closing ')', throw an exception.
if ($this->currentToken === null) {
throw new ImapParserException('Unterminated list in response');
}
// Consume the closing ')' token.
$this->advance();
return new ListData($elements);
}
/**
* Parses a single element, which might be a list or a simple token.
*/
protected function parseElement(): Data|Token|null
{
// If there is no current token, return null.
if ($this->currentToken === null) {
return null;
}
// If the token indicates the start of a list, parse it as a list.
if ($this->currentToken instanceof ListOpen) {
return $this->parseList();
}
// If the token indicates the start of a group, parse it as a group.
if ($this->currentToken instanceof ResponseCodeOpen) {
return $this->parseBracketGroup();
}
// Otherwise, capture the current token.
$token = clone $this->currentToken;
$this->advance();
return $token;
}
/**
* Advance to the next token from the tokenizer.
*/
protected function advance(): void
{
$this->currentToken = $this->tokenizer->nextToken();
}
}

View File

@@ -1,510 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use BackedEnum;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use DirectoryTree\ImapEngine\Enums\ImapSearchKey;
use DirectoryTree\ImapEngine\Support\Str;
class ImapQueryBuilder
{
/**
* The where conditions for the query.
*/
protected array $wheres = [];
/**
* The date format to use for date based queries.
*/
protected string $dateFormat = 'd-M-Y';
/**
* Add a where "ALL" clause to the query.
*/
public function all(): static
{
return $this->where(ImapSearchKey::All);
}
/**
* Add a where "NEW" clause to the query.
*/
public function new(): static
{
return $this->where(ImapSearchKey::New);
}
/**
* Add a where "OLD" clause to the query.
*/
public function old(): static
{
return $this->where(ImapSearchKey::Old);
}
/**
* Add a where "SEEN" clause to the query.
*/
public function seen(): static
{
return $this->where(ImapSearchKey::Seen);
}
/**
* Add a where "DRAFT" clause to the query.
*/
public function draft(): static
{
return $this->where(ImapSearchKey::Draft);
}
/**
* Add a where "RECENT" clause to the query.
*/
public function recent(): static
{
return $this->where(ImapSearchKey::Recent);
}
/**
* Add a where "UNSEEN" clause to the query.
*/
public function unseen(): static
{
return $this->where(ImapSearchKey::Unseen);
}
/**
* Add a where "FLAGGED" clause to the query.
*/
public function flagged(): static
{
return $this->where(ImapSearchKey::Flagged);
}
/**
* Add a where "DELETED" clause to the query.
*/
public function deleted(): static
{
return $this->where(ImapSearchKey::Deleted);
}
/**
* Add a where "ANSWERED" clause to the query.
*/
public function answered(): static
{
return $this->where(ImapSearchKey::Answered);
}
/**
* Add a where "UNDELETED" clause to the query.
*/
public function undeleted(): static
{
return $this->where(ImapSearchKey::Undeleted);
}
/**
* Add a where "UNFLAGGED" clause to the query.
*/
public function unflagged(): static
{
return $this->where(ImapSearchKey::Unflagged);
}
/**
* Add a where "UNANSWERED" clause to the query.
*/
public function unanswered(): static
{
return $this->where(ImapSearchKey::Unanswered);
}
/**
* Add a where "FROM" clause to the query.
*/
public function from(string $email): static
{
return $this->where(ImapSearchKey::From, $email);
}
/**
* Add a where "TO" clause to the query.
*/
public function to(string $value): static
{
return $this->where(ImapSearchKey::To, $value);
}
/**
* Add a where "CC" clause to the query.
*/
public function cc(string $value): static
{
return $this->where(ImapSearchKey::Cc, $value);
}
/**
* Add a where "BCC" clause to the query.
*/
public function bcc(string $value): static
{
return $this->where(ImapSearchKey::Bcc, $value);
}
/**
* Add a where "BODY" clause to the query.
*/
public function body(string $value): static
{
return $this->where(ImapSearchKey::Body, $value);
}
/**
* Add a where "KEYWORD" clause to the query.
*/
public function keyword(string $value): static
{
return $this->where(ImapSearchKey::Keyword, $value);
}
/**
* Add a where "UNKEYWORD" clause to the query.
*/
public function unkeyword(string $value): static
{
return $this->where(ImapSearchKey::Unkeyword, $value);
}
/**
* Add a where "ON" clause to the query.
*/
public function on(mixed $date): static
{
return $this->where(ImapSearchKey::On, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SINCE" clause to the query.
*/
public function since(mixed $date): static
{
return $this->where(ImapSearchKey::Since, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "BEFORE" clause to the query.
*/
public function before(mixed $value): static
{
return $this->where(ImapSearchKey::Before, new RawQueryValue(
$this->parseDate($value)->format($this->dateFormat)
));
}
/**
* Add a where "SENTON" clause to the query.
*/
public function sentOn(mixed $date): static
{
return $this->where(ImapSearchKey::SentOn, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTSINCE" clause to the query.
*/
public function sentSince(mixed $date): static
{
return $this->where(ImapSearchKey::SentSince, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTBEFORE" clause to the query.
*/
public function sentBefore(mixed $date): static
{
return $this->where(ImapSearchKey::SentBefore, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SUBJECT" clause to the query.
*/
public function subject(string $value): static
{
return $this->where(ImapSearchKey::Subject, $value);
}
/**
* Add a where "TEXT" clause to the query.
*/
public function text(string $value): static
{
return $this->where(ImapSearchKey::Text, $value);
}
/**
* Add a where "HEADER" clause to the query.
*/
public function header(string $header, string $value): static
{
return $this->where(ImapSearchKey::Header->value." $header", $value);
}
/**
* Add a where "UID" clause to the query.
*/
public function uid(int|string|array $from, int|float|null $to = null): static
{
return $this->where(ImapSearchKey::Uid, new RawQueryValue(Str::set($from, $to)));
}
/**
* Add a where "LARGER" clause to the query.
*/
public function larger(int $bytes): static
{
return $this->where(ImapSearchKey::Larger, new RawQueryValue($bytes));
}
/**
* Add a where "SMALLER" clause to the query.
*/
public function smaller(int $bytes): static
{
return $this->where(ImapSearchKey::Smaller, new RawQueryValue($bytes));
}
/**
* Add a "where" condition.
*/
public function where(mixed $column, mixed $value = null): static
{
if (is_callable($column)) {
$this->addNestedCondition('AND', $column);
} else {
$this->addBasicCondition('AND', $column, $value);
}
return $this;
}
/**
* Add an "or where" condition.
*/
public function orWhere(mixed $column, mixed $value = null): static
{
if (is_callable($column)) {
$this->addNestedCondition('OR', $column);
} else {
$this->addBasicCondition('OR', $column, $value);
}
return $this;
}
/**
* Add a "where not" condition.
*/
public function whereNot(mixed $column, mixed $value = null): static
{
$this->addBasicCondition('AND', $column, $value, true);
return $this;
}
/**
* Determine if the query has any where conditions.
*/
public function isEmpty(): bool
{
return empty($this->wheres);
}
/**
* Transform the instance into an IMAP-compatible query string.
*/
public function toImap(): string
{
return $this->compileWheres($this->wheres);
}
/**
* Create a new query instance (like Eloquent's newQuery).
*/
protected function newQuery(): static
{
return new static;
}
/**
* Add a basic condition to the query.
*/
protected function addBasicCondition(string $boolean, mixed $column, mixed $value, bool $not = false): void
{
$value = $this->prepareWhereValue($value);
$column = Str::enum($column);
$this->wheres[] = [
'type' => 'basic',
'not' => $not,
'key' => $column,
'value' => $value,
'boolean' => $boolean,
];
}
/**
* Prepare the where value, escaping it as needed.
*/
protected function prepareWhereValue(mixed $value): RawQueryValue|string|null
{
if (is_null($value)) {
return null;
}
if ($value instanceof RawQueryValue) {
return $value;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if ($value instanceof DateTimeInterface) {
$value = Carbon::instance($value);
}
if ($value instanceof CarbonInterface) {
$value = $value->format($this->dateFormat);
}
return Str::escape($value);
}
/**
* Add a nested condition group to the query.
*/
protected function addNestedCondition(string $boolean, callable $callback): void
{
$nested = $this->newQuery();
$callback($nested);
$this->wheres[] = [
'type' => 'nested',
'query' => $nested,
'boolean' => $boolean,
];
}
/**
* Attempt to parse a date string into a Carbon instance.
*/
protected function parseDate(mixed $date): CarbonInterface
{
if ($date instanceof CarbonInterface) {
return $date;
}
return Carbon::parse($date);
}
/**
* Build a single expression node from a basic or nested where.
*
* @param array{type: 'basic'|'nested', boolean: 'AND'|'OR', query: ImapQueryBuilder} $where
*/
protected function makeExpressionNode(array $where): array
{
return match ($where['type']) {
'basic' => [
'expr' => $this->compileBasic($where),
'boolean' => $where['boolean'],
],
'nested' => [
'expr' => $where['query']->toImap(),
'boolean' => $where['boolean'],
]
};
}
/**
* Merge the existing expression with the next expression, respecting the boolean operator.
*
* @param 'AND'|'OR' $boolean
*/
protected function mergeExpressions(string $existing, string $next, string $boolean): string
{
return match ($boolean) {
// AND is implicit just append.
'AND' => $existing.' '.$next,
// IMAP's OR is binary; nest accordingly.
'OR' => 'OR ('.$existing.') ('.$next.')',
};
}
/**
* Recursively compile the wheres array into an IMAP-compatible string.
*/
protected function compileWheres(array $wheres): string
{
if (empty($wheres)) {
return '';
}
// Convert each "where" into a node for later merging.
$exprNodes = array_map(fn (array $where) => (
$this->makeExpressionNode($where)
), $wheres);
// Start with the first expression.
$combined = array_shift($exprNodes)['expr'];
// Merge the rest of the expressions.
foreach ($exprNodes as $node) {
$combined = $this->mergeExpressions(
$combined, $node['expr'], $node['boolean']
);
}
return trim($combined);
}
/**
* Compile a basic where condition into an IMAP-compatible string.
*/
protected function compileBasic(array $where): string
{
$part = strtoupper($where['key']);
if ($where['value'] instanceof RawQueryValue) {
$part .= ' '.$where['value']->value;
} elseif ($where['value']) {
$part .= ' "'.Str::toImapUtf7($where['value']).'"';
}
if ($where['not']) {
$part = 'NOT '.$part;
}
return $part;
}
}

View File

@@ -1,511 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\EmailAddress;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Literal;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\QuotedString;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
class ImapTokenizer
{
/**
* The current position in the buffer.
*/
protected int $position = 0;
/**
* The buffer of characters read from the stream.
*/
protected string $buffer = '';
/**
* Constructor.
*/
public function __construct(
protected StreamInterface $stream
) {}
/**
* Returns the next token from the stream.
*/
public function nextToken(): ?Token
{
$this->skipWhitespace();
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null || $char === '') {
return null;
}
// Check for line feed.
if ($char === "\n") {
// With a valid IMAP response, we should never reach this point,
// but in case we receive a malformed response, we will flush
// the buffer and return null to prevent an infinite loop.
$this->flushBuffer();
return null;
}
// Check for carriage return. (\r\n)
if ($char === "\r") {
$this->advance(); // Consume CR
$this->ensureBuffer(1);
if ($this->currentChar() !== "\n") {
throw new ImapParserException('Expected LF after CR');
}
$this->advance(); // Consume LF (\n)
return new Crlf("\r\n");
}
// Check for parameter list opening.
if ($char === '(') {
$this->advance();
return new ListOpen('(');
}
// Check for a parameter list closing.
if ($char === ')') {
$this->advance();
return new ListClose(')');
}
// Check for a response group open.
if ($char === '[') {
$this->advance();
return new ResponseCodeOpen('[');
}
// Check for response group close.
if ($char === ']') {
$this->advance();
return new ResponseCodeClose(']');
}
// Check for angle bracket open (email addresses).
if ($char === '<') {
$this->advance();
return $this->readEmailAddress();
}
// Check for quoted string.
if ($char === '"') {
return $this->readQuotedString();
}
// Check for literal block open.
if ($char === '{') {
return $this->readLiteral();
}
// Otherwise, parse a number or atom.
return $this->readNumberOrAtom();
}
/**
* Skips whitespace characters (spaces and tabs only, preserving CRLF).
*/
protected function skipWhitespace(): void
{
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
// Break on EOF.
if ($char === null || $char === '') {
break;
}
// Break on CRLF.
if ($char === "\r" || $char === "\n") {
break;
}
// Break on non-whitespace.
if ($char !== ' ' && $char !== "\t") {
break;
}
$this->advance();
}
}
/**
* Reads a quoted string token.
*
* Quoted strings are enclosed in double quotes and may contain escaped characters.
*/
protected function readQuotedString(): QuotedString
{
// Skip the opening quote.
$this->advance();
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException(sprintf(
'Unterminated quoted string at buffer offset %d. Buffer: "%s"',
$this->position,
substr($this->buffer, max(0, $this->position - 10), 20)
));
}
if ($char === '\\') {
$this->advance(); // Skip the backslash.
$this->ensureBuffer(1);
$escapedChar = $this->currentChar();
if ($escapedChar === null) {
throw new ImapParserException('Unterminated escape sequence in quoted string');
}
$value .= $escapedChar;
$this->advance();
continue;
}
if ($char === '"') {
$this->advance(); // Skip the closing quote.
break;
}
$value .= $char;
$this->advance();
}
return new QuotedString($value);
}
/**
* Reads a literal token.
*
* Literal blocks in IMAP have the form {<length>}\r\n<data>.
*/
protected function readLiteral(): Literal
{
// Skip the opening '{'.
$this->advance();
// This will contain the size of the literal block in a sequence of digits.
// {<size>}\r\n<data>
$numStr = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException('Unterminated literal specifier');
}
if ($char === '}') {
$this->advance(); // Skip the '}'.
break;
}
$numStr .= $char;
$this->advance();
}
// Expect carriage return after the literal specifier.
$this->ensureBuffer(2);
// Get the carriage return.
$crlf = substr($this->buffer, $this->position, 2);
if ($crlf !== "\r\n") {
throw new ImapParserException('Expected CRLF after literal specifier');
}
// Skip the CRLF.
$this->advance(2);
$length = (int) $numStr;
// Use any data that is already in our buffer.
$available = strlen($this->buffer) - $this->position;
if ($available >= $length) {
$literal = substr($this->buffer, $this->position, $length);
$this->advance($length);
} else {
// Consume whatever is available without flushing the whole buffer.
$literal = substr($this->buffer, $this->position);
$consumed = strlen($literal);
// Advance the pointer by the number of bytes we took.
$this->advance($consumed);
// Calculate how many bytes are still needed.
$remaining = $length - $consumed;
// Read the missing bytes from the stream.
$data = $this->stream->read($remaining);
if ($data === false || strlen($data) !== $remaining) {
throw new ImapStreamException('Unexpected end of stream while trying to fill the buffer');
}
$literal .= $data;
}
// Verify that the literal length matches the expected length.
if (strlen($literal) !== $length) {
throw new ImapParserException(sprintf(
'Literal length mismatch: expected %d, got %d',
$length,
strlen($literal)
));
}
return new Literal($literal);
}
/**
* Reads a number or atom token.
*/
protected function readNumberOrAtom(): Token
{
$position = $this->position;
// First char must be a digit to even consider a number.
if (! ctype_digit($this->buffer[$position] ?? '')) {
return $this->readAtom();
}
// Walk forward to find the end of the digit run.
while (ctype_digit($this->buffer[$position] ?? '')) {
$position++;
$this->ensureBuffer($position - $this->position + 1);
}
$next = $this->buffer[$position] ?? null;
// If next is EOF or a delimiter, it's a Number.
if ($next === null || $this->isDelimiter($next)) {
return $this->readNumber();
}
// Otherwise it's an Atom.
return $this->readAtom();
}
/**
* Reads a number token.
*
* A number consists of one or more digit characters and represents a numeric value.
*/
protected function readNumber(): Number
{
$start = $this->position;
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! ctype_digit($char)) {
break;
}
$this->advance();
}
return new Number(substr($this->buffer, $start, $this->position - $start));
}
/**
* Reads an atom token.
*
* ATOMs are sequences of printable ASCII characters that do not contain delimiters.
*/
protected function readAtom(): Atom
{
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! $this->isValidAtomCharacter($char)) {
break;
}
$value .= $char;
$this->advance();
}
if (strcasecmp($value, 'NIL') === 0) {
return new Nil($value);
}
return new Atom($value);
}
/**
* Reads an email address token enclosed in angle brackets.
*
* Email addresses are enclosed in angle brackets ("<" and ">").
*
* For example "<johndoe@email.com>"
*/
protected function readEmailAddress(): ?EmailAddress
{
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException('Unterminated email address, expected ">"');
}
if ($char === '>') {
$this->advance(); // Skip the closing '>'.
break;
}
$value .= $char;
$this->advance();
}
return new EmailAddress($value);
}
/**
* Ensures that at least the given length in characters are available in the buffer.
*/
protected function ensureBuffer(int $length): void
{
// If we have enough data in the buffer, return early.
while ((strlen($this->buffer) - $this->position) < $length) {
$data = $this->stream->fgets();
if ($data === false) {
return;
}
$this->buffer .= $data;
}
}
/**
* Returns the current character in the buffer.
*/
protected function currentChar(): ?string
{
return $this->buffer[$this->position] ?? null;
}
/**
* Advances the internal pointer by $n characters.
*/
protected function advance(int $n = 1): void
{
$this->position += $n;
// If we have consumed the entire buffer, reset it.
if ($this->position >= strlen($this->buffer)) {
$this->flushBuffer();
}
}
/**
* Flush the buffer and reset the position.
*/
protected function flushBuffer(): void
{
$this->buffer = '';
$this->position = 0;
}
/**
* Determine if the given character is a valid atom character.
*/
protected function isValidAtomCharacter(string $char): bool
{
// Get the ASCII code.
$code = ord($char);
// Allow only printable ASCII (32-126).
if ($code < 32 || $code > 126) {
return false;
}
// Delimiters are not allowed inside ATOMs.
if ($this->isDelimiter($char)) {
return false;
}
return true;
}
/**
* Determine if the given character is a delimiter for tokenizing responses.
*/
protected function isDelimiter(string $char): bool
{
// This delimiter list includes additional characters (such as square
// brackets, curly braces, and angle brackets) to ensure that tokens
// like the response code group brackets are split out. This is fine
// for tokenizing responses, even though its more restrictive
// than the IMAP atom definition in RFC 3501 (section 9).
return in_array($char, [' ', '(', ')', '[', ']', '{', '}', '<', '>'], true);
}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class EchoLogger extends Logger
{
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
echo $message;
}
}

View File

@@ -1,21 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class FileLogger extends Logger
{
/**
* Constructor.
*/
public function __construct(
protected string $path
) {}
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
file_put_contents($this->path, $message, FILE_APPEND);
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
abstract class Logger implements LoggerInterface
{
/**
* Write a message to the log.
*/
abstract protected function write(string $message): void;
/**
* {@inheritDoc}
*/
public function sent(string $message): void
{
$this->write(sprintf('%s: >> %s', $this->date(), $message).PHP_EOL);
}
/**
* {@inheritDoc}
*/
public function received(string $message): void
{
$this->write(sprintf('%s: << %s', $this->date(), $message).PHP_EOL);
}
/**
* Get the current date and time.
*/
protected function date(): string
{
return date('Y-m-d H:i:s');
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
interface LoggerInterface
{
/**
* Log when a message is sent.
*/
public function sent(string $message): void;
/**
* Log when a message is received.
*/
public function received(string $message): void;
}

View File

@@ -1,14 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class RayLogger extends Logger
{
/**
* {@inheritDoc}
*/
protected function write(string $message): void
{
ray($message);
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class RawQueryValue
{
/**
* Constructor.
*/
public function __construct(
public readonly Stringable|string $value
) {}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ContinuationResponse extends Response
{
/**
* Get the data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 1);
}
}

View File

@@ -1,73 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Responses\HasTokens;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Stringable;
abstract class Data implements Stringable
{
use HasTokens;
/**
* Constructor.
*/
public function __construct(
protected array $tokens
) {}
/**
* Get the tokens.
*
* @return Token[]|Data[]
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Get the first token.
*/
public function first(): Token|Data|null
{
return $this->tokens[0] ?? null;
}
/**
* Get the last token.
*/
public function last(): Token|Data|null
{
return $this->tokens[count($this->tokens) - 1] ?? null;
}
/**
* Determine if the data contains a specific value.
*/
public function contains(array|string $needles): bool
{
$haystack = $this->values();
foreach ((array) $needles as $needle) {
if (! in_array($needle, $haystack)) {
return false;
}
}
return true;
}
/**
* Get all the token's values.
*/
public function values(): array
{
return array_map(function (Token|Data $token) {
return $token instanceof Data
? $token->values()
: $token->value;
}, $this->tokens);
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ListData extends Data
{
/**
* Find the immediate successor token of the given field in the list.
*/
public function lookup(string $field): Data|Token|null
{
foreach ($this->tokens as $index => $token) {
if ((string) $token === $field) {
return $this->tokenAt(++$index);
}
}
return null;
}
/**
* Convert alternating key/value tokens to an associative array.
*/
public function toKeyValuePairs(): array
{
$pairs = [];
for ($i = 0; $i < count($this->tokens) - 1; $i += 2) {
$key = strtolower($this->tokens[$i]->value);
$pairs[$key] = $this->tokens[$i + 1]->value;
}
return $pairs;
}
/**
* Get the list as a string.
*/
public function __toString(): string
{
return sprintf('(%s)', implode(
' ', array_map('strval', $this->tokens)
));
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
class ResponseCodeData extends Data
{
/**
* Get the group as a string.
*/
public function __toString(): string
{
return sprintf('[%s]', implode(
' ', array_map('strval', $this->tokens)
));
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
trait HasTokens
{
/**
* Get the response tokens.
*
* @return Token[]|Data[]
*/
abstract public function tokens(): array;
/**
* Get the response token at the given index.
*/
public function tokenAt(int $index): Token|Data|null
{
return $this->tokens()[$index] ?? null;
}
/**
* Get the response tokens after the given index.
*/
public function tokensAfter(int $index): array
{
return array_slice($this->tokens(), $index);
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
class MessageResponseParser
{
/**
* Get the UID from a tagged move or copy response.
*/
public static function getUidFromCopy(TaggedResponse $response): ?int
{
if (! $data = $response->tokenAt(2)) {
return null;
}
if (! $data instanceof ResponseCodeData) {
return null;
}
if (! $value = $data->tokenAt(3)?->value) {
return null;
}
return (int) $value;
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use Stringable;
class Response implements Arrayable, Stringable
{
use HasTokens;
/**
* Constructor.
*/
public function __construct(
protected array $tokens
) {}
/**
* Get the response tokens.
*
* @return Token[]|Data[]
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Get the instance as an array.
*/
public function toArray(): array
{
return array_map(function (Token|Data $token) {
return $token instanceof Data
? $token->values()
: $token->value;
}, $this->tokens);
}
/**
* Get a JSON representation of the response tokens.
*/
public function __toString(): string
{
return implode(' ', $this->tokens);
}
}

View File

@@ -1,52 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class TaggedResponse extends Response
{
/**
* Get the response tag.
*/
public function tag(): Atom|Number
{
return $this->tokens[0];
}
/**
* Get the response status token.
*/
public function status(): Atom
{
return $this->tokens[1];
}
/**
* Get the response data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 2);
}
/**
* Determine if the response was successful.
*/
public function successful(): bool
{
return strtoupper($this->status()->value) === 'OK';
}
/**
* Determine if the response failed.
*/
public function failed(): bool
{
return in_array(strtoupper($this->status()->value), ['NO', 'BAD']);
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
class UntaggedResponse extends Response
{
/**
* Get the response type token.
*/
public function type(): Atom|Number
{
return $this->tokens[1];
}
/**
* Get the data tokens.
*
* @return Atom[]
*/
public function data(): array
{
return array_slice($this->tokens, 2);
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
class Result
{
/**
* Constructor.
*/
public function __construct(
protected ImapCommand $command,
protected array $responses = [],
) {}
/**
* Get the executed command.
*/
public function command(): ImapCommand
{
return $this->command;
}
/**
* Add a response to the result.
*/
public function addResponse(Response $response): void
{
$this->responses[] = $response;
}
/**
* Get the recently received responses.
*/
public function responses(): ResponseCollection
{
return new ResponseCollection($this->responses);
}
}

View File

@@ -1,232 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Streams;
use PHPUnit\Framework\Assert;
use RuntimeException;
class FakeStream implements StreamInterface
{
/**
* Lines queued for testing; each call to fgets() pops the next line.
*
* @var string[]
*/
protected array $buffer = [];
/**
* Data that has been "written" to this fake stream (for assertion).
*
* @var string[]
*/
protected array $written = [];
/**
* The connection info.
*/
protected ?array $connection = null;
/**
* The mock meta info.
*/
protected array $meta = [
'crypto' => [
'protocol' => '',
'cipher_name' => '',
'cipher_bits' => 0,
'cipher_version' => '',
],
'mode' => 'c',
'eof' => false,
'blocked' => false,
'timed_out' => false,
'seekable' => false,
'unread_bytes' => 0,
'stream_type' => 'tcp_socket/unknown',
];
/**
* Feed a line to the stream buffer with a newline character.
*/
public function feed(array|string $lines): self
{
// We'll ensure that each line ends with a CRLF,
// as this is the expected behavior of every
// reply that comes from an IMAP server.
$lines = array_map(fn (string $line) => (
rtrim($line, "\r\n")."\r\n"
), (array) $lines);
array_push($this->buffer, ...$lines);
return $this;
}
/**
* Feed a raw line to the stream buffer.
*/
public function feedRaw(array|string $lines): self
{
array_push($this->buffer, ...(array) $lines);
return $this;
}
/**
* Set the timed out status.
*/
public function setMeta(string $attribute, mixed $value): self
{
if (! isset($this->meta[$attribute])) {
throw new RuntimeException(
"Unknown metadata attribute: {$attribute}"
);
}
if (gettype($this->meta[$attribute]) !== gettype($value)) {
throw new RuntimeException(
"Metadata attribute {$attribute} must be of type ".gettype($this->meta[$attribute])
);
}
$this->meta[$attribute] = $value;
return $this;
}
/**
* {@inheritDoc}
*/
public function open(?string $transport = null, ?string $host = null, ?int $port = null, ?int $timeout = null, array $options = []): bool
{
$this->connection = compact('transport', 'host', 'port', 'timeout', 'options');
return true;
}
/**
* {@inheritDoc}
*/
public function close(): void
{
$this->buffer = [];
$this->connection = null;
}
/**
* {@inheritDoc}
*/
public function read(int $length): string|false
{
if (! $this->opened()) {
return false;
}
if ($this->meta['eof'] && empty($this->buffer)) {
return false; // EOF and no data left. Indicate end of stream.
}
$data = implode('', $this->buffer);
$availableLength = strlen($data);
if ($availableLength === 0) {
// No data available right now (but not EOF).
// Simulate non-blocking behavior.
return '';
}
$bytesToRead = min($length, $availableLength);
$result = substr($data, 0, $bytesToRead);
$remainingData = substr($data, $bytesToRead);
$this->buffer = $remainingData !== '' ? [$remainingData] : [];
return $result;
}
/**
* {@inheritDoc}
*/
public function fgets(): string|false
{
if (! $this->opened()) {
return false;
}
// Simulate timeout/eof checks.
if ($this->meta['timed_out'] || $this->meta['eof']) {
return false;
}
return array_shift($this->buffer) ?? false;
}
/**
* {@inheritDoc}
*/
public function fwrite(string $data): int|false
{
if (! $this->opened()) {
return false;
}
$this->written[] = $data;
return strlen($data);
}
/**
* {@inheritDoc}
*/
public function meta(): array
{
return $this->meta;
}
/**
* {@inheritDoc}
*/
public function opened(): bool
{
return (bool) $this->connection;
}
/**
* {@inheritDoc}
*/
public function setTimeout(int $seconds): bool
{
return true;
}
/**
* {@inheritDoc}
*/
public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int
{
return true;
}
/**
* Assert that the given data was written to the stream.
*/
public function assertWritten(string $string): void
{
$found = false;
foreach ($this->written as $index => $written) {
if (str_contains($written, $string)) {
unset($this->written[$index]);
$found = true;
break;
}
}
Assert::assertTrue($found, "Failed asserting that the string '{$string}' was written to the stream.");
}
}

View File

@@ -1,120 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Streams;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
class ImapStream implements StreamInterface
{
/**
* The underlying PHP stream resource.
*
* @var resource|null
*/
protected mixed $stream = null;
/**
* {@inheritDoc}
*/
public function open(string $transport, string $host, int $port, int $timeout, array $options = []): bool
{
$this->stream = @stream_socket_client(
$address = "{$transport}://{$host}:{$port}",
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
stream_context_create($options)
);
if (! $this->stream) {
throw new ImapConnectionFailedException("Unable to connect to {$address} ({$errstr})", $errno);
}
return true;
}
/**
* {@inheritDoc}
*/
public function close(): void
{
if ($this->opened()) {
fclose($this->stream);
}
$this->stream = null;
}
/**
* {@inheritDoc}
*/
public function read(int $length): string|false
{
if (! $this->opened()) {
return false;
}
$data = '';
while (strlen($data) < $length && ! feof($this->stream)) {
$chunk = fread($this->stream, $length - strlen($data));
if ($chunk === false) {
return false;
}
$data .= $chunk;
}
return $data;
}
/**
* {@inheritDoc}
*/
public function fgets(): string|false
{
return $this->opened() ? fgets($this->stream) : false;
}
/**
* {@inheritDoc}
*/
public function fwrite(string $data): int|false
{
return $this->opened() ? fwrite($this->stream, $data) : false;
}
/**
* {@inheritDoc}
*/
public function meta(): array
{
return $this->opened() ? stream_get_meta_data($this->stream) : [];
}
/**
* {@inheritDoc}
*/
public function opened(): bool
{
return is_resource($this->stream);
}
/**
* {@inheritDoc}
*/
public function setTimeout(int $seconds): bool
{
return stream_set_timeout($this->stream, $seconds);
}
/**
* {@inheritDoc}
*/
public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int
{
return stream_socket_enable_crypto($this->stream, $enabled, $method);
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Streams;
interface StreamInterface
{
/**
* Open the underlying stream.
*/
public function open(string $transport, string $host, int $port, int $timeout, array $options = []): bool;
/**
* Close the underlying stream.
*/
public function close(): void;
/**
* Read data from the stream.
*/
public function read(int $length): string|false;
/**
* Read a single line from the stream.
*/
public function fgets(): string|false;
/**
* Write data to the stream.
*/
public function fwrite(string $data): int|false;
/**
* Return meta info (like stream_get_meta_data).
*/
public function meta(): array;
/**
* Determine if the stream is open.
*/
public function opened(): bool;
/**
* Set the timeout on the stream.
*/
public function setTimeout(int $seconds): bool;
/**
* Set encryption state on an already connected socked.
*/
public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int;
}

View File

@@ -1,8 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
/**
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-atom
*/
class Atom extends Token {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Crlf extends Token {}

View File

@@ -1,14 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class EmailAddress extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return '<'.$this->value.'>';
}
}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ListClose extends Token {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ListOpen extends Token {}

View File

@@ -1,14 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Literal extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return sprintf("{%d}\r\n%s", strlen($this->value), $this->value);
}
}

View File

@@ -1,8 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
/**
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-nil
*/
class Nil extends Atom {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Number extends Token {}

View File

@@ -1,14 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class QuotedString extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return '"'.$this->value.'"';
}
}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ResponseCodeClose extends Token {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ResponseCodeOpen extends Token {}

View File

@@ -1,39 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
use Stringable;
abstract class Token implements Stringable
{
/**
* Constructor.
*/
public function __construct(
public string $value,
) {}
/**
* Determine if the token is the given value.
*/
public function is(string $value): bool
{
return $this->value === $value;
}
/**
* Determine if the token is not the given value.
*/
public function isNot(string $value): bool
{
return ! $this->is($value);
}
/**
* Get the token's value.
*/
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -1,122 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ContentDispositionType;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @see https://datatracker.ietf.org/doc/html/rfc2183
*/
class ContentDisposition implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected ContentDispositionType $type,
protected array $parameters = [],
) {}
/**
* Parse the disposition from tokens.
*
* @param array<Token|ListData> $tokens
*/
public static function parse(array $tokens): ?static
{
for ($i = 8; $i < count($tokens); $i++) {
if (! $tokens[$i] instanceof ListData) {
continue;
}
$innerTokens = $tokens[$i]->tokens();
if (! isset($innerTokens[0]) || ! $innerTokens[0] instanceof Token) {
continue;
}
if (! $type = ContentDispositionType::tryFrom(strtolower($innerTokens[0]->value))) {
continue;
}
$parameters = isset($innerTokens[1]) && $innerTokens[1] instanceof ListData
? $innerTokens[1]->toKeyValuePairs()
: [];
return new self($type, $parameters);
}
return null;
}
/**
* Get the disposition type.
*/
public function type(): ContentDispositionType
{
return $this->type;
}
/**
* Get the disposition parameters.
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get a specific parameter value.
*/
public function parameter(string $name): ?string
{
return $this->parameters[strtolower($name)] ?? null;
}
/**
* Get the filename parameter.
*/
public function filename(): ?string
{
return $this->parameters['filename'] ?? null;
}
/**
* Determine if this is an attachment disposition.
*/
public function isAttachment(): bool
{
return $this->type === ContentDispositionType::Attachment;
}
/**
* Determine if this is an inline disposition.
*/
public function isInline(): bool
{
return $this->type === ContentDispositionType::Inline;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'type' => $this->type->value,
'parameters' => $this->parameters,
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,99 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DateTimeInterface;
use Stringable;
use Symfony\Component\Mime\Email;
class DraftMessage implements Stringable
{
/**
* The underlying Symfony Email instance.
*/
protected Email $message;
/**
* Constructor.
*/
public function __construct(
protected ?string $from = null,
protected array|string $to = [],
protected array|string $cc = [],
protected array|string $bcc = [],
protected ?string $subject = null,
protected ?string $text = null,
protected ?string $html = null,
protected array $headers = [],
protected array $attachments = [],
protected ?DateTimeInterface $date = null,
) {
$this->message = new Email;
if ($this->from) {
$this->message->from($this->from);
}
if ($this->subject) {
$this->message->subject($this->subject);
}
if ($this->text) {
$this->message->text($this->text);
}
if ($this->html) {
$this->message->html($this->html);
}
if ($this->date) {
$this->message->date($this->date);
}
if (! empty($this->to)) {
$this->message->to(...(array) $this->to);
}
if (! empty($this->cc)) {
$this->message->cc(...(array) $this->cc);
}
if (! empty($this->bcc)) {
$this->message->bcc(...(array) $this->bcc);
}
foreach ($this->attachments as $attachment) {
match (true) {
$attachment instanceof Attachment => $this->message->attach(
$attachment->contents(),
$attachment->filename(),
$attachment->contentType()
),
is_resource($attachment) => $this->message->attach($attachment),
default => $this->message->attachFromPath($attachment),
};
}
foreach ($this->headers as $name => $value) {
$this->message->getHeaders()->addTextHeader($name, $value);
}
}
/**
* Get the underlying Symfony Email instance.
*/
public function email(): Email
{
return $this->message;
}
/**
* Get the email as a string.
*/
public function __toString(): string
{
return $this->message->toString();
}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
/**
* @see https://datatracker.ietf.org/doc/html/rfc2183
*/
enum ContentDispositionType: string
{
case Inline = 'inline';
case Attachment = 'attachment';
}

View File

@@ -1,9 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapFetchIdentifier
{
case Uid;
case MessageNumber;
}

View File

@@ -1,13 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapFlag: string
{
case Seen = '\Seen';
case Draft = '\Draft';
case Recent = '\Recent';
case Flagged = '\Flagged';
case Deleted = '\Deleted';
case Answered = '\Answered';
}

View File

@@ -1,39 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapSearchKey: string
{
case Cc = 'CC';
case On = 'ON';
case To = 'TO';
case All = 'ALL';
case New = 'NEW';
case Old = 'OLD';
case Bcc = 'BCC';
case Uid = 'UID';
case Seen = 'SEEN';
case Body = 'BODY';
case From = 'FROM';
case Text = 'TEXT';
case Draft = 'DRAFT';
case Since = 'SINCE';
case SentOn = 'SENTON';
case SentSince = 'SENTSINCE';
case SentBefore = 'SENTBEFORE';
case Recent = 'RECENT';
case Unseen = 'UNSEEN';
case Before = 'BEFORE';
case Header = 'HEADER';
case Larger = 'LARGER';
case Deleted = 'DELETED';
case Flagged = 'FLAGGED';
case Keyword = 'KEYWORD';
case Unkeyword = 'UNKEYWORD';
case Subject = 'SUBJECT';
case Smaller = 'SMALLER';
case Answered = 'ANSWERED';
case Undeleted = 'UNDELETED';
case Unflagged = 'UNFLAGGED';
case Unanswered = 'UNANSWERED';
}

View File

@@ -1,14 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapSortKey: string
{
case Cc = 'CC';
case To = 'TO';
case Date = 'DATE';
case From = 'FROM';
case Size = 'SIZE';
case Arrival = 'ARRIVAL';
case Subject = 'SUBJECT';
}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class Exception extends \Exception {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapCapabilityException extends Exception {}

View File

@@ -1,48 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
use DirectoryTree\ImapEngine\Connection\ImapCommand;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
class ImapCommandException extends Exception
{
/**
* The IMAP response.
*/
protected Response $response;
/**
* The failed IMAP command.
*/
protected ImapCommand $command;
/**
* Make a new instance from a failed command and response.
*/
public static function make(ImapCommand $command, Response $response): static
{
$exception = new static(sprintf('IMAP command "%s" failed. Response: "%s"', $command, $response));
$exception->command = $command;
$exception->response = $response;
return $exception;
}
/**
* Get the failed IMAP command.
*/
public function command(): ImapCommand
{
return $this->command;
}
/**
* Get the IMAP response.
*/
public function response(): Response
{
return $this->response;
}
}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapConnectionClosedException extends ImapConnectionException {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
abstract class ImapConnectionException extends Exception {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapConnectionFailedException extends ImapConnectionException {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapConnectionTimedOutException extends ImapConnectionException {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapParserException extends Exception {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapResponseException extends Exception {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapStreamException extends Exception {}

View File

@@ -1,5 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class RuntimeException extends \RuntimeException {}

View File

@@ -1,99 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use BadMethodCallException;
class FileMessage implements MessageInterface
{
use HasFlags, HasParsedMessage;
/**
* Constructor.
*/
public function __construct(
protected string $contents
) {}
/**
* {@inheritDoc}
*/
public function uid(): int
{
throw new BadMethodCallException('FileMessage does not support a UID');
}
/**
* {@inheritDoc}
*/
public function size(): ?int
{
return strlen($this->contents);
}
/**
* {@inheritDoc}
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void
{
throw new BadMethodCallException('FileMessage does not support flagging');
}
/**
* Get the string representation of the message.
*/
public function __toString(): string
{
return $this->contents;
}
/**
* Determine if this message is equal to another.
*/
public function is(MessageInterface $message): bool
{
return $message instanceof self
&& $this->contents === $message->contents;
}
/**
* Get the message flags.
*/
public function flags(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function bodyStructure(): ?BodyStructureCollection
{
return null;
}
/**
* {@inheritDoc}
*/
public function hasBodyStructure(): bool
{
return false;
}
/**
* {@inheritDoc}
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string
{
throw new BadMethodCallException('FileMessage does not support fetching body parts');
}
/**
* Determine if the message is empty.
*/
public function isEmpty(): bool
{
return empty($this->contents);
}
}

View File

@@ -1,127 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
interface FlaggableInterface
{
/**
* Mark the message as read. Alias for markSeen.
*/
public function markRead(): void;
/**
* Mark the message as unread. Alias for unmarkSeen.
*/
public function markUnread(): void;
/**
* Mark the message as seen.
*/
public function markSeen(): void;
/**
* Unmark the seen flag.
*/
public function unmarkSeen(): void;
/**
* Mark the message as answered.
*/
public function markAnswered(): void;
/**
* Unmark the answered flag.
*/
public function unmarkAnswered(): void;
/**
* Mark the message as flagged.
*/
public function markFlagged(): void;
/**
* Unmark the flagged flag.
*/
public function unmarkFlagged(): void;
/**
* Mark the message as deleted.
*/
public function markDeleted(bool $expunge = false): void;
/**
* Unmark the deleted flag.
*/
public function unmarkDeleted(): void;
/**
* Mark the message as a draft.
*/
public function markDraft(): void;
/**
* Unmark the draft flag.
*/
public function unmarkDraft(): void;
/**
* Mark the message as recent.
*/
public function markRecent(): void;
/**
* Unmark the recent flag.
*/
public function unmarkRecent(): void;
/**
* Determine if the message is marked as seen.
*/
public function isSeen(): bool;
/**
* Determine if the message is marked as answered.
*/
public function isAnswered(): bool;
/**
* Determine if the message is flagged.
*/
public function isFlagged(): bool;
/**
* Determine if the message is marked as deleted.
*/
public function isDeleted(): bool;
/**
* Determine if the message is marked as a draft.
*/
public function isDraft(): bool;
/**
* Determine if the message is marked as recent.
*/
public function isRecent(): bool;
/**
* Get the message's flags.
*
* @return string[]
*/
public function flags(): array;
/**
* Determine if the message has the given flag.
*/
public function hasFlag(BackedEnum|string $flag): bool;
/**
* Add or remove a flag from the message.
*
* @param '+'|'-' $operation
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void;
}

View File

@@ -1,278 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Closure;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\ItemNotFoundException;
use JsonSerializable;
class Folder implements Arrayable, FolderInterface, JsonSerializable
{
use ComparesFolders;
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox,
protected string $path,
protected array $flags = [],
protected string $delimiter = '/',
) {}
/**
* Get the folder's mailbox.
*/
public function mailbox(): Mailbox
{
return $this->mailbox;
}
/**
* Get the folder path.
*/
public function path(): string
{
return $this->path;
}
/**
* Get the folder flags.
*
* @return string[]
*/
public function flags(): array
{
return $this->flags;
}
/**
* {@inheritDoc}
*/
public function delimiter(): string
{
return $this->delimiter;
}
/**
* {@inheritDoc}
*/
public function name(): string
{
return Str::fromImapUtf7(
last(explode($this->delimiter, $this->path))
);
}
/**
* {@inheritDoc}
*/
public function is(FolderInterface $folder): bool
{
return $this->isSameFolder($this, $folder);
}
/**
* {@inheritDoc}
*/
public function messages(): MessageQuery
{
// Ensure the folder is selected.
$this->select(true);
return new MessageQuery($this, new ImapQueryBuilder);
}
/**
* {@inheritDoc}
*/
public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void
{
if (! in_array('IDLE', $this->mailbox->capabilities())) {
throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.');
}
// Normalize timeout into a closure.
if (is_callable($timeout) && ! $timeout instanceof Closure) {
$timeout = $timeout(...);
}
// The message query to use when fetching messages.
$query ??= fn (MessageQuery $query) => $query;
// Fetch the message by message number.
$fetch = fn (int $msgn) => (
$query($this->messages())->findOrFail($msgn, ImapFetchIdentifier::MessageNumber)
);
(new Idle(clone $this->mailbox, $this->path, $timeout))->await(
function (int $msgn) use ($callback, $fetch) {
if (! $this->mailbox->connected()) {
$this->mailbox->connect();
}
try {
$message = $fetch($msgn);
} catch (ItemNotFoundException) {
// The message wasn't found. We will skip
// it and continue awaiting new messages.
return;
} catch (Exception) {
// Something else happened. We will attempt
// reconnecting and re-fetching the message.
$this->mailbox->reconnect();
$message = $fetch($msgn);
}
$callback($message);
}
);
}
/**
* {@inheritDoc}
*/
public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void
{
(new Poll(clone $this->mailbox, $this->path, $frequency))->start(
function (MessageInterface $message) use ($callback) {
if (! $this->mailbox->connected()) {
$this->mailbox->connect();
}
try {
$callback($message);
} catch (Exception) {
// Something unexpected happened. We will attempt
// reconnecting and continue polling for messages.
$this->mailbox->reconnect();
}
},
$query ?? fn (MessageQuery $query) => $query
);
}
/**
* {@inheritDoc}
*/
public function move(string $newPath): void
{
$this->mailbox->connection()->rename($this->path, $newPath);
$this->path = $newPath;
}
/**
* {@inheritDoc}
*/
public function select(bool $force = false): void
{
$this->mailbox->select($this, $force);
}
/**
* {@inheritDoc}
*/
public function quota(): array
{
if (! in_array('QUOTA', $this->mailbox->capabilities())) {
throw new ImapCapabilityException(
'Unable to fetch mailbox quotas. IMAP server does not support QUOTA capability.'
);
}
$responses = $this->mailbox->connection()->quotaRoot($this->path);
$values = [];
foreach ($responses as $response) {
$resource = $response->tokenAt(2);
$tokens = $response->tokenAt(3)->tokens();
for ($i = 0; $i + 2 < count($tokens); $i += 3) {
$values[$resource->value][$tokens[$i]->value] = [
'usage' => (int) $tokens[$i + 1]->value,
'limit' => (int) $tokens[$i + 2]->value,
];
}
}
return $values;
}
/**
* {@inheritDoc}
*/
public function status(): array
{
$response = $this->mailbox->connection()->status($this->path);
$tokens = $response->tokenAt(3)->tokens();
$values = [];
// Tokens are expected to alternate between keys and values.
for ($i = 0; $i < count($tokens); $i += 2) {
$values[$tokens[$i]->value] = $tokens[$i + 1]->value;
}
return $values;
}
/**
* {@inheritDoc}
*/
public function examine(): array
{
return $this->mailbox->connection()->examine($this->path)->map(
fn (UntaggedResponse $response) => $response->toArray()
)->all();
}
/**
* {@inheritDoc}
*/
public function expunge(): array
{
return $this->mailbox->connection()->expunge()->map(
fn (UntaggedResponse $response) => $response->tokenAt(1)->value
)->all();
}
/**
* {@inheritDoc}
*/
public function delete(): void
{
$this->mailbox->connection()->delete($this->path);
}
/**
* Get the array representation of the folder.
*/
public function toArray(): array
{
return [
'path' => $this->path,
'flags' => $this->flags,
'delimiter' => $this->delimiter,
];
}
/**
* Get the JSON representation of the folder.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,88 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
interface FolderInterface
{
/**
* Get the folder's mailbox.
*/
public function mailbox(): MailboxInterface;
/**
* Get the folder path.
*/
public function path(): string;
/**
* Get the folder flags.
*
* @return string[]
*/
public function flags(): array;
/**
* Get the folder delimiter.
*/
public function delimiter(): string;
/**
* Get the folder name.
*/
public function name(): string;
/**
* Determine if the current folder is the same as the given.
*/
public function is(FolderInterface $folder): bool;
/**
* Begin querying for messages.
*/
public function messages(): MessageQueryInterface;
/**
* Begin idling on the current folder for the given timeout in seconds.
*/
public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void;
/**
* Begin polling for new messages at the given frequency in seconds.
*/
public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void;
/**
* Move or rename the current folder.
*/
public function move(string $newPath): void;
/**
* Select the current folder.
*/
public function select(bool $force = false): void;
/**
* Get the folder's quotas.
*/
public function quota(): array;
/**
* Get the folder's status.
*/
public function status(): array;
/**
* Examine the current folder and get detailed status information.
*/
public function examine(): array;
/**
* Expunge the mailbox and return the expunged message sequence numbers.
*/
public function expunge(): array;
/**
* Delete the current folder.
*/
public function delete(): void;
}

View File

@@ -1,68 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Collections\FolderCollection;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Support\Str;
class FolderRepository implements FolderRepositoryInterface
{
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox
) {}
/**
* {@inheritDoc}
*/
public function find(string $path): ?FolderInterface
{
return $this->get($path)->first();
}
/**
* {@inheritDoc}
*/
public function findOrFail(string $path): FolderInterface
{
return $this->get($path)->firstOrFail();
}
/**
* {@inheritDoc}
*/
public function create(string $path): FolderInterface
{
$this->mailbox->connection()->create(
Str::toImapUtf7($path)
);
return $this->find($path);
}
/**
* {@inheritDoc}
*/
public function firstOrCreate(string $path): FolderInterface
{
return $this->find($path) ?? $this->create($path);
}
/**
* {@inheritDoc}
*/
public function get(?string $match = '*', ?string $reference = ''): FolderCollection
{
return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map(
fn (UntaggedResponse $response) => new Folder(
mailbox: $this->mailbox,
path: $response->tokenAt(4)->value,
flags: $response->tokenAt(2)->values(),
delimiter: $response->tokenAt(3)->value,
)
)->pipeInto(FolderCollection::class);
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Collections\FolderCollection;
interface FolderRepositoryInterface
{
/**
* Find a folder.
*/
public function find(string $path): ?FolderInterface;
/**
* Find a folder or throw an exception.
*/
public function findOrFail(string $path): FolderInterface;
/**
* Create a new folder.
*/
public function create(string $path): FolderInterface;
/**
* Find or create a folder.
*/
public function firstOrCreate(string $path): FolderInterface;
/**
* Get the mailboxes folders.
*/
public function get(?string $match = '*', ?string $reference = ''): FolderCollection;
}

View File

@@ -1,188 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Enums\ImapFlag;
use DirectoryTree\ImapEngine\Support\Str;
trait HasFlags
{
/**
* {@inheritDoc}
*/
public function markRead(): void
{
$this->markSeen();
}
/**
* {@inheritDoc}
*/
public function markUnread(): void
{
$this->unmarkSeen();
}
/**
* {@inheritDoc}
*/
public function markSeen(): void
{
$this->flag(ImapFlag::Seen, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkSeen(): void
{
$this->flag(ImapFlag::Seen, '-');
}
/**
* {@inheritDoc}
*/
public function markAnswered(): void
{
$this->flag(ImapFlag::Answered, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkAnswered(): void
{
$this->flag(ImapFlag::Answered, '-');
}
/**
* {@inheritDoc}
*/
public function markFlagged(): void
{
$this->flag(ImapFlag::Flagged, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkFlagged(): void
{
$this->flag(ImapFlag::Flagged, '-');
}
/**
* {@inheritDoc}
*/
public function markDeleted(bool $expunge = false): void
{
$this->flag(ImapFlag::Deleted, '+', $expunge);
}
/**
* {@inheritDoc}
*/
public function unmarkDeleted(): void
{
$this->flag(ImapFlag::Deleted, '-');
}
/**
* {@inheritDoc}
*/
public function markDraft(): void
{
$this->flag(ImapFlag::Draft, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkDraft(): void
{
$this->flag(ImapFlag::Draft, '-');
}
/**
* {@inheritDoc}
*/
public function markRecent(): void
{
$this->flag(ImapFlag::Recent, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkRecent(): void
{
$this->flag(ImapFlag::Recent, '-');
}
/**
* {@inheritDoc}
*/
public function isSeen(): bool
{
return $this->hasFlag(ImapFlag::Seen);
}
/**
* {@inheritDoc}
*/
public function isAnswered(): bool
{
return $this->hasFlag(ImapFlag::Answered);
}
/**
* {@inheritDoc}
*/
public function isFlagged(): bool
{
return $this->hasFlag(ImapFlag::Flagged);
}
/**
* {@inheritDoc}
*/
public function isDeleted(): bool
{
return $this->hasFlag(ImapFlag::Deleted);
}
/**
* {@inheritDoc}
*/
public function isDraft(): bool
{
return $this->hasFlag(ImapFlag::Draft);
}
/**
* {@inheritDoc}
*/
public function isRecent(): bool
{
return $this->hasFlag(ImapFlag::Recent);
}
/**
* {@inheritDoc}
*/
public function hasFlag(BackedEnum|string $flag): bool
{
return in_array(Str::enum($flag), $this->flags());
}
/**
* {@inheritDoc}
*/
abstract public function flags(): array;
/**
* {@inheritDoc}
*/
abstract public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void;
}

View File

@@ -1,258 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use GuzzleHttp\Psr7\Utils;
use ZBateson\MailMimeParser\Header\DateHeader;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\AddressPart;
use ZBateson\MailMimeParser\Header\Part\ContainerPart;
use ZBateson\MailMimeParser\Header\Part\NameValuePart;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message\IMessagePart;
trait HasParsedMessage
{
/**
* The parsed message.
*/
protected ?IMessage $parsed = null;
/**
* Get the message date and time.
*/
public function date(): ?CarbonInterface
{
if (! $header = $this->header(HeaderConsts::DATE)) {
return null;
}
if (! $header instanceof DateHeader) {
return null;
}
if (! $date = $header->getDateTime()) {
return null;
}
return Carbon::instance($date);
}
/**
* Get the message's message-id.
*/
public function messageId(): ?string
{
return $this->header(HeaderConsts::MESSAGE_ID)?->getValue();
}
/**
* Get the message's subject.
*/
public function subject(): ?string
{
return $this->header(HeaderConsts::SUBJECT)?->getValue();
}
/**
* Get the FROM address.
*/
public function from(): ?Address
{
return head($this->addresses(HeaderConsts::FROM)) ?: null;
}
/**
* Get the SENDER address.
*/
public function sender(): ?Address
{
return head($this->addresses(HeaderConsts::SENDER)) ?: null;
}
/**
* Get the REPLY-TO address.
*/
public function replyTo(): ?Address
{
return head($this->addresses(HeaderConsts::REPLY_TO)) ?: null;
}
/**
* Get the IN-REPLY-TO message identifier(s).
*
* @return string[]
*/
public function inReplyTo(): array
{
$parts = $this->header(HeaderConsts::IN_REPLY_TO)?->getParts() ?? [];
$values = array_map(function (IHeaderPart $part) {
return $part->getValue();
}, $parts);
return array_values(array_filter($values));
}
/**
* Get the TO addresses.
*
* @return Address[]
*/
public function to(): array
{
return $this->addresses(HeaderConsts::TO);
}
/**
* Get the CC addresses.
*
* @return Address[]
*/
public function cc(): array
{
return $this->addresses(HeaderConsts::CC);
}
/**
* Get the BCC addresses.
*
* @return Address[]
*/
public function bcc(): array
{
return $this->addresses(HeaderConsts::BCC);
}
/**
* Get the message's attachments.
*
* @return Attachment[]
*/
public function attachments(): array
{
$attachments = [];
foreach ($this->parse()->getAllAttachmentParts() as $part) {
if ($this->isForwardedMessage($part)) {
$message = new FileMessage($part->getContent());
$attachments = array_merge($attachments, $message->attachments());
} else {
$attachments[] = new Attachment(
$part->getFilename(),
$part->getContentId(),
$part->getContentType(),
$part->getContentDisposition(),
$part->getBinaryContentStream() ?? Utils::streamFor(''),
);
}
}
return $attachments;
}
/**
* Determine if the message has attachments.
*/
public function hasAttachments(): bool
{
return $this->attachmentCount() > 0;
}
/**
* Get the count of attachments.
*/
public function attachmentCount(): int
{
return $this->parse()->getAttachmentCount();
}
/**
* Determine if the attachment should be treated as an embedded forwarded message.
*/
protected function isForwardedMessage(IMessagePart $part): bool
{
return empty($part->getFilename())
&& strtolower((string) $part->getContentType()) === 'message/rfc822'
&& strtolower((string) $part->getContentDisposition()) !== 'attachment';
}
/**
* Get addresses from the given header.
*
* @return Address[]
*/
public function addresses(string $header): array
{
$parts = $this->header($header)?->getParts() ?? [];
$addresses = array_map(fn (IHeaderPart $part) => match (true) {
$part instanceof AddressPart => new Address($part->getEmail(), $part->getName()),
$part instanceof NameValuePart => new Address($part->getName(), $part->getValue()),
$part instanceof ContainerPart => new Address($part->getValue(), ''),
default => null,
}, $parts);
return array_filter($addresses);
}
/**
* Get the message's HTML content.
*/
public function html(): ?string
{
return $this->parse()->getHtmlContent();
}
/**
* Get the message's text content.
*/
public function text(): ?string
{
return $this->parse()->getTextContent();
}
/**
* Get all headers from the message.
*/
public function headers(): array
{
return $this->parse()->getAllHeaders();
}
/**
* Get a header from the message.
*/
public function header(string $name, int $offset = 0): ?IHeader
{
return $this->parse()->getHeader($name, $offset);
}
/**
* Parse the message into a MailMimeMessage instance.
*/
public function parse(): IMessage
{
if ($this->isEmpty()) {
throw new RuntimeException('Cannot parse an empty message');
}
return $this->parsed ??= MessageParser::parse((string) $this);
}
/**
* Determine if the message is empty.
*/
abstract public function isEmpty(): bool;
/**
* Get the string representation of the message.
*/
abstract public function __toString(): string;
}

View File

@@ -1,173 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Closure;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use Generator;
class Idle
{
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox,
protected string $folder,
protected Closure|int $timeout,
) {}
/**
* Destructor.
*/
public function __destruct()
{
$this->disconnect();
}
/**
* Await new messages on the connection.
*/
public function await(callable $callback): void
{
$this->connect();
while ($ttl = $this->getNextTimeout()) {
try {
$this->listen($callback, $ttl);
} catch (ImapConnectionTimedOutException) {
$this->restart();
} catch (ImapConnectionClosedException) {
$this->reconnect();
}
}
}
/**
* Start listening for new messages using the idle() generator.
*/
protected function listen(callable $callback, CarbonInterface $ttl): void
{
// Iterate over responses yielded by the idle generator.
foreach ($this->idle($ttl) as $response) {
if (! $response instanceof UntaggedResponse) {
continue;
}
if (! $token = $response->tokenAt(2)) {
continue;
}
if ($token instanceof Atom && $token->is('EXISTS')) {
$msgn = (int) $response->tokenAt(1)->value;
$callback($msgn);
$ttl = $this->getNextTimeout();
}
if ($ttl === false) {
break;
}
// If we've been idle too long, break out to restart the session.
if (Carbon::now()->greaterThanOrEqualTo($ttl)) {
$this->restart();
break;
}
}
}
/**
* Get the folder to idle.
*/
protected function folder(): FolderInterface
{
return $this->mailbox->folders()->findOrFail($this->folder);
}
/**
* Issue a done command and restart the idle session.
*/
protected function restart(): void
{
try {
// Send DONE to terminate the current IDLE session gracefully.
$this->done();
} catch (Exception) {
$this->reconnect();
}
}
/**
* Reconnect the client and restart the idle session.
*/
protected function reconnect(): void
{
$this->mailbox->disconnect();
$this->connect();
}
/**
* Connect the client and select the folder to idle.
*/
protected function connect(): void
{
$this->mailbox->connect();
$this->mailbox->select($this->folder(), true);
}
/**
* Disconnect the client.
*/
protected function disconnect(): void
{
try {
// Attempt to terminate IDLE gracefully.
$this->done();
} catch (Exception) {
// Do nothing.
}
$this->mailbox->disconnect();
}
/**
* End the current IDLE session.
*/
protected function done(): void
{
$this->mailbox->connection()->done();
}
/**
* Begin a new IDLE session as a generator.
*/
protected function idle(CarbonInterface $ttl): Generator
{
yield from $this->mailbox->connection()->idle(
(int) Carbon::now()->diffInSeconds($ttl, true)
);
}
/**
* Get the next timeout as a Carbon instance.
*/
protected function getNextTimeout(): CarbonInterface|false
{
if (is_numeric($seconds = value($this->timeout))) {
return Carbon::now()->addSeconds(abs($seconds));
}
return false;
}
}

View File

@@ -1,232 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\ImapConnection;
use DirectoryTree\ImapEngine\Connection\Loggers\EchoLogger;
use DirectoryTree\ImapEngine\Connection\Loggers\FileLogger;
use DirectoryTree\ImapEngine\Connection\Streams\ImapStream;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Exception;
class Mailbox implements MailboxInterface
{
/**
* The mailbox configuration.
*/
protected array $config = [
'port' => 993,
'host' => '',
'timeout' => 30,
'debug' => false,
'username' => '',
'password' => '',
'encryption' => 'ssl',
'validate_cert' => true,
'authentication' => 'plain',
'proxy' => [
'socket' => null,
'username' => null,
'password' => null,
'request_fulluri' => false,
],
];
/**
* The cached mailbox capabilities.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.1.1
*/
protected ?array $capabilities = null;
/**
* The currently selected folder.
*/
protected ?FolderInterface $selected = null;
/**
* The mailbox connection.
*/
protected ?ConnectionInterface $connection = null;
/**
* Constructor.
*/
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
}
/**
* Prepare the cloned instance.
*/
public function __clone(): void
{
$this->connection = null;
}
/**
* Make a new mailbox instance.
*/
public static function make(array $config = []): static
{
return new static($config);
}
/**
* {@inheritDoc}
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if (is_null($key)) {
return $this->config;
}
return data_get($this->config, $key, $default);
}
/**
* {@inheritDoc}
*/
public function connection(): ConnectionInterface
{
if (! $this->connection) {
$this->connect();
}
return $this->connection;
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return (bool) $this->connection?->connected();
}
/**
* {@inheritDoc}
*/
public function reconnect(): void
{
$this->disconnect();
$this->connect();
}
/**
* {@inheritDoc}
*/
public function connect(?ConnectionInterface $connection = null): void
{
if ($this->connected()) {
return;
}
$debug = $this->config('debug');
$this->connection = $connection ?? new ImapConnection(new ImapStream, match (true) {
class_exists($debug) => new $debug,
is_string($debug) => new FileLogger($debug),
is_bool($debug) && $debug => new EchoLogger,
default => null,
});
$this->connection->connect($this->config('host'), $this->config('port'), [
'proxy' => $this->config('proxy'),
'debug' => $this->config('debug'),
'timeout' => $this->config('timeout'),
'encryption' => $this->config('encryption'),
'validate_cert' => $this->config('validate_cert'),
]);
$this->authenticate();
}
/**
* Authenticate the current session.
*/
protected function authenticate(): void
{
if ($this->config('authentication') === 'oauth') {
$this->connection->authenticate(
$this->config('username'),
$this->config('password')
);
} else {
$this->connection->login(
$this->config('username'),
$this->config('password'),
);
}
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
try {
$this->connection?->logout();
$this->connection?->disconnect();
} catch (Exception) {
// Do nothing.
} finally {
$this->connection = null;
}
}
/**
* {@inheritDoc}
*/
public function inbox(): FolderInterface
{
// "INBOX" is a special name reserved for the user's primary mailbox.
// See: https://datatracker.ietf.org/doc/html/rfc9051#section-5.1
return $this->folders()->find('INBOX');
}
/**
* {@inheritDoc}
*/
public function folders(): FolderRepositoryInterface
{
// Ensure the connection is established.
$this->connection();
return new FolderRepository($this);
}
/**
* {@inheritDoc}
*/
public function capabilities(): array
{
return $this->capabilities ??= array_map(
fn (Token $token) => $token->value,
$this->connection()->capability()->tokensAfter(2)
);
}
/**
* {@inheritDoc}
*/
public function select(FolderInterface $folder, bool $force = false): void
{
if (! $this->selected($folder) || $force) {
$this->connection()->select($folder->path());
}
$this->selected = $folder;
}
/**
* {@inheritDoc}
*/
public function selected(FolderInterface $folder): bool
{
return $this->selected?->is($folder) ?? false;
}
}

View File

@@ -1,63 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
interface MailboxInterface
{
/**
* Get mailbox configuration values.
*/
public function config(?string $key = null, mixed $default = null): mixed;
/**
* Get the mailbox connection.
*/
public function connection(): ConnectionInterface;
/**
* Determine if connection was established.
*/
public function connected(): bool;
/**
* Force a reconnection to the server.
*/
public function reconnect(): void;
/**
* Connect to the server.
*/
public function connect(?ConnectionInterface $connection = null): void;
/**
* Disconnect from server.
*/
public function disconnect(): void;
/**
* Get the mailbox's inbox folder.
*/
public function inbox(): FolderInterface;
/**
* Begin querying for mailbox folders.
*/
public function folders(): FolderRepositoryInterface;
/**
* Get the mailbox's capabilities.
*/
public function capabilities(): array;
/**
* Select the given folder.
*/
public function select(FolderInterface $folder, bool $force = false): void;
/**
* Determine if the given folder is selected.
*/
public function selected(FolderInterface $folder): bool;
}

View File

@@ -1,50 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use Generator;
class Mbox
{
/**
* Constructor.
*/
public function __construct(
protected string $filepath
) {}
/**
* Get the messages from the mbox file.
*/
public function messages(
string $delimiter = '/^From\s+\S+\s+' // From
.'(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+' // Day
.'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+' // Month
.'\d{1,2}\s+\d{2}:\d{2}:\d{2}' // Time (HH:MM:SS)
.'(?:\s+[+-]\d{4})?' // Optional Timezone ("+0000")
.'\s+\d{4}/' // Year
): Generator {
if (! $handle = fopen($this->filepath, 'r')) {
throw new RuntimeException('Failed to open mbox file: '.$this->filepath);
}
$buffer = '';
while (($line = fgets($handle)) !== false) {
if (preg_match($delimiter, $line) && $buffer !== '') {
yield new FileMessage($buffer);
$buffer = '';
}
$buffer .= $line;
}
if ($buffer !== '') {
yield new FileMessage($buffer);
}
fclose($handle);
}
}

View File

@@ -1,299 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class Message implements Arrayable, JsonSerializable, MessageInterface
{
use HasFlags, HasParsedMessage;
/**
* The parsed body structure.
*/
protected ?BodyStructureCollection $bodyStructure = null;
/**
* Constructor.
*/
public function __construct(
protected FolderInterface $folder,
protected int $uid,
protected array $flags,
protected string $head,
protected string $body,
protected ?int $size = null,
protected ?ListData $bodyStructureData = null,
) {}
/**
* Get the names of properties that should be serialized.
*/
public function __sleep(): array
{
// We don't want to serialize the parsed message.
return ['folder', 'uid', 'flags', 'head', 'body', 'size'];
}
/**
* Get the message's folder.
*/
public function folder(): FolderInterface
{
return $this->folder;
}
/**
* Get the message's identifier.
*/
public function uid(): int
{
return $this->uid;
}
/**
* Get the message's size in bytes (RFC822.SIZE).
*/
public function size(): ?int
{
return $this->size;
}
/**
* Get the message's flags.
*/
public function flags(): array
{
return $this->flags;
}
/**
* Get the message's raw headers.
*/
public function head(): string
{
return $this->head;
}
/**
* Determine if the message has headers.
*/
public function hasHead(): bool
{
return ! empty($this->head);
}
/**
* Get the message's raw body.
*/
public function body(): string
{
return $this->body;
}
/**
* Determine if the message has contents.
*/
public function hasBody(): bool
{
return ! empty($this->body);
}
/**
* Get the message's body structure.
*/
public function bodyStructure(): ?BodyStructureCollection
{
if ($this->bodyStructure) {
return $this->bodyStructure;
}
if (! $tokens = $this->bodyStructureData?->tokens()) {
return null;
}
// If the first token is a list, it's a multipart message.
return $this->bodyStructure = head($tokens) instanceof ListData
? BodyStructureCollection::fromListData($this->bodyStructureData)
: new BodyStructureCollection(parts: [BodyStructurePart::fromListData($this->bodyStructureData)]);
}
/**
* Determine if the message has body structure data.
*/
public function hasBodyStructure(): bool
{
return (bool) $this->bodyStructureData;
}
/**
* {@inheritDoc}
*/
public function is(MessageInterface $message): bool
{
return $message instanceof self
&& $this->uid === $message->uid
&& $this->folder->is($message->folder);
}
/**
* Add or remove a flag from the message.
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void
{
$flag = Str::enum($flag);
$this->folder->mailbox()
->connection()
->store($flag, $this->uid, mode: $operation);
if ($expunge) {
$this->folder->expunge();
}
$this->flags = match ($operation) {
'+' => array_unique(array_merge($this->flags, [$flag])),
'-' => array_diff($this->flags, [$flag]),
};
}
/**
* Copy the message to the given folder.
*/
public function copy(string $folder): ?int
{
$mailbox = $this->folder->mailbox();
$capabilities = $mailbox->capabilities();
if (! in_array('UIDPLUS', $capabilities)) {
throw new ImapCapabilityException(
'Unable to copy message. IMAP server does not support UIDPLUS capability'
);
}
$response = $mailbox->connection()->copy($folder, $this->uid);
return MessageResponseParser::getUidFromCopy($response);
}
/**
* Move the message to the given folder.
*
* @throws ImapCapabilityException
*/
public function move(string $folder, bool $expunge = false): ?int
{
$mailbox = $this->folder->mailbox();
$capabilities = $mailbox->capabilities();
switch (true) {
case in_array('MOVE', $capabilities):
$response = $mailbox->connection()->move($folder, $this->uid);
if ($expunge) {
$this->folder->expunge();
}
return MessageResponseParser::getUidFromCopy($response);
case in_array('UIDPLUS', $capabilities):
$uid = $this->copy($folder);
$this->delete($expunge);
return $uid;
default:
throw new ImapCapabilityException(
'Unable to move message. IMAP server does not support MOVE or UIDPLUS capabilities'
);
}
}
/**
* Fetch a specific body part by part number.
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string
{
$response = $this->folder->mailbox()
->connection()
->bodyPart($partNumber, $this->uid, $peek);
if ($response->isEmpty()) {
return null;
}
$data = $response->first()->tokenAt(3);
if (! $data instanceof ListData) {
return null;
}
return $data->lookup("[$partNumber]")?->value;
}
/**
* Delete the message.
*/
public function delete(bool $expunge = false): void
{
$this->markDeleted($expunge);
}
/**
* Restore the message.
*/
public function restore(): void
{
$this->unmarkDeleted();
}
/**
* Get the array representation of the message.
*/
public function toArray(): array
{
return [
'uid' => $this->uid,
'flags' => $this->flags,
'head' => $this->head,
'body' => $this->body,
'size' => $this->size,
];
}
/**
* Get the string representation of the message.
*/
public function __toString(): string
{
return implode("\r\n\r\n", array_filter([
rtrim($this->head),
ltrim($this->body),
]));
}
/**
* Get the JSON representation of the message.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Determine if the message is empty.
*/
public function isEmpty(): bool
{
return ! $this->hasHead() && ! $this->hasBody();
}
}

View File

@@ -1,154 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Carbon\CarbonInterface;
use Stringable;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message as MailMimeMessage;
interface MessageInterface extends FlaggableInterface, Stringable
{
/**
* Get the message's identifier.
*/
public function uid(): int;
/**
* Get the message's size in bytes (RFC822.SIZE).
*/
public function size(): ?int;
/**
* Get the message date and time.
*/
public function date(): ?CarbonInterface;
/**
* Get the message's subject.
*/
public function subject(): ?string;
/**
* Get the 'From' address.
*/
public function from(): ?Address;
/**
* Get the 'Sender' address.
*/
public function sender(): ?Address;
/**
* Get the message's 'Message-ID'.
*/
public function messageId(): ?string;
/**
* Get the 'Reply-To' address.
*/
public function replyTo(): ?Address;
/**
* Get the 'In-Reply-To' message identifier(s).
*
* @return string[]
*/
public function inReplyTo(): array;
/**
* Get the 'To' addresses.
*
* @return Address[]
*/
public function to(): array;
/**
* Get the 'CC' addresses.
*
* @return Address[]
*/
public function cc(): array;
/**
* Get the 'BCC' addresses.
*
* @return Address[]
*/
public function bcc(): array;
/**
* Get the message's attachments.
*
* @return Attachment[]
*/
public function attachments(): array;
/**
* Determine if the message has attachments.
*/
public function hasAttachments(): bool;
/**
* Get the count of attachments.
*/
public function attachmentCount(): int;
/**
* Get addresses from the given header.
*
* @return Address[]
*/
public function addresses(string $header): array;
/**
* Get the message's HTML content.
*/
public function html(): ?string;
/**
* Get the message's text content.
*/
public function text(): ?string;
/**
* Get all headers from the message.
*/
public function headers(): array;
/**
* Get a header from the message.
*/
public function header(string $name, int $offset = 0): ?IHeader;
/**
* Parse the message into a MailMimeMessage instance.
*/
public function parse(): IMessage;
/**
* Get the message's body structure.
*/
public function bodyStructure(): ?BodyStructureCollection;
/**
* Determine if the message has body structure data.
*/
public function hasBodyStructure(): bool;
/**
* Fetch a specific body part by part number.
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string;
/**
* Determine if the message is the same as another message.
*/
public function is(MessageInterface $message): bool;
/**
* Get the string representation of the message.
*/
public function __toString(): string;
}

View File

@@ -1,30 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\MailMimeParser;
class MessageParser
{
/**
* The mail mime parser instance.
*/
protected static ?MailMimeParser $parser = null;
/**
* Parse the given message contents.
*/
public static function parse(string $contents): IMessage
{
return static::parser()->parse($contents, true);
}
/**
* Get the mail mime parser instance.
*/
protected static function parser(): MailMimeParser
{
return static::$parser ??= new MailMimeParser;
}
}

View File

@@ -1,521 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapFlag;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Support\ItemNotFoundException;
/**
* @mixin \DirectoryTree\ImapEngine\Connection\ImapQueryBuilder
*/
class MessageQuery implements MessageQueryInterface
{
use QueriesMessages;
/**
* Constructor.
*/
public function __construct(
protected FolderInterface $folder,
protected ImapQueryBuilder $query,
) {}
/**
* Count all available messages matching the current search criteria.
*/
public function count(): int
{
return $this->search()->count();
}
/**
* Get the first message in the resulting collection.
*/
public function first(): ?MessageInterface
{
try {
return $this->firstOrFail();
} catch (ItemNotFoundException) {
return null;
}
}
/**
* Get the first message in the resulting collection or throw an exception.
*/
public function firstOrFail(): MessageInterface
{
return $this->limit(1)->get()->firstOrFail();
}
/**
* Get the messages matching the current query.
*/
public function get(): MessageCollection
{
return $this->process($this->sortKey ? $this->sort() : $this->search());
}
/**
* Append a new message to the folder.
*/
public function append(string $message, mixed $flags = null): int
{
$response = $this->connection()->append(
$this->folder->path(), $message, (array) Str::enums($flags),
);
return (int) $response // TAG4 OK [APPENDUID <uidvalidity> <uid>] APPEND completed.
->tokenAt(2) // [APPENDUID <uidvalidity> <uid>]
->tokenAt(2) // <uid>
->value;
}
/**
* Execute a callback over each message via a chunked query.
*/
public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
{
$this->chunk(function (MessageCollection $messages) use ($callback) {
foreach ($messages as $key => $message) {
if ($callback($message, $key) === false) {
return false;
}
}
}, $chunkSize, $startChunk);
}
/**
* Execute a callback over each chunk of messages.
*/
public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
{
$startChunk = max($startChunk, 1);
$chunkSize = max($chunkSize, 1);
// Get all search result tokens once.
$messages = $this->search();
// Calculate how many chunks there are
$totalChunks = (int) ceil($messages->count() / $chunkSize);
// If startChunk is beyond our total chunks, return early.
if ($startChunk > $totalChunks) {
return;
}
// Save previous state to restore later.
$previousLimit = $this->limit;
$previousPage = $this->page;
$this->limit = $chunkSize;
// Iterate from the starting chunk to the last chunk.
for ($page = $startChunk; $page <= $totalChunks; $page++) {
$this->page = $page;
// populate() will use $this->page to slice the results.
$hydrated = $this->populate($messages);
// If no messages are returned, break out to prevent infinite loop.
if ($hydrated->isEmpty()) {
break;
}
// If the callback returns false, break out.
if ($callback($hydrated, $page) === false) {
break;
}
}
// Restore the original state.
$this->limit = $previousLimit;
$this->page = $previousPage;
}
/**
* Paginate the current query.
*/
public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator
{
if (is_null($page) && isset($_GET[$pageName]) && $_GET[$pageName] > 0) {
$this->page = intval($_GET[$pageName]);
} elseif ($page > 0) {
$this->page = (int) $page;
}
$this->limit = $perPage;
return $this->get()->paginate($perPage, $this->page, $pageName, true);
}
/**
* Find a message by the given identifier type or throw an exception.
*/
public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface
{
/** @var UntaggedResponse $response */
$response = $this->id($id, $identifier)->firstOrFail();
$uid = $response->tokenAt(3) // ListData
->tokenAt(1) // Atom
->value; // UID
return $this->process(new MessageCollection([$uid]))->firstOrFail();
}
/**
* Find a message by the given identifier type.
*/
public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface
{
$response = $this->id($id, $identifier)->first();
if (! $response instanceof UntaggedResponse) {
return null;
}
$uid = $response->tokenAt(3) // ListData
->tokenAt(1) // Atom
->value; // UID
return $this->process(new MessageCollection([$uid]))->first();
}
/**
* Destroy the given messages.
*/
public function destroy(array|int $uids, bool $expunge = false): void
{
$uids = (array) $uids;
$this->folder->mailbox()
->connection()
->store([ImapFlag::Deleted->value], $uids, mode: '+');
if ($expunge) {
$this->folder->expunge();
}
}
/**
* {@inheritDoc}
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): int
{
$uids = $this->search()->all();
if (empty($uids)) {
return 0;
}
$this->connection()->store(
(array) Str::enums($flag),
$uids,
mode: $operation
);
if ($expunge) {
$this->folder->expunge();
}
return count($uids);
}
/**
* {@inheritDoc}
*/
public function markRead(): int
{
return $this->flag(ImapFlag::Seen, '+');
}
/**
* {@inheritDoc}
*/
public function markUnread(): int
{
return $this->flag(ImapFlag::Seen, '-');
}
/**
* {@inheritDoc}
*/
public function markFlagged(): int
{
return $this->flag(ImapFlag::Flagged, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkFlagged(): int
{
return $this->flag(ImapFlag::Flagged, '-');
}
/**
* {@inheritDoc}
*/
public function delete(bool $expunge = false): int
{
return $this->flag(ImapFlag::Deleted, '+', $expunge);
}
/**
* {@inheritDoc}
*/
public function move(string $folder, bool $expunge = false): int
{
$uids = $this->search()->all();
if (empty($uids)) {
return 0;
}
$this->connection()->move($folder, $uids);
if ($expunge) {
$this->folder->expunge();
}
return count($uids);
}
/**
* {@inheritDoc}
*/
public function copy(string $folder): int
{
$uids = $this->search()->all();
if (empty($uids)) {
return 0;
}
$this->connection()->copy($folder, $uids);
return count($uids);
}
/**
* Process the collection of messages.
*/
protected function process(Collection $messages): MessageCollection
{
if ($messages->isNotEmpty()) {
return $this->populate($messages);
}
return MessageCollection::make();
}
/**
* Populate a given id collection and receive a fully fetched message collection.
*/
protected function populate(Collection $uids): MessageCollection
{
$messages = MessageCollection::make();
$messages->total($uids->count());
foreach ($this->fetch($uids) as $uid => $response) {
$messages->push(
$this->newMessage(
$uid,
$response['flags'] ?? [],
$response['head'] ?? '',
$response['body'] ?? '',
$response['size'] ?? null,
$response['bodystructure'] ?? null,
)
);
}
return $messages;
}
/**
* Fetch a given id collection.
*/
protected function fetch(Collection $messages): array
{
// Only apply client-side sorting when not using server-side sorting.
// When sortKey is set, the IMAP SORT command already returns UIDs
// in the correct order, so we should preserve that order.
if (! $this->sortKey) {
$messages = match ($this->fetchOrder) {
'asc' => $messages->sort(SORT_NUMERIC),
'desc' => $messages->sortDesc(SORT_NUMERIC),
};
}
$uids = $messages->forPage($this->page, $this->limit)->values();
$fetch = [];
if ($this->fetchFlags) {
$fetch[] = 'FLAGS';
}
if ($this->fetchSize) {
$fetch[] = 'RFC822.SIZE';
}
if ($this->fetchHeaders) {
$fetch[] = $this->fetchAsUnread
? 'BODY.PEEK[HEADER]'
: 'BODY[HEADER]';
}
if ($this->fetchBody) {
$fetch[] = $this->fetchAsUnread
? 'BODY.PEEK[TEXT]'
: 'BODY[TEXT]';
}
if ($this->fetchBodyStructure) {
$fetch[] = 'BODYSTRUCTURE';
}
if (empty($fetch)) {
return $uids->mapWithKeys(fn (string|int $uid) => [
$uid => [
'size' => null,
'flags' => [],
'head' => '',
'body' => '',
'bodystructure' => null,
],
])->all();
}
return $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) {
$data = $response->tokenAt(3);
if (! $data instanceof ListData) {
throw new RuntimeException(sprintf(
'Expected instance of %s at index 3 in FETCH response, got %s',
ListData::class,
get_debug_type($data)
));
}
$uid = $data->lookup('UID')->value;
$size = $data->lookup('RFC822.SIZE')?->value;
return [
$uid => [
'size' => $size ? (int) $size : null,
'flags' => $data->lookup('FLAGS')?->values() ?? [],
'head' => $data->lookup('[HEADER]')->value ?? '',
'body' => $data->lookup('[TEXT]')->value ?? '',
'bodystructure' => $data->lookup('BODYSTRUCTURE'),
],
];
})->all();
}
/**
* Execute an IMAP search request.
*/
protected function search(): Collection
{
// If the query is empty, default to fetching all.
if ($this->query->isEmpty()) {
$this->query->all();
}
$response = $this->connection()->search([
$this->query->toImap(),
]);
return new Collection(array_map(
fn (Token $token) => $token->value,
$response->tokensAfter(2)
));
}
/**
* Execute an IMAP UID SORT request using RFC 5256.
*/
protected function sort(): Collection
{
if (! in_array('SORT', $this->folder->mailbox()->capabilities())) {
throw new ImapCapabilityException(
'Unable to sort messages. IMAP server does not support SORT capability.'
);
}
if ($this->query->isEmpty()) {
$this->query->all();
}
$response = $this->connection()->sort(
$this->sortKey,
$this->sortDirection,
[$this->query->toImap()]
);
return new Collection(array_map(
fn (Token $token) => $token->value,
$response->tokensAfter(2)
));
}
/**
* Get the UID for the given identifier.
*/
protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
{
try {
return $this->connection()->uid([$id], $identifier);
} catch (ImapCommandException $e) {
// IMAP servers may return an error if the message number is not found.
// If the identifier being used is a message number, and the message
// number is in the command tokens, we can assume this has occurred
// and safely ignore the error and return an empty collection.
if (
$identifier === ImapFetchIdentifier::MessageNumber
&& in_array($id, $e->command()->tokens())
) {
return ResponseCollection::make();
}
// Otherwise, re-throw the exception.
throw $e;
}
}
/**
* Make a new message from given raw components.
*/
protected function newMessage(int $uid, array $flags, string $head, string $body, ?int $size = null, ?ListData $bodystructure = null): Message
{
return new Message($this->folder, $uid, $flags, $head, $body, $size, $bodystructure);
}
/**
* Get the connection instance.
*/
protected function connection(): ConnectionInterface
{
return $this->folder->mailbox()->connection();
}
}

View File

@@ -1,297 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
/**
* @mixin \DirectoryTree\ImapEngine\Connection\ImapQueryBuilder
*/
interface MessageQueryInterface
{
/**
* Don't mark messages as read when fetching.
*/
public function leaveUnread(): MessageQueryInterface;
/**
* Mark all messages as read when fetching.
*/
public function markAsRead(): MessageQueryInterface;
/**
* Set the limit and page for the current query.
*/
public function limit(int $limit, int $page = 1): MessageQueryInterface;
/**
* Get the set fetch limit.
*/
public function getLimit(): ?int;
/**
* Set the fetch limit.
*/
public function setLimit(int $limit): MessageQueryInterface;
/**
* Get the set page.
*/
public function getPage(): int;
/**
* Set the page.
*/
public function setPage(int $page): MessageQueryInterface;
/**
* Determine if the body of messages is being fetched.
*/
public function isFetchingBody(): bool;
/**
* Determine if the flags of messages is being fetched.
*/
public function isFetchingFlags(): bool;
/**
* Determine if the headers of messages is being fetched.
*/
public function isFetchingHeaders(): bool;
/**
* Determine if the size of messages is being fetched.
*/
public function isFetchingSize(): bool;
/**
* Determine if the body structure of messages is being fetched.
*/
public function isFetchingBodyStructure(): bool;
/**
* Fetch the flags of messages.
*/
public function withFlags(): MessageQueryInterface;
/**
* Fetch the body of messages.
*/
public function withBody(): MessageQueryInterface;
/**
* Fetch the headers of messages.
*/
public function withHeaders(): MessageQueryInterface;
/**
* Fetch the size of messages.
*/
public function withSize(): MessageQueryInterface;
/**
* Fetch the body structure of messages.
*/
public function withBodyStructure(): MessageQueryInterface;
/**
* Don't fetch the body of messages.
*/
public function withoutBody(): MessageQueryInterface;
/**
* Don't fetch the headers of messages.
*/
public function withoutHeaders(): MessageQueryInterface;
/**
* Don't fetch the flags of messages.
*/
public function withoutFlags(): MessageQueryInterface;
/**
* Don't fetch the size of messages.
*/
public function withoutSize(): MessageQueryInterface;
/**
* Don't fetch the body structure of messages.
*/
public function withoutBodyStructure(): MessageQueryInterface;
/**
* Set the fetch order.
*/
public function setFetchOrder(string $fetchOrder): MessageQueryInterface;
/**
* Get the fetch order.
*/
public function getFetchOrder(): string;
/**
* Set the fetch order to 'ascending'.
*/
public function setFetchOrderAsc(): MessageQueryInterface;
/**
* Set the fetch order to 'descending'.
*/
public function setFetchOrderDesc(): MessageQueryInterface;
/**
* Set the fetch order to show oldest messages first (ascending).
*/
public function oldest(): MessageQueryInterface;
/**
* Set the fetch order to show newest messages first (descending).
*/
public function newest(): MessageQueryInterface;
/**
* Set the sort key for server-side sorting (RFC 5256).
*/
public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface;
/**
* Get the sort key for server-side sorting.
*/
public function getSortKey(): ?ImapSortKey;
/**
* Set the sort direction for server-side sorting.
*/
public function setSortDirection(string $direction): MessageQueryInterface;
/**
* Get the sort direction for server-side sorting.
*/
public function getSortDirection(): string;
/**
* Sort messages by a field using server-side sorting (RFC 5256).
*/
public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface;
/**
* Sort messages by a field in descending order using server-side sorting.
*/
public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface;
/**
* Count all available messages matching the current search criteria.
*/
public function count(): int;
/**
* Get the first message in the resulting collection.
*/
public function first(): ?MessageInterface;
/**
* Get the first message in the resulting collection or throw an exception.
*/
public function firstOrFail(): MessageInterface;
/**
* Get the messages matching the current query.
*/
public function get(): MessageCollection;
/**
* Append a new message to the folder.
*/
public function append(string $message, mixed $flags = null): int;
/**
* Execute a callback over each message via a chunked query.
*/
public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void;
/**
* Execute a callback over each chunk of messages.
*/
public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void;
/**
* Paginate the current query.
*/
public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator;
/**
* Find a message by the given identifier type or throw an exception.
*/
public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface;
/**
* Find a message by the given identifier type.
*/
public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface;
/**
* Destroy the given messages.
*/
public function destroy(array|int $uids, bool $expunge = false): void;
/**
* Add or remove a flag from all messages matching the current query.
*
* @param string $operation '+'|'-'
* @return int The number of messages affected.
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): int;
/**
* Mark all messages matching the current query as read.
*
* @return int The number of messages affected.
*/
public function markRead(): int;
/**
* Mark all messages matching the current query as unread.
*
* @return int The number of messages affected.
*/
public function markUnread(): int;
/**
* Mark all messages matching the current query as flagged.
*
* @return int The number of messages affected.
*/
public function markFlagged(): int;
/**
* Unmark all messages matching the current query as flagged.
*
* @return int The number of messages affected.
*/
public function unmarkFlagged(): int;
/**
* Delete all messages matching the current query.
*
* @return int The number of messages affected.
*/
public function delete(bool $expunge = false): int;
/**
* Move all messages matching the current query to the given folder.
*
* @return int The number of messages affected.
*/
public function move(string $folder, bool $expunge = false): int;
/**
* Copy all messages matching the current query to the given folder.
*
* @return int The number of messages affected.
*/
public function copy(string $folder): int;
}

View File

@@ -1,181 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Pagination;
use DirectoryTree\ImapEngine\Support\ForwardsCalls;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use JsonSerializable;
/**
* @template TKey of array-key
* @template TValue
*
* @template-implements Arrayable<TKey, TValue>
*/
class LengthAwarePaginator implements Arrayable, JsonSerializable
{
use ForwardsCalls;
/**
* Constructor.
*/
public function __construct(
protected Collection $items,
protected int $total,
protected int $perPage,
protected int $currentPage = 1,
protected string $path = '',
protected array $query = [],
protected string $pageName = 'page',
) {
$this->currentPage = max($currentPage, 1);
$this->path = rtrim($path, '/');
}
/**
* Handle dynamic method calls on the paginator.
*/
public function __call(string $method, array $parameters): mixed
{
return $this->forwardCallTo($this->items, $method, $parameters);
}
/**
* Get the items being paginated.
*
* @return Collection<TKey, TValue>
*/
public function items(): Collection
{
return $this->items;
}
/**
* Get the total number of items.
*/
public function total(): int
{
return $this->total;
}
/**
* Get the number of items per page.
*/
public function perPage(): int
{
return $this->perPage;
}
/**
* Get the current page number.
*/
public function currentPage(): int
{
return $this->currentPage;
}
/**
* Get the last page (total pages).
*/
public function lastPage(): int
{
return (int) ceil($this->total / $this->perPage);
}
/**
* Determine if there are enough items to split into multiple pages.
*/
public function hasPages(): bool
{
return $this->total() > $this->perPage();
}
/**
* Determine if there is a next page.
*/
public function hasMorePages(): bool
{
return $this->currentPage() < $this->lastPage();
}
/**
* Generate the URL for a given page.
*/
public function url(int $page): string
{
$params = array_merge($this->query, [$this->pageName => $page]);
$queryString = http_build_query($params);
return $this->path.($queryString ? '?'.$queryString : '');
}
/**
* Get the URL for the next page, or null if none.
*/
public function nextPageUrl(): ?string
{
if ($this->hasMorePages()) {
return $this->url($this->currentPage() + 1);
}
return null;
}
/**
* Get the URL for the previous page, or null if none.
*/
public function previousPageUrl(): ?string
{
if ($this->currentPage() > 1) {
return $this->url($this->currentPage() - 1);
}
return null;
}
/**
* Get the array representation of the paginator.
*/
public function toArray(): array
{
return [
'path' => $this->path,
'total' => $this->total(),
'to' => $this->calculateTo(),
'per_page' => $this->perPage(),
'last_page' => $this->lastPage(),
'first_page_url' => $this->url(1),
'data' => $this->items()->toArray(),
'current_page' => $this->currentPage(),
'next_page_url' => $this->nextPageUrl(),
'prev_page_url' => $this->previousPageUrl(),
'last_page_url' => $this->url($this->lastPage()),
'from' => $this->total() ? ($this->currentPage() - 1) * $this->perPage() + 1 : null,
];
}
/**
* Calculate the "to" index for the current page.
*/
protected function calculateTo(): ?int
{
if (! $this->total()) {
return null;
}
$to = $this->currentPage() * $this->perPage();
return min($to, $this->total());
}
/**
* Get the JSON representation of the paginator.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -1,135 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use Closure;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
class Poll
{
/**
* The last seen message UID.
*/
protected ?int $lastSeenUid = null;
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox,
protected string $folder,
protected Closure|int $frequency,
) {}
/**
* Destructor.
*/
public function __destruct()
{
$this->disconnect();
}
/**
* Poll for new messages at a given frequency.
*/
public function start(callable $callback, callable $query): void
{
$this->connect();
while ($frequency = $this->getNextFrequency()) {
try {
$this->check($callback, $query);
} catch (ImapConnectionClosedException) {
$this->reconnect();
}
sleep($frequency);
}
}
/**
* Check for new messages since the last seen UID.
*/
protected function check(callable $callback, callable $query): void
{
$folder = $this->folder();
// If we don't have a last seen UID, we will fetch
// the last one in the folder as a starting point.
if (! $this->lastSeenUid) {
$this->lastSeenUid = $folder->messages()
->first()
?->uid() ?? 0;
return;
}
$query($folder->messages())
->uid($this->lastSeenUid + 1, INF)
->each(function (MessageInterface $message) use ($callback) {
// Avoid processing the same message twice on subsequent polls.
// Some IMAP servers will always return the last seen UID in
// the search results regardless of given UID search range.
if ($this->lastSeenUid === $message->uid()) {
return;
}
$callback($message);
$this->lastSeenUid = $message->uid();
});
}
/**
* Get the folder to poll.
*/
protected function folder(): FolderInterface
{
return $this->mailbox->folders()->findOrFail($this->folder);
}
/**
* Reconnect the client and restart the poll session.
*/
protected function reconnect(): void
{
$this->mailbox->disconnect();
$this->connect();
}
/**
* Connect the client and select the folder to poll.
*/
protected function connect(): void
{
$this->mailbox->connect();
$this->mailbox->select($this->folder(), true);
}
/**
* Disconnect the client.
*/
protected function disconnect(): void
{
try {
$this->mailbox->disconnect();
} catch (Exception) {
// Do nothing.
}
}
/**
* Get the next frequency in seconds.
*/
protected function getNextFrequency(): int|false
{
if (is_numeric($seconds = value($this->frequency))) {
return abs((int) $seconds);
}
return false;
}
}

View File

@@ -1,448 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Support\ForwardsCalls;
use Illuminate\Support\Traits\Conditionable;
trait QueriesMessages
{
use Conditionable, ForwardsCalls;
/**
* The query builder instance.
*/
protected ImapQueryBuilder $query;
/**
* The current page.
*/
protected int $page = 1;
/**
* The fetch limit.
*/
protected ?int $limit = null;
/**
* Whether to fetch the message body.
*/
protected bool $fetchBody = false;
/**
* Whether to fetch the message flags.
*/
protected bool $fetchFlags = false;
/**
* Whether to fetch the message headers.
*/
protected bool $fetchHeaders = false;
/**
* Whether to fetch the message size.
*/
protected bool $fetchSize = false;
/**
* Whether to fetch the message body structure.
*/
protected bool $fetchBodyStructure = false;
/**
* The fetch order.
*
* @var 'asc'|'desc'
*/
protected string $fetchOrder = 'desc';
/**
* Whether to leave messages fetched as unread by default.
*/
protected bool $fetchAsUnread = true;
/**
* The methods that should be returned from query builder.
*/
protected array $passthru = ['toimap', 'isempty'];
/**
* The sort key for server-side sorting (RFC 5256).
*/
protected ?ImapSortKey $sortKey = null;
/**
* The sort direction for server-side sorting.
*
* @var 'asc'|'desc'
*/
protected string $sortDirection = 'asc';
/**
* Handle dynamic method calls into the query builder.
*/
public function __call(string $method, array $parameters): mixed
{
if (in_array(strtolower($method), $this->passthru)) {
return $this->query->{$method}(...$parameters);
}
$this->forwardCallTo($this->query, $method, $parameters);
return $this;
}
/**
* {@inheritDoc}
*/
public function leaveUnread(): MessageQueryInterface
{
$this->fetchAsUnread = true;
return $this;
}
/**
* {@inheritDoc}
*/
public function markAsRead(): MessageQueryInterface
{
$this->fetchAsUnread = false;
return $this;
}
/**
* {@inheritDoc}
*/
public function limit(int $limit, int $page = 1): MessageQueryInterface
{
if ($page >= 1) {
$this->page = $page;
}
$this->limit = $limit;
return $this;
}
/**
* {@inheritDoc}
*/
public function getLimit(): ?int
{
return $this->limit;
}
/**
* {@inheritDoc}
*/
public function setLimit(int $limit): MessageQueryInterface
{
$this->limit = max($limit, 1);
return $this;
}
/**
* {@inheritDoc}
*/
public function getPage(): int
{
return $this->page;
}
/**
* {@inheritDoc}
*/
public function setPage(int $page): MessageQueryInterface
{
$this->page = $page;
return $this;
}
/**
* {@inheritDoc}
*/
public function isFetchingBody(): bool
{
return $this->fetchBody;
}
/**
* {@inheritDoc}
*/
public function isFetchingFlags(): bool
{
return $this->fetchFlags;
}
/**
* {@inheritDoc}
*/
public function isFetchingHeaders(): bool
{
return $this->fetchHeaders;
}
/**
* {@inheritDoc}
*/
public function isFetchingSize(): bool
{
return $this->fetchSize;
}
/**
* {@inheritDoc}
*/
public function isFetchingBodyStructure(): bool
{
return $this->fetchBodyStructure;
}
/**
* {@inheritDoc}
*/
public function withFlags(): MessageQueryInterface
{
return $this->setFetchFlags(true);
}
/**
* {@inheritDoc}
*/
public function withBody(): MessageQueryInterface
{
return $this->setFetchBody(true);
}
/**
* {@inheritDoc}
*/
public function withHeaders(): MessageQueryInterface
{
return $this->setFetchHeaders(true);
}
/**
* {@inheritDoc}
*/
public function withSize(): MessageQueryInterface
{
return $this->setFetchSize(true);
}
/**
* {@inheritDoc}
*/
public function withBodyStructure(): MessageQueryInterface
{
return $this->setFetchBodyStructure(true);
}
/**
* {@inheritDoc}
*/
public function withoutBody(): MessageQueryInterface
{
return $this->setFetchBody(false);
}
/**
* {@inheritDoc}
*/
public function withoutHeaders(): MessageQueryInterface
{
return $this->setFetchHeaders(false);
}
/**
* {@inheritDoc}
*/
public function withoutFlags(): MessageQueryInterface
{
return $this->setFetchFlags(false);
}
/**
* {@inheritDoc}
*/
public function withoutSize(): MessageQueryInterface
{
return $this->setFetchSize(false);
}
/**
* {@inheritDoc}
*/
public function withoutBodyStructure(): MessageQueryInterface
{
return $this->setFetchBodyStructure(false);
}
/**
* Set whether to fetch the flags.
*/
protected function setFetchFlags(bool $fetchFlags): MessageQueryInterface
{
$this->fetchFlags = $fetchFlags;
return $this;
}
/**
* Set the fetch body flag.
*/
protected function setFetchBody(bool $fetchBody): MessageQueryInterface
{
$this->fetchBody = $fetchBody;
return $this;
}
/**
* Set whether to fetch the headers.
*/
protected function setFetchHeaders(bool $fetchHeaders): MessageQueryInterface
{
$this->fetchHeaders = $fetchHeaders;
return $this;
}
/**
* Set whether to fetch the size.
*/
protected function setFetchSize(bool $fetchSize): MessageQueryInterface
{
$this->fetchSize = $fetchSize;
return $this;
}
/**
* Set whether to fetch the body structure.
*/
protected function setFetchBodyStructure(bool $fetchBodyStructure): MessageQueryInterface
{
$this->fetchBodyStructure = $fetchBodyStructure;
return $this;
}
/** {@inheritDoc} */
public function setFetchOrder(string $fetchOrder): MessageQueryInterface
{
$fetchOrder = strtolower($fetchOrder);
if (in_array($fetchOrder, ['asc', 'desc'])) {
$this->fetchOrder = $fetchOrder;
}
return $this;
}
/**
* {@inheritDoc}
*/
public function getFetchOrder(): string
{
return $this->fetchOrder;
}
/**
* {@inheritDoc}
*/
public function setFetchOrderAsc(): MessageQueryInterface
{
return $this->setFetchOrder('asc');
}
/**
* {@inheritDoc}
*/
public function setFetchOrderDesc(): MessageQueryInterface
{
return $this->setFetchOrder('desc');
}
/**
* {@inheritDoc}
*/
public function oldest(): MessageQueryInterface
{
return $this->setFetchOrder('asc');
}
/**
* {@inheritDoc}
*/
public function newest(): MessageQueryInterface
{
return $this->setFetchOrder('desc');
}
/**
* {@inheritDoc}
*/
public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface
{
if (is_string($key)) {
$key = ImapSortKey::from(strtoupper($key));
}
$this->sortKey = $key;
return $this;
}
/**
* {@inheritDoc}
*/
public function getSortKey(): ?ImapSortKey
{
return $this->sortKey;
}
/**
* {@inheritDoc}
*/
public function setSortDirection(string $direction): MessageQueryInterface
{
$direction = strtolower($direction);
if (in_array($direction, ['asc', 'desc'])) {
$this->sortDirection = $direction;
}
return $this;
}
/**
* {@inheritDoc}
*/
public function getSortDirection(): string
{
return $this->sortDirection;
}
/**
* {@inheritDoc}
*/
public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface
{
return $this->setSortKey($key)->setSortDirection($direction);
}
/**
* {@inheritDoc}
*/
public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface
{
return $this->sortBy($key, 'desc');
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Support;
use BadMethodCallException;
use Error;
trait ForwardsCalls
{
/**
* Forward a method call to the given object.
*/
protected function forwardCallTo(object $object, string $method, array $parameters): mixed
{
try {
return $object->{$method}(...$parameters);
} catch (Error|BadMethodCallException $e) {
$pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~';
if (! preg_match($pattern, $e->getMessage(), $matches)) {
throw $e;
}
if ($matches['class'] != get_class($object) ||
$matches['method'] != $method) {
throw $e;
}
static::throwBadMethodCallException($method);
}
}
/**
* Throw a bad method call exception for the given method.
*/
protected static function throwBadMethodCallException(string $method): never
{
throw new BadMethodCallException(sprintf(
'Call to undefined method %s::%s()', static::class, $method
));
}
}

View File

@@ -1,313 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Support;
use BackedEnum;
class Str
{
/**
* Make a list with literals or nested lists.
*/
public static function list(array $list): string
{
$values = [];
foreach ($list as $value) {
if (is_array($value)) {
$values[] = static::list($value);
} else {
$values[] = $value;
}
}
return sprintf('(%s)', implode(' ', $values));
}
/**
* Make one or more literals.
*/
public static function literal(array|string $string): array|string
{
if (is_array($string)) {
$result = [];
foreach ($string as $value) {
$result[] = static::literal($value);
}
return $result;
}
if (str_contains($string, "\n")) {
return ['{'.strlen($string).'}', $string];
}
return '"'.static::escape($string).'"';
}
/**
* Resolve the value of the given enums.
*/
public static function enums(BackedEnum|array|string|null $enums = null): array|string|null
{
if (is_null($enums)) {
return null;
}
if (is_array($enums)) {
return array_map([static::class, 'enums'], $enums);
}
return Str::enum($enums);
}
/**
* Resolve the value of the given enum.
*/
public static function enum(BackedEnum|string $enum): string
{
if ($enum instanceof BackedEnum) {
return $enum->value;
}
return (string) $enum;
}
/**
* Make a range set for use in a search command.
*/
public static function set(int|string|array $from, int|float|string|null $to = null): string
{
// If $from is an array with multiple elements, return them as a comma-separated list.
if (is_array($from) && count($from) > 1) {
return implode(',', $from);
}
// If $from is an array with a single element, return that element.
if (is_array($from) && count($from) === 1) {
return (string) reset($from);
}
// At this point, $from is an integer. No upper bound provided, return $from as a string.
if (is_null($to)) {
return (string) $from;
}
// If the upper bound is infinite, use the '*' notation.
if ($to == INF) {
return $from.':*';
}
// Otherwise, return a typical range string.
return $from.':'.$to;
}
/**
* Make a credentials string for use in the AUTHENTICATE command.
*/
public static function credentials(string $user, string $token): string
{
return base64_encode("user=$user\1auth=Bearer $token\1\1");
}
/**
* Prefix a string with the given prefix if it does not already start with it.
*/
public static function prefix(string $value, string $prefix): string
{
return str_starts_with($value, $prefix) ? $value : $prefix.$value;
}
/**
* Escape a string for use in a list.
*/
public static function escape(string $string): string
{
// Remove newlines and control characters (ASCII 0-31 and 127).
$string = preg_replace('/[\r\n\x00-\x1F\x7F]/', '', $string);
// Escape backslashes first to avoid double-escaping and then escape double quotes.
return str_replace(['\\', '"'], ['\\\\', '\\"'], $string);
}
/**
* Decode a modified UTF-7 string (IMAP specific) to UTF-8.
*/
public static function fromImapUtf7(string $string): string
{
// If the string doesn't contain any '&' character, it's not UTF-7 encoded.
if (! str_contains($string, '&')) {
return $string;
}
// Handle the special case of '&-' which represents '&' in UTF-7.
if ($string === '&-') {
return '&';
}
// Direct implementation of IMAP's modified UTF-7 decoding.
return preg_replace_callback('/&([^-]*)-?/', function ($matches) {
/** @var array{0: string, 1: string, 2?: string} $matches */
// If it's just an ampersand.
if ($matches[1] === '') {
return '&';
}
// If it's the special case for ampersand.
if ($matches[1] === '-') {
return '&';
}
// Convert modified base64 to standard base64.
$base64 = strtr($matches[1], ',', '/');
// Add padding if necessary.
switch (strlen($base64) % 4) {
case 1: $base64 .= '===';
break;
case 2: $base64 .= '==';
break;
case 3: $base64 .= '=';
break;
}
// Decode base64 to binary.
$binary = base64_decode($base64, true);
if ($binary === false) {
// If decoding fails, return the original string.
return '&'.$matches[1].($matches[2] ?? '');
}
$result = '';
// Convert binary UTF-16BE to UTF-8.
for ($i = 0; $i < strlen($binary); $i += 2) {
if (isset($binary[$i + 1])) {
$char = (ord($binary[$i]) << 8) | ord($binary[$i + 1]);
if ($char < 0x80) {
$result .= chr($char);
} elseif ($char < 0x800) {
$result .= chr(0xC0 | ($char >> 6)).chr(0x80 | ($char & 0x3F));
} else {
$result .= chr(0xE0 | ($char >> 12)).chr(0x80 | (($char >> 6) & 0x3F)).chr(0x80 | ($char & 0x3F));
}
}
}
return $result;
}, $string);
}
/**
* Encode a UTF-8 string to modified UTF-7 (IMAP specific).
*/
public static function toImapUtf7(string $string): string
{
$result = '';
$buffer = '';
// Iterate over each character in the UTF-8 string.
for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
$char = mb_substr($string, $i, 1, 'UTF-8');
// Convert character to its UTF-16BE code unit (for deciding if ASCII).
$ord = unpack('n', mb_convert_encoding($char, 'UTF-16BE', 'UTF-8'))[1];
// Handle printable ASCII characters (0x20 - 0x7E) except '&'
if ($ord >= 0x20 && $ord <= 0x7E && $char !== '&') {
// If there is any buffered non-ASCII content, flush it as a base64 section.
if ($buffer !== '') {
// Encode the buffer to UTF-16BE, then to base64, swap '/' for ',', trim '=' padding, and wrap with '&' and '-'.
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
$buffer = '';
}
// Append the ASCII character as-is.
$result .= $char;
continue;
}
// Special handling for literal '&' which becomes '&-'
if ($char === '&') {
// Flush any buffered non-ASCII content first.
if ($buffer !== '') {
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
$buffer = '';
}
// '&' is encoded as '&-'
$result .= '&-';
continue;
}
// Buffer non-ASCII characters for later base64 encoding.
$buffer .= $char;
}
// After the loop, flush any remaining buffered non-ASCII content.
if ($buffer !== '') {
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
}
return $result;
}
/**
* Determine if a given string matches a given pattern.
*/
public static function is(array|string $pattern, string $value, bool $ignoreCase = false): bool
{
if (! is_iterable($pattern)) {
$pattern = [$pattern];
}
foreach ($pattern as $pattern) {
$pattern = (string) $pattern;
// If the given value is an exact match we can of course return true right
// from the beginning. Otherwise, we will translate asterisks and do an
// actual pattern match against the two strings to see if they match.
if ($pattern === '*' || $pattern === $value) {
return true;
}
if ($ignoreCase && mb_strtolower($pattern) === mb_strtolower($value)) {
return true;
}
$pattern = preg_quote($pattern, '#');
// Asterisks are translated into zero-or-more regular expression wildcards
// to make it convenient to check if the strings starts with the given
// pattern such as "library/*", making any string check convenient.
$pattern = str_replace('\*', '.*', $pattern);
if (preg_match('#^'.$pattern.'\z#'.($ignoreCase ? 'isu' : 'su'), $value) === 1) {
return true;
}
}
return false;
}
/**
* Decode MIME-encoded header values.
*/
public static function decodeMimeHeader(string $value): string
{
if (! str_contains($value, '=?')) {
return $value;
}
if ($decoded = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8')) {
return $decoded;
}
return $value;
}
}

View File

@@ -1,245 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use DirectoryTree\ImapEngine\ComparesFolders;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use DirectoryTree\ImapEngine\MessageQueryInterface;
use DirectoryTree\ImapEngine\Support\Str;
class FakeFolder implements FolderInterface
{
use ComparesFolders;
/**
* Constructor.
*/
public function __construct(
protected string $path = '',
protected array $flags = [],
/** @var FakeMessage[] */
protected array $messages = [],
protected string $delimiter = '/',
protected ?MailboxInterface $mailbox = null,
) {}
/**
* {@inheritDoc}
*/
public function mailbox(): MailboxInterface
{
return $this->mailbox ?? throw new Exception('Folder has no mailbox.');
}
/**
* {@inheritDoc}
*/
public function path(): string
{
return $this->path;
}
/**
* {@inheritDoc}
*/
public function flags(): array
{
return $this->flags;
}
/**
* {@inheritDoc}
*/
public function delimiter(): string
{
return $this->delimiter;
}
/**
* {@inheritDoc}
*/
public function name(): string
{
return Str::fromImapUtf7(
last(explode($this->delimiter, $this->path))
);
}
/**
* {@inheritDoc}
*/
public function is(FolderInterface $folder): bool
{
return $this->isSameFolder($this, $folder);
}
/**
* {@inheritDoc}
*/
public function messages(): MessageQueryInterface
{
// Ensure the folder is selected.
$this->select(true);
return new FakeMessageQuery($this);
}
/**
* {@inheritDoc}
*/
public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void
{
foreach ($this->messages as $message) {
$callback($message);
}
}
/**
* {@inheritDoc}
*/
public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void
{
foreach ($this->messages as $message) {
$callback($message);
}
}
/**
* {@inheritDoc}
*/
public function move(string $newPath): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function select(bool $force = false): void
{
$this->mailbox?->select($this, $force);
}
/**
* {@inheritDoc}
*/
public function status(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function examine(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function expunge(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function quota(): array
{
return [
$this->path => [
'STORAGE' => [
'usage' => 0,
'limit' => 0,
],
'MESSAGE' => [
'usage' => 0,
'limit' => 0,
],
],
];
}
/**
* {@inheritDoc}
*/
public function delete(): void
{
// Do nothing.
}
/**
* Set the folder's path.
*/
public function setPath(string $path): FakeFolder
{
$this->path = $path;
return $this;
}
/**
* Set the folder's flags.
*/
public function setFlags(array $flags): FakeFolder
{
$this->flags = $flags;
return $this;
}
/**
* Set the folder's mailbox.
*/
public function setMailbox(MailboxInterface $mailbox): FakeFolder
{
$this->mailbox = $mailbox;
return $this;
}
/**
* Set the folder's messages.
*
* @param FakeMessage[] $messages
*/
public function setMessages(array $messages): FakeFolder
{
$this->messages = $messages;
return $this;
}
/**
* Get the folder's messages.
*
* @return FakeMessage[]
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Add a message to the folder.
*/
public function addMessage(FakeMessage $message): void
{
$this->messages[] = $message;
}
/**
* Set the folder's delimiter.
*/
public function setDelimiter(string $delimiter = '/'): FakeFolder
{
$this->delimiter = $delimiter;
return $this;
}
}

View File

@@ -1,77 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use DirectoryTree\ImapEngine\Collections\FolderCollection;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\FolderRepositoryInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Support\ItemNotFoundException;
class FakeFolderRepository implements FolderRepositoryInterface
{
/**
* Constructor.
*/
public function __construct(
protected MailboxInterface $mailbox,
/** @var FolderInterface[] */
protected array $folders = []
) {}
/**
* {@inheritDoc}
*/
public function find(string $path): ?FolderInterface
{
try {
return $this->findOrFail($path);
} catch (ItemNotFoundException) {
return null;
}
}
/**
* {@inheritDoc}
*/
public function findOrFail(string $path): FolderInterface
{
return $this->get()->firstOrFail(
fn (FolderInterface $folder) => strtolower($folder->path()) === strtolower($path)
);
}
/**
* {@inheritDoc}
*/
public function create(string $path): FolderInterface
{
return $this->folders[] = new FakeFolder($path, mailbox: $this->mailbox);
}
/**
* {@inheritDoc}
*/
public function firstOrCreate(string $path): FolderInterface
{
return $this->find($path) ?? $this->create($path);
}
/**
* {@inheritDoc}
*/
public function get(?string $match = '*', ?string $reference = ''): FolderCollection
{
$folders = FolderCollection::make($this->folders);
// If we're not matching all, filter the folders by the match pattern.
if (! in_array($match, ['*', null])) {
return $folders->filter(
fn (FolderInterface $folder) => Str::is($match, $folder->path())
);
}
return $folders;
}
}

View File

@@ -1,123 +0,0 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\FolderRepositoryInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
class FakeMailbox implements MailboxInterface
{
/**
* The currently selected folder.
*/
protected ?FolderInterface $selected = null;
/**
* Constructor.
*/
public function __construct(
protected array $config = [],
/** @var FakeFolder[] */
protected array $folders = [],
protected array $capabilities = [],
) {
foreach ($folders as $folder) {
$folder->setMailbox($this);
}
}
/**
* {@inheritDoc}
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if (is_null($key)) {
return $this->config;
}
return data_get($this->config, $key, $default);
}
/**
* {@inheritDoc}
*/
public function connection(): ConnectionInterface
{
throw new Exception('Unsupported.');
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return true;
}
/**
* {@inheritDoc}
*/
public function reconnect(): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function connect(?ConnectionInterface $connection = null): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function inbox(): FolderInterface
{
return $this->folders()->findOrFail('inbox');
}
/**
* {@inheritDoc}
*/
public function folders(): FolderRepositoryInterface
{
return new FakeFolderRepository($this, $this->folders);
}
/**
* {@inheritDoc}
*/
public function capabilities(): array
{
return $this->capabilities;
}
/**
* {@inheritDoc}
*/
public function select(FolderInterface $folder, bool $force = false): void
{
$this->selected = $folder;
}
/**
* {@inheritDoc}
*/
public function selected(FolderInterface $folder): bool
{
return $this->selected?->is($folder) ?? false;
}
}

Some files were not shown because too many files have changed in this diff Show More